Ziglings 笔记 96: 内存分配器 (Allocators)

向操作系统借地盘

在 Ziglings 的第 96 个练习中,我们迎来了一个里程碑:动态内存分配

在之前的练习中,数组的大小必须在编译期已知(如 [5]u8)。但如果我们要读取用户输入的字符串,或者处理一个未知大小的文件怎么办?栈内存(Stack)满足不了我们,我们需要堆内存(Heap)。

Zig 与 C 或 Go 不同,它没有全局的 malloc,也没有垃圾回收(GC)。它要求我们在需要内存时,必须显式地选择一个 Allocator(分配器)

挑战:计算移动平均数

我们需要为一个长度未知的输入数组计算移动平均数。 因为 avg 数组的长度取决于输入 arr 的长度,我们无法在编译期确定它的大小,必须在运行时分配。

解决方案

我们使用 ArenaAllocator。这是一种非常省心的分配策略:只管申请,最后一次性全部释放。

const std = @import("std");

fn runningAverage(arr: []const f64, avg: []f64) void {
    var sum: f64 = 0;
    for (0.., arr) |index, val| {
        sum += val;
        const f_index: f64 = @floatFromInt(index + 1);
        avg[index] = sum / f_index;
    }
}

pub fn main() !void {
    // 假设这是从用户输入读取的动态数据
    const arr: []const f64 = &[_]f64{ 0.3, 0.2, 0.1, 0.1, 0.4 };

    // 1. 初始化 Arena 分配器
    // page_allocator 是直接找操作系统要内存的底层分配器
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);

    // 2. 确保在 main 函数退出时释放所有内存
    // Arena 的好处就在这里:我们不需要 free(avg),只需要释放 arena 即可
    defer arena.deinit();

    // 3. 获取分配器接口
    const allocator = arena.allocator();

    // 4. 动态分配内存
    // alloc 可能会失败(内存不足),所以需要 try
    // 它返回一个切片 []f64
    const avg: []f64 = try allocator.alloc(f64, arr.len);

    runningAverage(arr, avg);
    
    std.debug.print("Running Average: ", .{});
    for (avg) |val| {
        std.debug.print("{d:.2} ", .{val});
    }
    std.debug.print("\n", .{});
}

核心知识点总结

1. 为什么是 Arena?

ArenaAllocator 就像是把你所有的玩具都倒在一个大框里。

  • 优点:分配速度极快(指针移动一下就行),清理极其方便(直接把框倒空)。
  • 缺点:内存使用量只能增加,不能减少。你不能单独释放框里的某一个玩具。 对于生命周期较短的任务(如 HTTP 请求处理、CLI 工具),这是最佳选择。

2. defer 的至关重要性

defer arena.deinit() 是 Zig 内存安全的基石。它保证了无论函数通过什么路径退出(正常结束或发生错误),内存都会被清理。这避免了 C 语言中常见的内存泄漏问题。

3. alloc vs create

  • alloc(T, n): 分配数组。返回切片 []T
  • create(T): 分配单个对象。返回指针 *T

后续预告:我们学会了分配原始内存切片。但在实际开发中,我们需要更高级的数据结构,比如可以自动扩容的数组。下一篇,我们将学习 Zig 标准库中的 ArrayList