Chapter 10Allocators And Memory Management

分配器与内存管理

概述

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

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

学习目标

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

分配器接口

Zig的分配器是一个小型的、值类型的接口,具有用于类型化分配和显式释放的方法。包装器处理哨兵和对齐,因此您大多数时候可以停留在[]T级别。

alloc/free、create/destroy和哨兵

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

Zig
const std = @import("std");

pub fn main() !void {
    const allocator = std.heap.page_allocator; // OS-backed; fast & simple
    // 操作系统支持;快速且简单

    // Allocate a small buffer and fill it.
    // 分配一个小缓冲区并填充它。
    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});

    // Create/destroy a single item.
    // 创建/销毁单个项。
    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 });

    // Allocate a null-terminated string (sentinel). Great for C APIs.
    // 分配空终止字符串(哨兵)。非常适合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类型是一个使用指针和虚函数表(vtable)的类型擦除接口。这种设计允许任何分配器实现通过同一个接口传递,从而在不为常见情况带来虚拟分派开销的情况下实现运行时多态。

graph TB ALLOC["分配器"] PTR["ptr: *anyopaque"] VTABLE["vtable: *VTable"] ALLOC --> PTR ALLOC --> VTABLE subgraph "虚函数表函数" 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 "高级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和Arena分配器

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

Zig
const std = @import("std");

pub fn main() !void {
    // GeneralPurposeAllocator with leak detection on deinit.
    // 在deinit时具有泄漏检测功能的GeneralPurposeAllocator。
    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});

    // Arena allocator: bulk free with 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 "验证检查" CHECK1["在alloc中断言n > 0"] CHECK2["断言对齐是正确的"] CHECK3["在resize/free中断言buf.len > 0"] 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.
    // 3个小分配应该能容纳。
    const a = try A.alloc(u8, 8);
    const b = try A.alloc(u8, 8);
    const c = try A.alloc(u8, 8);
    _ = a;
    _ = b;
    _ = c;

    // This one should fail (32 total capacity, 24 already used).
    // 这个应该失败(总容量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 });

    // Grow using realloc (may move).
    // 使用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 });

    // Shrink in-place using resize; remember to slice.
    // 使用resize就地缩小;记住要切片。
    if (alloc.resize(buf, 3)) {
        buf = buf[0..3];
        std.debug.print("shrunk len={} contents={s}\n", .{ buf.len, buf });
    } else {
        // Fallback when in-place shrink not supported by allocator.
        // 当分配器不支持就地缩小时的后备方案。
        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["Alignment enum"] 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应该接受一个分配器,并将拥有的内存返回给调用者。这使得生命周期明确,并让你的用户为他们的上下文选择正确的分配器(用于临时的arena、通用的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 {
    // Use GPA to build a string, then free.
    // 使用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});

    // Try with a tiny fixed buffer to demonstrate OOM.
    // 尝试使用微小的固定缓冲区来演示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| {
        // If it somehow fits, free it (unlikely with 16 bytes here).
        // 如果万一能容纳,就释放它(这里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
  • Arenas:永远不要将arena拥有的内存返回给长生命周期的调用者。Arena内存在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.