Zig 自定义分配器:从 Allocator 接口到内存池与组合

Zig 把内存分配显式化为一个可替换的接口:std.mem.Allocator 的 vtable 设计让 Arena、FixedBuffer、GPA、栈回退与内存池都能自由组合。本文讲解 Allocator 接口的本质与 vtable 结构、标准分配器家族的适用场景、GPA 的元数据与泄漏检测机制、如何从零编写自定义分配器、分配器包装(计数/日志/失败注入)以及真实项目中的场景化选型。

引言

多数语言把「内存从哪来」藏进运行时:Go 有 GC、Java 有堆管理器、Rust 用 GlobalAlloc 但日常代码几乎不感知。Zig 反其道而行——没有全局分配器,任何需要内存的函数都必须显式接收一个 std.mem.Allocator 参数。这条规则看起来啰嗦,却换来三个直接收益:分配行为完全可预测、测试时能注入检测分配器、不同生命周期与不同负载可以用完全不同的策略。

正因为 Allocator 是一个只有四个函数指针的接口,你可以在几十分钟内写出一个契合自己业务的分配器:为每帧渲染准备的 bump 分配器、为请求级临时数据准备的 Arena、为固定大小对象准备的 freelist 内存池、为调试准备的记账分配器。它们还能像管道一样组合起来。

前置:内存管理与分配器模式、测试与代码质量:testing.allocator 泄漏检测。


目录


1. Allocator 接口的本质

std.mem.Allocator 是一个胖指针:一个 *anyopaque 上下文加一张虚函数表。四个函数各有明确分工(签名以 Zig 0.14 的 Alignment 参数形式为准):

pub const Allocator = struct {
    ptr: *anyopaque,
    vtable: *const VTable,

    pub const VTable = struct {
        alloc: *const fn (ctx: *anyopaque, len: usize, alignment: Alignment, ret_addr: usize) ?[*]u8,
        resize: *const fn (ctx: *anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) bool,
        remap: *const fn (ctx: *anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) ?[*]u8,
        free: *const fn (ctx: *anyopaque, memory: []u8, alignment: Alignment, ret_addr: usize) void,
    };
};
函数语义失败的含义
alloc申请 len 字节、满足 alignment返回 null 即 OutOfMemory
resize就地调整大小,不能移动返回 false,旧内存仍有效
remap调整大小,可以移动并返回新指针返回 null,旧内存仍有效
free释放—

resize 与 remap 的区别是接口设计的精髓:ArrayList.append 优先尝试 resize(无拷贝),失败再走 remap(可能拷贝)。自定义分配器只要把 resize/remap 留空(返回 false/null),语义上就自动退化成「永远重新分配 + 拷贝」,这是最省事的起点。

上层 API 全部建立在这四个原语之上:allocator.alloc(u8, 128) 拿切片、allocator.create(Node) 拿单对象、allocator.dupe(u8, "hi") 申请并拷贝、allocator.realloc(buf, 256) 调整大小,释放分别对应 free/destroy/free。

心法:Allocator 是值类型,传参时按值拷贝(两个指针而已)。所以「分配器」可以随意存进结构体、传给线程,不需要引用计数或所有权管理——它的生命周期由使用者自己保证。


2. 标准分配器家族

标准库内置了一批分配器,覆盖从「永不释放」到「生产级通用」的整个谱系:

分配器释放语义典型场景
std.heap.page_allocator每次分配一个页,free 立即归还大块内存、启动期
std.heap.ArenaAllocator只支持整体释放/重置请求级、每帧、AST
std.heap.FixedBufferAllocator只支持重置,不归还栈上临时、无堆环境
std.heap.GeneralPurposeAllocator逐个释放 + 泄漏检测通用生产与调试
std.heap.DebugAllocator同 GPA,0.14 起的更名版本测试与开发
std.heap.smp_allocator逐个释放,无泄漏检测生产默认(0.14+)
std.heap.stackFallback(n, child)栈内不释放,超出走 child小分配热路径
std.heap.c_allocator逐个释放与 C 库交互
std.heap.wasm_allocator逐个释放wasm32 目标
std.testing.allocator逐个释放 + 泄漏/越界检测单元测试
std.heap.LoggingAllocator透传 + 打印每次调用定位分配热点

选择的原则只有一条:先问生命周期,再问并发,最后才问性能。生命周期一致的分配可以用 Arena 一次性回收;跨线程共享才需要考虑 smp_allocator 或加锁包装。


3. ArenaAllocator 与生命周期管理

Arena 是最常用的策略型分配器:所有分配记录在一块连续区域里,deinit 或 reset 时一次性归还。它把 N 次 free 变成 1 次,也彻底消灭了「忘记释放某一项」的可能。

const std = @import("std");

