Chapter 10Allocators And Memory Management

分配器与内存管理

概述

Zig的动态内存方法是显式的、可组合的和可测试的。API接受一个std.mem.Allocator并将所有权清晰地返回给调用者,而不是将分配隐藏在隐式全局变量后面。本章展示了核心分配器接口(allocfreereallocresizecreatedestroy),介绍了最常见的分配器实现(页分配器、带泄漏检测的Debug/GPA、竞技场和固定缓冲区),并建立了通过你自己的API传递分配器的模式(参见Allocator.zigheap.zig)。

你将学习何时优先使用批量释放竞技场,如何使用固定堆栈缓冲区来消除堆流量,以及如何安全地增长和缩小分配。这些技能支撑了本书的其余部分——从集合到I/O适配器——并将使后续项目更快、更健壮(参见03)。

学习目标

  • 使用std.mem.Allocator来分配、释放和调整类型化切片和单个项目的大小。
  • 选择分配器:页分配器、Debug/GPA(泄漏检测)、竞技场、固定缓冲区或堆栈回退组合。
  • 设计接受分配器并将拥有的内存返回给调用者的函数(参见08)。

分配器接口

Zig的分配器是一个小巧的值类型接口,具有类型化分配和显式释放的方法。包装器处理哨兵和对齐,因此大部分时间你可以保持在[]T级别。

alloc/free、create/destroy和哨兵

基本操作:分配一个类型化切片,修改其元素,然后释放。对于单个项目,优先使用create/destroy。当需要C互操作的null终止符时,使用allocSentinel(或dupeZ)。

Zig
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});
}
运行
Shell
$ zig run alloc_free_basics.zig
输出
Shell
buf: abcde
point: (x=7, y=-3)
zstr: hello

优先使用{s}来打印[]const u8切片(不需要终止符)。当与需要尾随\0的API进行互操作时,使用allocSentineldupeZ

分配器接口底层工作原理

std.mem.Allocator类型是一个使用指针和虚函数表的类型擦除接口。这种设计允许任何分配器实现通过相同的接口传递,为常见情况启用运行时多态性而无需虚拟分派开销。

graph TB ALLOC["Allocator"] PTR["ptr: *anyopaque"] VTABLE["vtable: *VTable"] ALLOC --> PTR ALLOC --> VTABLE subgraph "VTable Functions" ALLOCFN["alloc(*anyopaque, len, alignment, ret_addr)"] RESIZEFN["resize(*anyopaque, memory, alignment, new_len, ret_addr)"] REMAPFN["remap(*anyopaque, memory, alignment, new_len, ret_addr)"] FREEFN["free(*anyopaque, memory, alignment, ret_addr)"] end VTABLE --> ALLOCFN VTABLE --> RESIZEFN VTABLE --> REMAPFN VTABLE --> FREEFN subgraph "High-Level API" CREATE["create(T)"] DESTROY["destroy(ptr)"] ALLOCAPI["alloc(T, n)"] FREE["free(slice)"] REALLOC["realloc(slice, new_len)"] end ALLOC --> CREATE ALLOC --> DESTROY ALLOC --> ALLOCAPI ALLOC --> FREE ALLOC --> REALLOC

虚函数表包含四个基本操作:

  • alloc:返回指向len字节的指针,具有指定的对齐方式,失败时返回错误
  • resize:尝试原地扩展或缩小内存,返回bool
  • remap:尝试扩展或缩小内存,允许重新定位(由realloc使用)
  • free:释放并使内存区域无效

高级API(createdestroyallocfreerealloc)使用类型安全、符合人体工程学的方法包装这些虚函数表函数。这种两层设计使分配器实现保持简单,同时为用户提供方便的类型化分配(参见Allocator.zig)。

Debug/GPA和竞技场分配器

对于整个程序工作,Debug/GPA是默认选择:它跟踪分配并在deinit()时报告泄漏。对于作用域内的临时分配,竞技场在deinit()期间一次性返回所有内容。

Zig
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});
}
运行
Shell
$ zig run gpa_arena.zig
输出
Shell
gpa sum: 10
arena msg len: 31

在Zig 0.15.x中,std.heap.GeneralPurposeAllocator是Debug分配器的薄别名。始终检查deinit()的返回值:.leak表示某些内容未被释放。

