Zig 源码告诉你什么是真正的手动内存自由(第10篇)
在很多现代语言里,“内存管理”要么被自动藏起来(GC),要么被借用检查器严格约束(Rust)。而 Zig 把选择权完完全全交还给了程序员,却没有让这件事变得痛苦。
这期我们直接看 Zig 标准库源码,看看它是怎么用最朴素、最显式的方式实现“手动内存自由”的,同时还能写出可读性高、泄漏可检测、性能可控的代码。
1. Zig 从不偷偷给你分配内存
这是 Zig 内存哲学最核心的一条:标准库里凡是可能分配内存的函数,都必须显式接收一个 std.mem.Allocator 参数。
看几个典型 std 源码例子(基于 master 分支,路径大致为 lib/std/…):
// std/ArrayList.zig (简化版核心结构)
pub fn ArrayList(comptime T: type) type {
return struct {
items: []T = &[_]T{},
capacity: usize = 0,
allocator: Allocator,
pub fn init(allocator: Allocator) @This() { ... }
pub fn deinit(self: *@This()) void {
self.allocator.free(self.items);
}
pub fn append(self: *@This(), item: T) !void {
if (self.items.len == self.capacity) {
// 一定会看到 grow 操作 → 分配新内存
try self.ensureTotalCapacityPrecise(self.items.len + 1);
}
self.items[self.items.len] = item;
self.items.len += 1;
}
};
}
关键点:
-
没有全局 allocator -
没有隐式堆分配 -
想用 ArrayList,必须自己传 allocator -
想销毁,必须显式调用 deinit()
这和很多语言的 vector、list 形成了鲜明对比——它们往往偷偷用线程局部的默认堆。
2. Allocator 接口本身极简,却能力极强
看最核心的定义(lib/std/mem/Allocator.zig):
pub const Allocator = struct {
ptr: *anyopaque,
vtable: *const VTable,
pub const VTable = struct {
alloc: *const fn (
ctx: *anyopaque,
len: usize,
ptr_align: u8,
ret_addr: usize,
) ?[*]u8,
resize: *const fn (
ctx: *anyopaque,
buf: []u8,
buf_align: u8,
new_len: usize,
ret_addr: usize,
) bool,
free: *const fn (
ctx: *anyopaque,
buf: []u8,
buf_align: u8,
ret_addr: usize,
) void,
};
// 便利方法
pub fn alloc(self: Allocator, comptime T: type, n: usize) Error![]T { ... }
pub fn create(self: Allocator, comptime T: type) Error!*T { ... }
pub fn free(self: Allocator, memory: anytype) void { ... }
...
};
只有三个虚函数:alloc / resize / free。
却能实现几乎所有内存策略:
-
page_allocator(直接系统调用 mmap/malloc) -
ArenaAllocator(只分配,最后一次性释放) -
FixedBufferAllocator(栈上/静态缓冲区) -
GeneralPurposeAllocator(带泄漏检测、双端链表、哨兵字节) -
DebugAllocator(更强的边界检查、毒化内存) -
LogAllocator、ThreadSafe、CAllocator、……
你甚至可以几行代码自己写一个符合这个 vtable 的自定义分配器。
3. defer + errdefer 让手动释放不再痛苦
Zig 不靠 RAII,而是靠更轻量的 defer:
pub fn jsonParse(comptime T: type, allocator: Allocator, source: []const u8) !T {
var parser = std.json.Parser.init(allocator, .alloc_if_needed);
defer parser.deinit();
var tree = try parser.parse(source);
defer tree.deinit();
return std.json.parseFromTokenSource(T, &parser, tree.root, .{});
}
哪怕中间有十几层嵌套错误返回,defer 也会在函数退出时按逆序执行清理。
这比 C 的 goto 清理模式清晰得多,比 RAII 类更直观(你一眼就能看到资源在哪释放)。
4. 真正“自由”的例子:ArenaAllocator 的使用模式
最能体现 Zig 内存自由的写法之一就是 Arena + 一级分配器:
pub fn doComplexWork(allocator: Allocator) !void {
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit(); // ← 一句释放全部
const aa = arena.allocator(); // 子分配器
var list = std.ArrayList(u32).init(aa);
var map = std.StringHashMap([]const u8).init(aa);
var tree = std.json.ValueTree.init(aa); // 随便用多少层嵌套结构
// 中间几百行代码,疯狂 append、put、parse……
// 全都不用单独 free!
// 函数结束 → arena.deinit() → 全部归还
}
这种模式在:
-
编译器 / 解释器的一次请求处理 -
游戏的一帧逻辑 -
命令行工具的整个生命周期 -
测试用例
……里极其常见。它把“手动内存”变成了“作用域内存”,既安全又高效。
5. 标准库自带的泄漏检测有多狠?
把下面代码编译运行:
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer std.debug.assert(gpa.deinit() == .ok); // ← 这里会检测
const alloc = gpa.allocator();
_ = try alloc.alloc(u8, 4096); // 故意不 free
}
你会看到类似输出:
error(gpa): memory address 0x... leaked:
C:\...\main.zig:XX:36 in main (exe)
_ = try alloc.alloc(u8, 4096);
^
这是在编译期就能选开/关的检测机制,而不是运行时开销很大的 valgrind。
总结:Zig 给你的内存自由长什么样?
-
完全显式:没有默认 allocator,没有隐式分配 -
完全可换:同一个接口支持十几种策略 -
完全可检:DebugAllocator / GeneralPurposeAllocator 能抓泄漏、越界 -
语法友好:defer + errdefer + Arena 让手动管理写起来像自动管理一样顺 -
零成本抽象:你选 page_allocator 就是接近 malloc 的性能,你选 Arena 就是接近栈分配的性能
真正的内存自由,不是“随便写随便用”,而是我知道每一块内存从哪来,到哪去,我能精确控制它,我也能轻松验证它。
欢迎关注「Zig 源码学习」系列,第11篇见~
(欢迎留言你最喜欢的 Zig 内存使用模式,或者你写过的最爽的一次 Arena 使用经历)
夜雨聆风