概述
Zig的动态内存方法是显式的、可组合的和可测试的。API接受一个std.mem.Allocator并将所有权清晰地返回给调用者,而不是将分配隐藏在隐式全局变量后面。本章展示了核心分配器接口(alloc、free、realloc、resize、create、destroy),介绍了最常见的分配器实现(页分配器、带泄漏检测的Debug/GPA、竞技场和固定缓冲区),并建立了通过你自己的API传递分配器的模式(参见Allocator.zig和heap.zig)。
你将学习何时优先使用批量释放竞技场,如何使用固定堆栈缓冲区来消除堆流量,以及如何安全地增长和缩小分配。这些技能支撑了本书的其余部分——从集合到I/O适配器——并将使后续项目更快、更健壮(参见03)。
学习目标
- 使用
std.mem.Allocator来分配、释放和调整类型化切片和单个项目的大小。 - 选择分配器:页分配器、Debug/GPA(泄漏检测)、竞技场、固定缓冲区或堆栈回退组合。
- 设计接受分配器并将拥有的内存返回给调用者的函数(参见08)。
分配器接口
Zig的分配器是一个小巧的值类型接口,具有类型化分配和显式释放的方法。包装器处理哨兵和对齐,因此大部分时间你可以保持在[]T级别。
alloc/free、create/destroy和哨兵
基本操作:分配一个类型化切片,修改其元素,然后释放。对于单个项目,优先使用create/destroy。当需要C互操作的null终止符时,使用allocSentinel(或dupeZ)。
const std = @import("std");
pub fn main() !void {
const allocator = std.heap.page_allocator; // 操作系统支持;快速且简单
// 分配一个小缓冲区并填充它
const buf = try allocator.alloc(u8, 5);
defer allocator.free(buf);
for (buf, 0..) |*b, i| b.* = 'a' + @as(u8, @intCast(i));
std.debug.print("buf: {s}\n", .{buf});
// 创建/销毁单个项目
const Point = struct { x: i32, y: i32 };
const p = try allocator.create(Point);
defer allocator.destroy(p);
p.* = .{ .x = 7, .y = -3 };
std.debug.print("point: (x={}, y={})\n", .{ p.x, p.y });
// 分配空终止字符串(哨兵)。非常适合C API
var hello = try allocator.allocSentinel(u8, 5, 0);
defer allocator.free(hello);
@memcpy(hello[0..5], "hello");
std.debug.print("zstr: {s}\n", .{hello});
}
$ zig run alloc_free_basics.zigbuf: abcde
point: (x=7, y=-3)
zstr: hello优先使用{s}来打印[]const u8切片(不需要终止符)。当与需要尾随\0的API进行互操作时,使用allocSentinel或dupeZ。
分配器接口底层工作原理
std.mem.Allocator类型是一个使用指针和虚函数表的类型擦除接口。这种设计允许任何分配器实现通过相同的接口传递,为常见情况启用运行时多态性而无需虚拟分派开销。
虚函数表包含四个基本操作:
- alloc:返回指向
len字节的指针,具有指定的对齐方式,失败时返回错误 - resize:尝试原地扩展或缩小内存,返回
bool - remap:尝试扩展或缩小内存,允许重新定位(由
realloc使用) - free:释放并使内存区域无效
高级API(create、destroy、alloc、free、realloc)使用类型安全、符合人体工程学的方法包装这些虚函数表函数。这种两层设计使分配器实现保持简单,同时为用户提供方便的类型化分配(参见Allocator.zig)。
Debug/GPA和竞技场分配器
对于整个程序工作,Debug/GPA是默认选择:它跟踪分配并在deinit()时报告泄漏。对于作用域内的临时分配,竞技场在deinit()期间一次性返回所有内容。
const std = @import("std");
pub fn main() !void {
// GeneralPurposeAllocator with leak detection on deinit.
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
defer {
const leaked = gpa.deinit() == .leak;
if (leaked) @panic("leak detected");
}
const alloc = gpa.allocator();
const nums = try alloc.alloc(u64, 4);
defer alloc.free(nums);
for (nums, 0..) |*n, i| n.* = @as(u64, i + 1);
var sum: u64 = 0;
for (nums) |n| sum += n;
std.debug.print("gpa sum: {}\n", .{sum});
// 区域分配器:通过deinit进行批量释放。
var arena_inst = std.heap.ArenaAllocator.init(alloc);
defer arena_inst.deinit();
const arena = arena_inst.allocator();
const msg = try arena.dupe(u8, "temporary allocations live here");
std.debug.print("arena msg len: {}\n", .{msg.len});
}
$ zig run gpa_arena.ziggpa sum: 10
arena msg len: 31在Zig 0.15.x中,std.heap.GeneralPurposeAllocator是Debug分配器的薄别名。始终检查deinit()的返回值:.leak表示某些内容未被释放。
选择和组合分配器
分配器是常规值:你可以传递它们、包装它们和组合它们。两个主力工具是固定缓冲区分配器(用于栈支持的分配突发)和用于动态增长和缩小的realloc/resize。
包装分配器以增强安全性和调试
因为分配器只是具有公共接口的值,你可以包装一个分配器来添加功能。std.mem.validationWrap函数通过在委托给底层分配器之前添加安全检查来演示这种模式。
ValidationAllocator包装器验证:
- 分配大小大于零
- 返回的指针具有正确的对齐方式
- 内存长度在resize/free操作中有效
这种模式很强大:你可以构建自定义分配器包装器,添加日志记录、指标收集、内存限制或其他横切关注点,而无需修改底层分配器。包装器在执行其检查或副作用后简单地委托给underlying_allocator。mem.zig
栈上的固定缓冲区
使用FixedBufferAllocator从栈数组获取快速、零系统调用的分配。当你用完时,你会得到error.OutOfMemory——这正是你需要回退或修剪输入的确切信号。
const std = @import("std");
pub fn main() !void {
var backing: [32]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&backing);
const A = fba.allocator();
// 3 small allocations should fit.
const a = try A.alloc(u8, 8);
const b = try A.alloc(u8, 8);
const c = try A.alloc(u8, 8);
_ = a;
_ = b;
_ = c;
// 这个应该会失败(总容量32,已用24)。
if (A.alloc(u8, 16)) |_| {
std.debug.print("unexpected success\n", .{});
} else |err| switch (err) {
error.OutOfMemory => std.debug.print("fixed buffer OOM as expected\n", .{}),
else => return err,
}
}
$ zig run fixed_buffer.zigfixed buffer OOM as expected为了优雅地回退,使用std.heap.stackFallback(N, fallback)在较慢的分配器上组合固定缓冲区。返回的对象有一个.get()方法,每次都会产生一个新的Allocator。
使用realloc/resize安全地增长和缩小
realloc返回一个新的切片(可能会移动分配)。resize尝试原地改变长度并返回bool;成功后记得也要更新切片的len。
const std = @import("std");
pub fn main() !void {
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
defer { _ = gpa.deinit(); }
const alloc = gpa.allocator();
var buf = try alloc.alloc(u8, 4);
defer alloc.free(buf);
for (buf, 0..) |*b, i| b.* = 'A' + @as(u8, @intCast(i));
std.debug.print("len={} contents={s}\n", .{ buf.len, buf });
// 使用 realloc 增长(可能会移动内存)。
buf = try alloc.realloc(buf, 8);
for (buf[4..], 0..) |*b, i| b.* = 'a' + @as(u8, @intCast(i));
std.debug.print("grown len={} contents={s}\n", .{ buf.len, buf });
// 使用 resize 原地缩小;记得切片。
if (alloc.resize(buf, 3)) {
buf = buf[0..3];
std.debug.print("shrunk len={} contents={s}\n", .{ buf.len, buf });
} else {
// 当分配器不支持原地缩小时的回退方案。
buf = try alloc.realloc(buf, 3);
std.debug.print("shrunk (realloc) len={} contents={s}\n", .{ buf.len, buf });
}
}
$ zig run resize_and_realloc.ziglen=4 contents=ABCD
grown len=8 contents=ABCDabcd
shrunk (realloc) len=3 contents=ABC在resize(buf, n) == true之后,旧的buf仍然保持其先前的len。重新切片它(buf = buf[0..n])以便下游代码看到新的长度。
对齐系统底层工作原理
Zig的内存系统使用紧凑的2的幂次方对齐表示。std.mem.Alignment枚举将对齐存储为log₂值,允许高效存储同时提供丰富的实用方法。
这种紧凑表示提供了以下实用方法:
- 与字节单位之间的转换:
@"16".toByteUnits()返回16,fromByteUnits(16)返回@"16" - 向前对齐地址:
forward(addr)向上舍入到下一个对齐边界 - 向后对齐地址:
backward(addr)向下舍入到前一个对齐边界 - 检查对齐:
check(addr)如果地址满足对齐要求则返回true - 类型对齐:
of(T)返回类型T的对齐方式
当你看到alignedAlloc(T, .@"16", n)或在自定义分配器中使用对齐时,你正在使用这种log₂表示。紧凑的存储允许Zig高效地跟踪对齐而不会浪费空间(参见mem.zig)。
分配器作为参数模式
你的API应该接受一个分配器并将拥有的内存返回给调用者。这保持了生命周期的明确性,并让你的用户根据其上下文选择合适的分配器(临时使用竞技场,一般用途使用GPA,可用时使用固定缓冲区)。
const std = @import("std");
fn joinSep(allocator: std.mem.Allocator, parts: []const []const u8, sep: []const u8) ![]u8 {
var total: usize = 0;
for (parts) |p| total += p.len;
if (parts.len > 0) total += sep.len * (parts.len - 1);
var out = try allocator.alloc(u8, total);
var i: usize = 0;
for (parts, 0..) |p, idx| {
@memcpy(out[i .. i + p.len], p);
i += p.len;
if (idx + 1 < parts.len) {
@memcpy(out[i .. i + sep.len], sep);
i += sep.len;
}
}
return out;
}
pub fn main() !void {
// 使用GPA构建字符串,然后释放。
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
defer {
_ = gpa.deinit();
}
const A = gpa.allocator();
const joined = try joinSep(A, &.{ "zig", "likes", "allocators" }, "-");
defer A.free(joined);
std.debug.print("gpa: {s}\n", .{joined});
// 尝试使用一个小的固定缓冲区来演示内存不足(OOM)。
var buf: [8]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buf);
const B = fba.allocator();
if (joinSep(B, &.{ "this", "is", "too", "big" }, ",")) |s| {
// 如果它意外地适配了,就释放它(这里16字节不太可能)。
B.free(s);
std.debug.print("fba unexpectedly succeeded\n", .{});
} else |err| switch (err) {
error.OutOfMemory => std.debug.print("fba: OOM as expected\n", .{}),
else => return err,
}
}
$ zig run allocator_parameter.ziggpa: zig-likes-allocators
fba: OOM as expected返回[]u8(或[]T)将所有权清晰地转移给调用者;记录调用者必须free。如果可以,提供一个comptime友好的变体,写入调用者提供的缓冲区。04
注意事项
- 释放你分配的内容。在本书中,示例在成功的
alloc后立即使用defer allocator.free(buf)。 - 缩小:优先使用
resize进行原地缩小;如果返回false则回退到realloc。 - 竞技场:永远不要将竞技场拥有的内存返回给长生命周期的调用者。竞技场内存在
deinit()时死亡。 - GPA/Debug:检查
deinit()并将泄漏检测集成到使用std.testing的测试中(参见testing.zig)。 - 固定缓冲区:适用于有界工作负载;与
stackFallback结合使用以优雅降级。
练习
- 实现
splitJoin(allocator, s: []const u8, needle: u8) ![]u8,按字节分割并用'-'重新连接。添加一个写入调用者缓冲区的变体。 - 重写你之前的一个CLI工具以接受来自
main的分配器并贯穿使用。尝试使用ArenaAllocator处理临时缓冲区。06 - 用
stackFallback包装FixedBufferAllocator,并展示相同的函数如何在小输入上成功但在较大输入上回退。
替代方案与边缘情况
- 对齐敏感分配:使用
alignedAlloc(T, .@"16", n)或传播对齐的类型化助手。 - 接口支持零大小类型和零长度切片;不要特殊处理它们。
- C互操作:链接libc时,考虑使用
c_allocator/raw_c_allocator以匹配外部分配语义;否则优先使用页分配器/GPA。