文章
关于MemoryPool引发的几个思考
目录
示例代码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;
}
首先这段代码可以通过测试正常运行。有下面两个问题:
- 为什么
p2被destroy后p4会指向同一块内存 - 为什么
p2被destroy后还能比较,且p4解引用是什么值
1. MemoryPool 的工作原理#
std.heap.MemoryPool(T)会事先向底层分配器申请一大块内存,然后切成多个sizeof(T)大小的固定小块。create():优先从空闲链表(free list)里取一块,如果没有空闲块,就从未分配区域切一块新的出来。destroy(ptr):不会把内存交回操作系统,只是把ptr对应的块放回空闲链表。
2. 为什么 p2 和 p4 是同一地址#
流程是这样的:
p1 = create() // 分配块1
p2 = create() // 分配块2
p3 = create() // 分配块3
destroy(p2) // 块2加入 free list
p4 = create() // 发现 free list 有块2 → 直接复用
因此 p2 和 p4 存的是同一个地址。#
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 的内存** 所以p2和p4指向的物理地址完全一样。- 因此,当你修改
p4.* = 99;时,其实就是在写 p2 所指向的那块内存,p2 解引用也会读到相同的值。
2. 这里的隐藏问题(UB)#
虽然程序“正常运行”,但是存在两个未定义行为风险:
- 悬空指针使用
destroy(p2)后,p2就是悬空指针(dangling pointer),因为这块内存的“所有权”已经交还给内存池。- 在语言层面,悬空指针的解引用是 UB,哪怕地址还有效,也可能被别的对象覆盖。
- 未初始化内存访问
- 当你第一次读取
p4.*时,这块内存是未初始化的(虽然碰巧是 p2 的旧值 42/垃圾值)。 - Zig 在 Release 模式下不会帮你检测这个行为,所以运行时不会报错,但语义上依然是 UB。
- 当你第一次读取
3. 为什么在 Zig 中 UB 也可能“正常运行”#
Zig 在 Debug 模式下,某些 UB 会在运行时检测(例如越界访问),但对悬空指针和未初始化内存,很多情况是检测不到的,因为地址还在进程的合法范围内。 结果就是:
- 在小规模测试里,“看起来没事”;
- 但在不同优化级别、不同平台、不同代码布局下,可能直接出错(崩溃、错值、数据污染)。