fn processRequest(gpa: std.mem.Allocator, payload: []const u8) ![]u8 {
    var arena = std.heap.ArenaAllocator.init(gpa);
    defer arena.deinit();                 // 一次性释放本次请求的全部分配
    const a = arena.allocator();
    const tokens = try splitTokens(a, payload);   // 无需逐个 free
    const tree = try parseIntoTree(a, tokens);
    return try render(a, tree);                   // 返回的切片仍属 arena
}

三种重置方式,语义差别很大:

调用行为适用
arena.deinit()释放全部内存给子分配器生命周期结束
arena.reset(.free_all)保留自身结构,归还所有内存块释放压力优先
arena.reset(.retain_capacity)保留最大的一块缓冲区复用循环处理,性能优先
arena.reset(.{ .retain_with_limit = 64 * 1024 })只保留不超过上限的部分折中

典型的循环复用写法:每帧开头 const a = arena.allocator(); 分配、帧末 _ = arena.reset(.retain_capacity); 回收——缓冲被复用,没有一次系统调用。

Arena 的三个陷阱:

  • 返回的切片会随 arena 一起失效。跨出 deinit 作用域的指针立即悬空,必须 dupe 到长期分配器。
  • 单个大分配会拖大整个 arena。一次 1 MB 的临时分配会让 arena 常驻 1 MB,即使后面只用 1 KB。混合大小差异极大时,用两个 arena。
  • retain_capacity 会保留峰值内存。长跑服务里如果偶发一个巨大请求,峰值会被永久持有——此时改用 retain_with_limit。

提示:Arena 的 child_allocator 选择很关键——建在 page_allocator 上会频繁 syscall,建在 smp_allocator 上则更平滑。


4. FixedBufferAllocator 与栈上内存

FixedBufferAllocator 把一段现成的内存(通常是栈数组)当作堆来用,不做任何系统调用:

const std = @import("std");

fn handlePacket(bytes: []const u8) !void {
    var buf: [4096]u8 = undefined;               // 栈上 4 KB
    var fba = std.heap.FixedBufferAllocator.init(&buf);
    const a = fba.allocator();

    const header = try a.create(Header);
    const body = try a.alloc(u8, bytes.len);
    @memcpy(body, bytes);
    _ = header;                                  // 无需 free:随栈帧消失
}

它不支持真正的 free(free 是空操作),但支持 reset() 把游标拨回起点,从而逐条复用同一块缓冲区。嵌入式与裸机环境里这是唯一可用的策略——没有堆管理器,没有 mmap。std.heap.stackFallback(512, gpa).get() 则提供了更优雅的「栈优先、溢出回退」模式:512 字节以内不碰堆,超出自动转发给子分配器。

特性说明
分配算法线性 bump,指针一路前移,O(1)
对齐支持任意对齐,但每次对齐会浪费少量空间
碎片不回收 → 无碎片,但也无法复用单块
溢出返回 error.OutOfMemory,需调用方处理
生命周期绑定到 buf 所在作用域,切勿返回指针

注意:FixedBufferAllocator 分配失败是正常路径而非异常——网络包、日志行这类长度不可控的输入,必须显式处理 OutOfMemory,或改用 stackFallback。


5. GeneralPurposeAllocator 内部机制

GeneralPurposeAllocator(0.14 起称 DebugAllocator)是通用堆的主力,它的价值不只是「能分配」,而是可配置的安全检查:

var gpa = std.heap.GeneralPurposeAllocator(.{
    .safety = true,          // 越界/use-after-free 检测(Debug 默认开)
    .never_unmap = true,     // free 后保留页映射,更容易抓到悬空指针
    .retain_metadata = true, // 保留元数据,支持重复释放检测
    .verbose_log = true,     // 打印每次分配/释放的调用栈
}){};
defer _ = gpa.deinit();      // 返回 .leak / .ok,泄漏状态一目了然
const allocator = gpa.allocator();

它的工作原理:

  • 按大小分级(size class)管理,小对象走 bucket,大对象直接走页分配。
  • 每个分配块前后布置保护元数据(canary),越界写会被检测到。
  • 记录 ret_addr(调用返回地址),泄漏报告能直接定位到源码行。
  • 支持 never_unmap + retain_metadata 组合,把「释放后再写」变成确定性崩溃。
配置项开启代价换来的能力
safety = true每次分配多几个字节 + 检查开销越界、UAF、双重释放检测
never_unmap = true地址空间增长,不再复用页悬空指针必然触发 segfault
retain_metadata = true内存占用上升双重释放可被识别
verbose_log = true日志量极大完整分配轨迹与调用栈

心法:开发期用 GPA 全开,生产期换成 smp_allocator 或 c_allocator。Zig 的 std.heap.smp_allocator 是 0.14 引入的多线程优化通用堆,性能显著优于带安全检查的 GPA,代价是没有泄漏检测。


