Hey!
Billgo

EN

Zig 分配器详解

2025 年 5 月 1 日

Zig 是一门相对较新的系统编程语言,可以作为 C 的替代方案。它的设计目标是简单、直接、现代,并且相比 C++ 或 Rust 之类的语言,尽量减少容易踩坑的地方。它结合了 Go 的一些易用性,也吸收了 Rust 和 C 中很酷的一些特性。它已经成为我很喜欢的语言之一,我也经常在 Raspberry Pi 和家用服务器上的个人项目里使用它。

Zig 如何处理内存分配?

Zig 处理内存分配的方式非常独特,和 Rust 或 C++ 这样的语言差异很大。Rust 和 C++ 给了你很多内存控制能力,但仍然会在背后做一些分配;Zig 则通过分配器让你完全控制内存如何分配和释放。

什么是分配器?

本质上,分配器是一个实现了一组函数的结构体,这些函数允许你分配和释放内存。基本上,每当你在任何语言中声明变量或函数时,系统都需要分配内存来保存它。在很多语言中,包括 C 的 malloc,这件事会被自动完成。但 Zig 的不同之处在于,它要求你手动分配和释放内存。你可以通过分配器做到这一点。Zig 标准库默认提供了几种常用分配器。

Zig 自带哪些分配器?

  • std.heap.page_allocator:最基础的分配器。每次分配时都会向操作系统申请整页内存,因此效率不高,也比较慢。
  • std.heap.FixedBufferAllocator:在固定缓冲区中分配内存,不进行堆分配。性能很好,但需要提前知道缓冲区大小。
  • std.heap.ArenaAllocator:接收一个子分配器,可以多次分配,但只统一释放一次。适合需要分配多个对象,并在最后一次性释放的场景,因为它们都位于同一块内存区域。
  • std.heap.GeneralPurposeAllocator:通用安全分配器。刚开始学习 Zig、做实验或不确定该用哪种分配器时,可以优先使用它。它不是性能最高或最省资源的分配器,但可以通过关闭线程安全和其他安全检查来获得更好性能;前提是你知道自己在做什么。
  • std.heap.c_allocator:高性能分配器,但安全特性有限或几乎没有。它要求项目链接 LibC,本质上调用 C 标准库中的 mallocfree。这也会削弱使用 Zig 分配器的一项核心收益:没有隐藏的分配和释放。

如何在 Zig 中使用分配器?

先从最简单的 std.heap.page_allocator 开始。要使用它,只需要导入并初始化:

const std = @import("std");

pub fn main() !void {
    var buffer: [1048]u8 = undefined;
    var fba = std.heap.FixedBufferAllocator.init(&buffer);
    const allocator = fba.allocator();

    const memory = try allocator.alloc(u8, 255);
    defer allocator.free(memory);
}

这段代码会使用分配器分配一段内存,并在函数退出时释放它。

这里有几个很值得注意的点:

  • defer 关键字用于把某条语句延迟到当前作用域结束时执行。这非常适合清理内存、文件句柄等资源。
  • ! 操作符表示函数可能返回错误。这是 Zig 中非常常见的模式,用于以清晰直接的方式处理错误。
  • u8 是要分配的内存类型,这里是无符号 8 位整数。你可以把它改成任何想要分配的类型。
  • try 可能看起来像其他语言里的 try/catch,但不要混淆。Zig 中的 try 会把错误向上返回给调用方。这也是 ! 操作符出现的原因:如果函数返回类型前面带 !,表示它可能返回错误。

Zig 固定缓冲区分配器示例

const std = @import("std");

pub fn main() !void {
  var buffer: [100]u8 = undefined;
  var fixedBufferAllocator = std.heap.FixedBufferAllocator.init(&buffer);

  const memory = try fixedBufferAllocator.alloc(u8, 100);
  defer fixedBufferAllocator.free(memory);
}

注意这个例子和前一个例子的不同:这里需要先声明并初始化一个固定大小的缓冲区,然后把它的引用传给 FixedBufferAllocator,而不是简单告诉分配器我们想要多少内存。这是因为 FixedBufferAllocator 不在堆上分配内存,而是在栈上的固定缓冲区里分配。

Zig Arena 分配器示例

const std = @import("std");

pub fn main() !void {
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();
    const allocator = arena.allocator();

    const memory1 = try allocator.alloc(u8, 8);
    const memory2 = try allocator.alloc(u8, 16);
    const memory3 = try allocator.alloc(u8, 32);

    // 为了避免未使用变量错误,可以使用 _ 操作符
    _ = memory1;
    _ = memory2;
    _ = memory3;
}

在这个例子中,我们先用一个子分配器初始化 ArenaAllocator,这里使用的是 page allocator。然后从 arena 中获取分配器并用它分配内存。注意我们只对 arena 调用 deinit,而不是对 allocator 调用释放函数。这是因为 arena 会在自身释放时自动释放所有由该分配器分配的内存。由于 defer 会在函数退出时自动调用 deinit,所以我们不需要手动执行。

Zig 通用分配器示例

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    const memory = try allocator.alloc(u8, 215);
    defer allocator.free(memory);
}

简单直接,和 page allocator 类似,但提供了更多特性和安全检查。

这些东西的意义是什么?

使用分配器的核心意义,是让你完全控制内存如何分配和释放。这有点像 Rust 会强迫你关注内存分配,在对象离开作用域时释放不再需要的内存;只不过 Zig 的做法更直接。它不会在背后替你做事,而是要求你自己显式处理内存分配,不过方式仍然保持简单清晰。

这让 Zig 在内存安全方面接近 Rust,同时又保留了类似 C 或 Go 的简洁性。这是一种非常独特且有趣的内存管理方式,也是我很喜欢 Zig 的原因之一。

总结

希望这篇文章能帮助你理解什么是分配器,以及 Zig 如何使用分配器处理内存分配。如果你有问题或反馈,欢迎通过 Twitter 联系我。我很乐意帮忙、接收反馈,或者只是聊聊这类话题。感谢阅读,祝你编码愉快!