返回文章列表

文章

关于MemoryPool引发的几个思考

目录
  1. 示例代码1
  2. 1. MemoryPool 的工作原理
  3. 2. 为什么 p2 和 p4 是同一地址
  4. 因此 p2 和 p4 存的是同一个地址。
  5. 3. 为什么 p2 被 destroy 后还能比较
  6. 4. p4 解引用的值
  7. 示例代码2
  8. 这个示例代码是可以正常运行的 其实这段代码之所以可以“正常运行”,并不是因为它是安全的,而是因为 Zig 对悬空指针和未初始化内存的处理方式不会在所有情况下立刻报错——很多情况下,它会“看起来能用”,但实际上是未定义行为(UB)。
  9. 1. 为什么它没崩溃
  10. 2. 这里的隐藏问题(UB)
  11. 3. 为什么在 Zig 中 UB 也可能“正常运行”
  12. 在Zig中我们如何写出内存安全的代码呢?

示例代码1#

const std = @import("std");

test "use memory pool" {
    var pool = std.heap.MemoryPool(u32).init(std.heap.page_allocator);
    defer pool.deinit();

    // 连续申请三个对象
    const p1 = try pool.create();
    const p2 = try pool.create();
    const p3 = try pool.create();

    // 回收p2
    pool.destroy(p2);
    // 再申请一个新的对象
    const p4 = try pool.create();
    // 注意,此时p2和p4指向同一块内存, 因为pool直接复用了刚才destroy的p2对象
    //这里有一个问题值得深思,为什么p2被destroy了还能用来运算比较
    try std.testing.expect(p2 == p4);
    _ = p1;
    _ = p3;
}

首先这段代码可以通过测试正常运行。有下面两个问题:

  1. 为什么 p2destroyp4 会指向同一块内存
  2. 为什么 p2destroy 后还能比较,且 p4 解引用是什么值

1. MemoryPool 的工作原理#

  • std.heap.MemoryPool(T) 会事先向底层分配器申请一大块内存,然后切成多个 sizeof(T) 大小的固定小块。
  • create():优先从空闲链表(free list)里取一块,如果没有空闲块,就从未分配区域切一块新的出来。
  • destroy(ptr):不会把内存交回操作系统,只是把 ptr 对应的块放回空闲链表。

2. 为什么 p2p4 是同一地址#

流程是这样的:

p1 = create()   // 分配块1
p2 = create()   // 分配块2
p3 = create()   // 分配块3
destroy(p2)     // 块2加入 free list
p4 = create()   // 发现 free list 有块2 → 直接复用

因此 p2p4 存的是同一个地址。#

3. 为什么 p2 被 destroy 后还能比较#

  • p2 是一个普通变量,里面保存的是内存地址。
  • destroy(p2) 并不会修改这个变量,只是让内存池知道“这块地址空闲了”。
  • 比较指针值(p2 == p4)只是对两个地址数值的比较,不会访问那块内存,因此是安全的。
  • 但是!p2 在逻辑上已经是悬空指针(dangling pointer),不能解引用

4. p4 解引用的值#

  • p4 分到的是 p2 释放的那块内存,内容是之前残留的数据(如果 p2 赋过值,就可能看到它;如果没赋值,就是随机垃圾值)。
  • 因为 MemoryPool 不会自动清零,也不做初始化,所以这种内存是未定义的状态
  • 在 Zig 中,解引用未初始化的内存是未定义行为UB),必须在使用前主动写入。

示例代码2#

test "MemoryPool uninitialized vs initialized" {
    var pool = std.heap.MemoryPool(u32).init(std.heap.page_allocator);
    defer pool.deinit();

    // 分配 p1 和 p2
    const p1 = try pool.create();
    const p2 = try pool.create();

    // 给 p2 一个确定的值
    p2.* = 42;

    // 分配 p3(为了和 p2 中间隔一点)
    const p3 = try pool.create();

    // 释放 p2
    pool.destroy(p2);

    // 再申请一块(会复用 p2 的地址)
    const p4 = try pool.create();
    std.debug.print("\np2 地址: {p}\n", .{p2}); // u32@1ed44e70018 当然每台机器上运行的结果都不同
    std.debug.print("\np2 当前值(未初始化): {}\n", .{p2.*}); // 2863311530 当然每台机器上运行的结果都不同
    std.debug.print("\np4 地址: {p}\n", .{p4}); // u32@1ed44e70018 当然每台机器上运行的结果都不同
    std.debug.print("p4 当前值(未初始化): {}\n", .{p4.*}); //2863311530  一个随机值,同样每台机器上也不相同

    // 初始化 p4
    p4.* = 99;
    try std.testing.expect(p4.* == 99);
    try std.testing.expect(p2.* == 99);
    try std.testing.expect(p2.* != 42);

    _ = p1;
    _ = p3;
}

这个示例代码是可以正常运行的 其实这段代码之所以可以“正常运行”,并不是因为它是安全的,而是因为 Zig 对悬空指针和未初始化内存的处理方式不会在所有情况下立刻报错——很多情况下,它会“看起来能用”,但实际上是未定义行为(UB)。#

1. 为什么它没崩溃#

  • p2** 被 destroy 后,指针值并没有被修改** pool.destroy(p2) 只是把那块内存放回空闲链表,没有去改 p2 这个变量(它仍然保存着那块内存的地址)。
  • p4** 再次分配时复用了 p2 的内存** 所以 p2p4 指向的物理地址完全一样。
  • 因此,当你修改 p4.* = 99; 时,其实就是在写 p2 所指向的那块内存,p2 解引用也会读到相同的值。

2. 这里的隐藏问题(UB)#

虽然程序“正常运行”,但是存在两个未定义行为风险:

  1. 悬空指针使用
    • destroy(p2) 后,p2 就是悬空指针(dangling pointer),因为这块内存的“所有权”已经交还给内存池。
    • 在语言层面,悬空指针的解引用是 UB,哪怕地址还有效,也可能被别的对象覆盖。
  2. 未初始化内存访问
    • 当你第一次读取 p4.* 时,这块内存是未初始化的(虽然碰巧是 p2 的旧值 42/垃圾值)。
    • Zig 在 Release 模式下不会帮你检测这个行为,所以运行时不会报错,但语义上依然是 UB。

3. 为什么在 Zig 中 UB 也可能“正常运行”#

Zig 在 Debug 模式下,某些 UB 会在运行时检测(例如越界访问),但对悬空指针和未初始化内存,很多情况是检测不到的,因为地址还在进程的合法范围内。 结果就是:

  • 在小规模测试里,“看起来没事”;
  • 但在不同优化级别、不同平台、不同代码布局下,可能直接出错(崩溃、错值、数据污染)。

在Zig中我们如何写出内存安全的代码呢?#