6. 编写自定义分配器

自定义分配器的价值在于「把业务假设编码进内存策略」。下面实现一个单调 bump 分配器——只有指针前移,不支持回收,适合「一批分配、一次性释放」的场景:

const std = @import("std");

const BumpAllocator = struct {
    buffer: []u8,
    offset: usize = 0,

    pub fn init(buffer: []u8) BumpAllocator {
        return .{ .buffer = buffer };
    }

    pub fn allocator(self: *BumpAllocator) std.mem.Allocator {
        return .{ .ptr = self, .vtable = &.{
            .alloc = alloc, .resize = resize, .remap = remap, .free = free,
        } };
    }

    fn alloc(ctx: *anyopaque, len: usize, alignment: std.mem.Alignment, ret_addr: usize) ?[*]u8 {
        _ = ret_addr;
        const self: *BumpAllocator = @ptrCast(@alignCast(ctx));
        const base = @intFromPtr(self.buffer.ptr);
        const aligned = std.mem.alignForward(usize, base + self.offset, alignment.toByteUnits());
        if (aligned + len > base + self.buffer.len) return null;   // 溢出
        self.offset = aligned + len - base;
        return @ptrFromInt(aligned);
    }

    fn resize(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, new_len: usize, ret_addr: usize) bool {
        _ = .{ ctx, memory, alignment, new_len, ret_addr };
        return false;   // 不支持就地扩容
    }

    fn remap(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, new_len: usize, ret_addr: usize) ?[*]u8 {
        const self: *BumpAllocator = @ptrCast(@alignCast(ctx));
        const base = @intFromPtr(self.buffer.ptr);
        const start = @intFromPtr(memory.ptr);
        // 若是最后一个分配,直接前移游标即可,无需拷贝
        if (start + memory.len == base + self.offset and start + new_len <= base + self.buffer.len) {
            self.offset = start + new_len - base;
            return memory.ptr;
        }
        return alloc(ctx, new_len, alignment, ret_addr);
    }

    fn free(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, ret_addr: usize) void {
        _ = .{ ctx, memory, alignment, ret_addr };   // 空操作:整体重置
    }

    pub fn reset(self: *BumpAllocator) void {
        self.offset = 0;
    }
};

编写自定义分配器的检查清单:

要点说明
ctx 恢复用 @ptrCast(@alignCast(ctx)) 从 *anyopaque 拿回自身
对齐必须真正满足请求的 alignment,否则 UB
ret_addr不实现泄漏追踪时忽略即可,但要接收
resize 语义不能移动内存;不满足返回 false
remap 语义可以移动并返回新指针;失败返回 null 且旧内存仍有效
free 语义允许是空操作(Arena/Bump 的常态)
线程安全接口本身无锁;需要并发就自己加 Mutex 或做 per-thread 实例

写完必须测:对齐是否满足、溢出是否返回 OutOfMemory、reset 后能否复用——用 std.testing.expectError(error.OutOfMemory, a.alloc(u8, 2048)) 与 @intFromPtr(s.ptr) % 8 == 0 这类断言覆盖边界。


7. 分配器组合与包装

分配器可以像装饰器一样层层包装,每一层加一个能力。标准库自带两个:

const logged = std.heap.loggingAllocator(gpa_allocator);          // 记录每次分配/释放
var failing = std.testing.FailingAllocator.init(gpa_allocator, .{});  // 失败注入

FailingAllocator 是测试错误处理的利器:让第 N 次分配失败,验证代码在 OutOfMemory 下是否正确清理:

test "cleanup on OOM" {
    var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
    for (0..32) |i| {
        failing.fail_index = i;                    // 逐个注入失败点
        var list = std.ArrayList(u8).init(failing.allocator());
        defer list.deinit();
        list.appendSlice("data") catch continue;
    }
}

自己写一个计数分配器同样简单——只包一层,把调用透传给子分配器并累加统计:

const Counting = struct {
    child: std.mem.Allocator,
    total_bytes: usize = 0,
    alloc_count: usize = 0,

    fn alloc(ctx: *anyopaque, len: usize, alignment: std.mem.Alignment, ret_addr: usize) ?[*]u8 {
        const self: *Counting = @ptrCast(@alignCast(ctx));
        const r = self.child.rawAlloc(len, alignment, ret_addr);   // 转发到 raw 入口
        if (r != null) {
            self.total_bytes += len;
            self.alloc_count += 1;
        }
        return r;
    }
    // resize/remap/free 同样转发给 child.rawResize/rawRemap/rawFree
};

注意这里用了 child.rawAlloc(...)——Allocator 暴露了 rawAlloc/rawResize/rawRemap/rawFree 四个直接调用 vtable 的入口,包装器应当转发到 raw 版本,避免无限递归。