选择和组合分配器

分配器是常规值:你可以传递它们、包装它们和组合它们。两个主力工具是固定缓冲区分配器(用于栈支持的分配突发)和用于动态增长和缩小的realloc/resize

包装分配器以增强安全性和调试

因为分配器只是具有公共接口的值,你可以包装一个分配器来添加功能。std.mem.validationWrap函数通过在委托给底层分配器之前添加安全检查来演示这种模式。

graph TB VA["ValidationAllocator(T)"] UNDERLYING["underlying_allocator: T"] VA --> UNDERLYING subgraph "Validation Checks" CHECK1["Assert n > 0 in alloc"] CHECK2["Assert alignment is correct"] CHECK3["Assert buf.len > 0 in resize/free"] end VA --> CHECK1 VA --> CHECK2 VA --> CHECK3 UNDERLYING_PTR["getUnderlyingAllocatorPtr()"] VA --> UNDERLYING_PTR

ValidationAllocator包装器验证:

  • 分配大小大于零
  • 返回的指针具有正确的对齐方式
  • 内存长度在resize/free操作中有效

这种模式很强大:你可以构建自定义分配器包装器,添加日志记录、指标收集、内存限制或其他横切关注点,而无需修改底层分配器。包装器在执行其检查或副作用后简单地委托给underlying_allocatormem.zig

栈上的固定缓冲区

使用FixedBufferAllocator从栈数组获取快速、零系统调用的分配。当你用完时,你会得到error.OutOfMemory——这正是你需要回退或修剪输入的确切信号。

Zig
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,
    }
}
运行
Shell
$ zig run fixed_buffer.zig
输出
Shell
fixed buffer OOM as expected

为了优雅地回退,使用std.heap.stackFallback(N, fallback)在较慢的分配器上组合固定缓冲区。返回的对象有一个.get()方法,每次都会产生一个新的Allocator

使用realloc/resize安全地增长和缩小

realloc返回一个新的切片(可能会移动分配)。resize尝试原地改变长度并返回bool;成功后记得也要更新切片的len

Zig
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 });
    }
}
运行
Shell
$ zig run resize_and_realloc.zig
输出
Shell
len=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₂值,允许高效存储同时提供丰富的实用方法。

graph LR ALIGNMENT["对齐枚举"] subgraph "对齐值" A1["@'1' = 0"] A2["@'2' = 1"] A4["@'4' = 2"] A8["@'8' = 3"] A16["@'16' = 4"] end ALIGNMENT --> A1 ALIGNMENT --> A2 ALIGNMENT --> A4 ALIGNMENT --> A8 ALIGNMENT --> A16 subgraph "关键方法" TOBYTES["toByteUnits() -> usize"] FROMBYTES["fromByteUnits(n) -> Alignment"] OF["of(T) -> Alignment"] FORWARD["forward(address) -> usize"] BACKWARD["backward(address) -> usize"] CHECK["check(address) -> bool"] end ALIGNMENT --> TOBYTES ALIGNMENT --> FROMBYTES ALIGNMENT --> OF ALIGNMENT --> FORWARD ALIGNMENT --> BACKWARD ALIGNMENT --> CHECK

这种紧凑表示提供了以下实用方法:

  • 与字节单位之间的转换@"16".toByteUnits()返回16fromByteUnits(16)返回@"16"
  • 向前对齐地址forward(addr)向上舍入到下一个对齐边界
  • 向后对齐地址backward(addr)向下舍入到前一个对齐边界
  • 检查对齐check(addr)如果地址满足对齐要求则返回true
  • 类型对齐of(T)返回类型T的对齐方式

当你看到alignedAlloc(T, .@"16", n)或在自定义分配器中使用对齐时,你正在使用这种log₂表示。紧凑的存储允许Zig高效地跟踪对齐而不会浪费空间(参见mem.zig)。

分配器作为参数模式

你的API应该接受一个分配器并将拥有的内存返回给调用者。这保持了生命周期的明确性,并让你的用户根据其上下文选择合适的分配器(临时使用竞技场,一般用途使用GPA,可用时使用固定缓冲区)。

Zig
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,
    }
}
运行
Shell
$ zig run allocator_parameter.zig
输出
Shell
gpa: 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。

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.