内存池是另一种包装形态。std.heap.MemoryPool(T) 预先按 T 的大小切块,pool.create() 与 pool.destroy(x) 都是 O(1) 的 freelist 操作。相比每次走通用堆,它省掉了 size class 查找与元数据开销,在「大量同构小对象、高频创建销毁」的场景(连接、节点、消息)下通常有 2~5 倍提升。

包装层加什么能力代价
LoggingAllocator打印每次分配IO 与日志量
FailingAllocator失败注入仅测试用
Counting统计字节数与次数每次调用 +2 次加法
加锁包装线程安全每次分配一把锁
MemoryPoolO(1) 定长复用只能分配固定大小的 T
Arena批量回收不支持单项释放

8. 场景化选型与实践

把上面的工具映射到真实场景,选型表如下:

场景推荐分配器理由
命令行工具主流程smp_allocator 或 c_allocator生命周期短,简单可靠
HTTP 请求处理每请求一个 ArenaAllocator请求结束一次性回收,零泄漏风险
游戏每帧更新ArenaAllocator + reset(.retain_capacity)帧内大量临时对象
解析器 / ASTArena(按编译单元)节点生命周期一致,释放成本归零
长跑服务热路径MemoryPool(T)定长对象高频创建销毁
嵌入式 / 裸机FixedBufferAllocator无堆,全部静态内存
单元测试std.testing.allocator自动泄漏与越界检测
需要定位泄漏GPA(safety + retain_metadata)完整调用栈与双重释放检测
大块一次性内存page_allocator避免中间层拷贝

三条工程纪律:

  1. 分配器永远是参数,不是全局变量。库函数签名写 allocator: std.mem.Allocator,让调用方决定策略。这也是 Zig 标准库的统一约定。
  2. 不要跨生命周期传递切片。Arena 返回的切片在 deinit 后失效;若需长期持有,用 allocator.dupe 复制到长期分配器,或用 ArrayList 拥有自己的内存。
  3. 用测试证明内存正确。每个拥有内存的类型都应在测试里跑一遍 std.testing.allocator,让泄漏、双重释放、越界在 CI 上暴露。

一个典型的「混合策略」服务骨架:请求级变长数据用 Arena,定长对象用内存池,两者都挂在同一个 GPA 之下:

pub fn serve(conn: std.net.Stream, gpa: std.mem.Allocator) !void {
    var arena = std.heap.ArenaAllocator.init(gpa);
    defer arena.deinit();                                   // 请求级内存
    var pool = try std.heap.MemoryPool(Header).init(gpa);   // 定长对象池
    defer pool.deinit();

    const req = try readRequest(arena.allocator(), conn);   // 变长:用 arena
    const hdr = try pool.create();                          // 定长:用池
    defer pool.destroy(hdr);
    hdr.* = req.header;
    try handle(arena.allocator(), hdr, conn);
}

心法:选分配器的顺序是「生命周期 → 并发 → 性能」。先把生命周期相同的分配归到同一个 Arena,再决定要不要线程安全,最后才考虑用内存池压榨常数因子。


9. 速查表

需求手段
接口类型std.mem.Allocator(ptr + vtable 胖指针)
四个原语alloc / resize(不可移动)/ remap(可移动)/ free
上层 APIalloc / create / destroy / dupe / realloc
批量回收ArenaAllocator + deinit 或 reset(.retain_capacity)
栈上内存FixedBufferAllocator.init(&buf)
栈优先回退std.heap.stackFallback(512, gpa).get()
通用堆GeneralPurposeAllocator(.{ .safety = true })(0.14 亦名 DebugAllocator)
生产多线程堆std.heap.smp_allocator
定长对象池std.heap.MemoryPool(T)
失败注入std.testing.FailingAllocator
调用记录std.heap.loggingAllocator(child)
包装转发child.rawAlloc/rawResize/rawRemap/rawFree
对齐处理std.mem.alignForward、alignment.toByteUnits()
泄漏检测gpa.deinit() == .leak、std.testing.allocator

10. 一句话记忆

Zig 的内存哲学是「分配策略由使用者决定」:std.mem.Allocator 用四个函数指针(alloc/resize/remap/free)抽象一切,Arena 管生命周期、FixedBuffer 管栈上、GPA 管安全检测、MemoryPool 管定长复用,而它们还能层层包装——把分配器当参数传下去,就是把内存策略的控制权留在自己手里。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「系统编程」更多文章

  1. Zig 时间、日期与时区处理:std.time 与 epoch 换算
  2. Zig 插件系统与动态加载:C ABI 契约、热重载与错误隔离
  3. Zig 机器学习推理:张量、GEMM 与 int8 量化