第 3 章拆解(下)— 分配器内部、packed/extern 结构与对齐
分配器内部、packed/extern 结构与对齐
这一篇讲什么: 上一篇(03)讲了指针纪律、zunzip 和编译期布局校验; 这一篇把剩下的近景补完:一个记账分配器从无到有长什么样、 packed struct 和 extern struct 各自把字节变成结构的机制、 对齐为什么是硬件要求而不是编译器洁癖,以及四分配器基准的逐项读法。
主线例子:还是那台记账器——
alloc(i16, 250)进、free出, 计数器 0 → 500 → 1500 → 1000 的每一步都对应一次真实分配,全程手工可对账。
1. 分配器到底"是"什么:三个函数指针,没有魔法
Zig 里 std.mem.Allocator 不是编译器特殊构造,也不是运行时特权对象——它是一个普通接口:一个指向实现的指针,加一张覆盖 alloc()、resize()、free() 三个函数的函数指针表(vtable)1。任何结构体实现了这三个操作、再暴露一个 .allocator() 方法产出绑定好 vtable 的值,就是一个能和 std.ArrayList、一切标准容器互换的分配器2。代价说得坦白:多态的成本是每次分配一次间接调用,编译器没有义务内联或特化;买到的是「用内存的代码」和「管内存的代码」彻底解耦3。
自定义分配器只有两大模式。其一是包装器(wrapper):自己不碰操作系统,把真正的申请转发给"父分配器",在中间插入自己的逻辑——好写、好组合,可以"记账的叠在 arena 上、arena 叠在调试分配器上,每层只看见下面一层的接口"4。其二是底层分配器(backing allocator):没有父级,直接管理裸内存——从预分配池里切固定大小对象的 slab、在一块连续内存里撞针前进的 bump、支持后进先出零开销释放的 stack allocator5。书上造的是前者,理由很明确:写包装器更像"实现一个定义良好的接口",正是 Zig 想让系统编程任务变成的形状。
2. TrackingAllocator 全解剖
内部状态只有两格:allocator(父分配器的句柄)和 allocBytes(累计字节计数器)6。构造 init() 把父级存进来、计数清零,父子关系就此建立7。然后是五个方法,每条的账都记得很死板:
alloc(comptime T, n) 是全程序的心脏。它不自己找内存——先 self.allocator.alloc(T, n) 把硬活 delegate 给父级;成功返回后才轮到自定义逻辑:用 @sizeOf(T) * n 算出刚占了多少字节、累加进计数器、打印一行状态8。注意这个顺序——记账发生在内存真实到手之后,父级失败的错误直接向上传播,计数器不会记一笔不存在的账。
free(T, slice) 镜像回去:先按 slice.len 把字节从计数器里减掉、再 self.allocator.free(slice) 归还。书上点明这个次序的用意:"先更新统计、再释放资源"保证内部状态在内存还给系统之前永远一致9。
create(T) / destroy(memory) 管单个对象(比如一个结构体实例):没有元素个数可乘,就是裸的 @sizeOf(T),其余逻辑与切片版同构——delegation、记账、日志三件套10。bytesAllocated() 是个 getter,printBytes() / printBits() 顺手乘 8 报比特数,纯粹为调试时少写几行 print11。
实测主线例子的完整流水账(数字全部可手工验算)12:
Memory: 0 bytes ← 初始
Allocated 500. Current: 500 ← alloc(i16, 250):250 × 2 字节
Allocated 1000. Current: 1500 ← alloc(i32, 250):250 × 4 字节(句柄丢弃,只 bump 计数)
500 freed. Current: 1000 ← free(i16, slice):精确减 500,加法减法同精度
Memory: 1000 bytes
Allocated 8. Current 1008 ← create(i64)
Allocated 8. Current 1016 ← 又一个 create(i64)
8 freed. Current: 1008 ← destroy 第一个
Memory: 1008 bytes ← 退出时的净占用:1000(i32)+ 8(i64)
Memory: 8064 bits图说:两条故意"泄漏"(丢弃的 i32 句柄、没 destroy 的第二个 i64)都在账上现形——这个例子的真正用意是演示记账器的审计能力,不是演示良好的释放习惯。
它能做什么,靠的全是"接口统一"这一条:把 TrackingAllocator 递给解析器跑一遍代表性负载,得到它分配行为的精确测量;换成 FixedBufferAllocator 再跑,立刻知道它是否装得进内存预算;换一个"第三次调用必失败"的分配器,可以系统性地验证每一条分配失败路径都被正确处理——不需要 mock 框架、不需要猴子补丁、不需要运行期插桩,只要代码当初收了一个 allocator 参数——而在 Zig 里,它总是收的13。
故意缺的两块,书用一整段说清不是疏忽:计数器是普通 usize、没有原子保护,多线程并发分配会数据竞争,生产版要用 std.atomic.Value(usize) 或互斥锁;没有实现 resize(),所以不能参与切片的原地扩容,任何经标准接口的 resize() 调用都会失败——补上它意味着要跟踪每个指针各自的尺寸,通常得维护一张"指针→大小"的哈希表14。这些都是"Zig 从不默默替你做这种取舍"的示范。
3. packed struct:把字段塞进整数的位里
packed struct(u16) 是序列化场景的关键字:它指示编译器把字段紧紧压进一个 u16 整数的相邻位里,消掉为对齐 normally 插入的填充字节——Person{ age: u8, value: u8 } 恰好占 2 字节,和硬件寄存器、二进制协议的布局严丝合缝15。
序列化往返(bigLittle.zig)的每一步都有名有姓16:写入端 w.writeInt(u16, @bitCast(p), .little)——@bitCast 是零成本的 reinterpretation,骗编译器把 Person 当裸 u16 看;.little 显式指定小端,跨平台兼容的命门。读取端镜像:reader.takeInt(u16, .little) 从当前游标恰好取两字节、按小端解释、游标前进 2,再 @bitCast 还原成 Person。两个 0.16 新 I/O 类型在这里登场:Io.Writer.Allocating 是往动态增长的堆缓冲里攒字节的写器(总长未知时的正确选择),Io.Reader.fixed() 把一块已有字节切片当流读17。分配器用的是 0.16 新面孔 std.heap.smp_allocator——无锁、线程安全,为多核对多线程并发分配优化,免安装免拆卸18。
实测输出两行对账:Written: { 20, 100 }——小端先写低 8 位(age=20)再写高 8 位(value=100);Read: .{ .age = 20, .value = 100 }——同序读回、位还原,字段原封不动19。
4. extern struct 与两条 cast 之路
裸字节变结构体的两条路,差别只有一处——对齐,而对齐是硬件的硬规矩,不是编译器的洁癖20。
路一:指针强转。 @ptrCast(@alignCast(slice.ptr)) 两步:@alignCast 是运行期断言,验地址满足结构体的对齐要求;@ptrCast 做类型 reinterpretation,把"一串字节"的地址直接当成结构体指针。零拷贝、最快——但前提是你担保了缓冲的对齐:例子里声明数组时就上了 align(@alignOf(Header)),因为 Header 里有个 u16,它要求偶数地址21。书里故意构造了反向教材:数组前塞一个 0xFF 字节,让切片从奇地址开始——在多字节类型必须坐在自己大小的整数倍地址上的架构(ARM、SPARC)上,这种访问会直接 CPU fault22。
路二:拷贝解出。 std.mem.bytesToValue(T, slice) 不碰原内存,把字节拷进一个崭新的、安全的栈上实例——一次 memcpy 的代价,换来奇地址也能用、紧凑无填充的文件格式随便解析23。它仍然按本机字节序解释(例子里 0x34 0x12 在小端机上读作 0x1234),所以字节序的显式控制(readInt(..., .little) 一族)该用还得用。
Zig 把对齐编进指针类型本身,拒绝把 1 字节对齐的 [*]u8 隐式转成严格对齐的结构体指针——于是每条路都变成一次显式的架构决策:要么保证缓冲对齐、走零拷贝;要么付一次拷贝走安全路24。
5. 字节序三件套与"传家"转换
字节序一节用一行代码把本机看光:取 0x12345678,std.mem.asBytes 直接别名它的内存,小端机器打印 78563412——整个倒过来了25。出门记住三件套就够:std.mem.readInt(T, bytes, .little/.big) 解析时按指定序拼整数;std.mem.nativeToLittle / nativeToBig 写出时把本机整数转成指定序;std.mem.bytesToValue 整块拷贝加本机序解释。ZIP 诞生于 MS-DOS 时代,严格小端——.little 不是风格,是正确性要求26。
6. 分配器基准:四个数,四种读法
四个分配器跑同一份负载(申请 1000 万个 i32,约 38 MiB,10 轮,只计时申请),结果一行一个性格27:
DebugAllocator: 2711 µs ← 最慢;桶记账 + 泄漏跟踪 + 哨兵毒化,安全的价钱
PageAllocator: 1888 µs ← 一次 38 MiB 直接问内核要,大块反而便宜
FixedBufferAllocator: 343 µs ← 最快;撞针前进,零簿记
ArenaAllocator: 476 µs ← 次快;为"能超出初始块继续长"付 133 µs 块链表管理费图说:撞针两兄弟(FBA/arena)比通用分配快 5–8 倍,因为它们不找空闲块、只前进指针,初始化后不再发系统调用28。三种读法:
- PageAllocator 赢 DebugAllocator 是因为块大:一次 38 MiB 连续块直接问内核,比调试分配器的内部分桶记账便宜;换成每次 10 字节、百万次的负载,结果会反转——每次申请一次系统调用的税会压垮一切。"分配器性能不是绝对的,是负载形状的函数"29。
- DebugAllocator 的 2711 µs 不是浪费,是安全特性的成本:每次分配都进内部元数据结构,deinit 时才能报泄漏;开发期无条件值得付,问题是"留着它开发期罚多少"——这里十轮总共约多 800 µs,人感知不到30。
- FBA 与 arena 的 133 µs 差是"能不能长大"的价:FBA 是指针加长度、仅此而已;arena 维护 backing 块链表以便超出固定尺寸后继续生长。上限已知且装得下栈/预分配缓冲,选 FBA;需要动态增长,这 133 µs 付得值31。
还有一个基准测不到的维度:碎片。长跑进程里大小不一的小块申请与释放交错,DebugAllocator/PageAllocator 会堆出"总量够、凑不出连续大块"的碎片化堆;arena 和 FBA 结构性免疫——它们从不复用已释放的空间,只前进32。对长跑服务,这条免疫可能比 raw 速度更值钱。
7. 作者的判断与证据
- 判断: "内存管理不只是安全话题,更是关键的优化杠杆"——抽象取舍变成微秒数字,数字背后各有各的机制解释33。证据:四行基准 + 每行的归因。
- 判断: 换分配器 = 免费插桩(记账/预算/故障注入三件套)是这套接口设计的最大架构红利13。证据:机制完整;无基准。
- 判断: 字节序的
.little参数是正确性要求;覆盖式解析"看起来零拷贝又优雅,实则脆弱"34。证据:zunzip 有意不用 overlay、逐字段 readInt 的对照设计。
8. 边界与局限
- TrackingAllocator 无线程安全、无 resize,教学删减14。
bytesToValue按本机序解释字节,跨端序数据仍需 readInt 显式控制。- packed struct 的位布局虽确定,但跨编译器/语言的位序约定仍需以协议文本为准。
- 微基准单一尺寸、申请即释放;真实负载的混合尺寸与生命周期交错下结论可能不同32。
- smp_allocator 等 0.16 新面孔的后续演进未被书覆盖(书只标注"introduced in Zig 0.16")。
9. 可带走的
- 分配器 = 三函数指针表;包装器(转发+插逻辑)比裸写内存管理好写也好组合。
- 记账次序:alloc 先委托后记;free 先减账后归还——状态先于资源一致。
- 三次分配各自 errdefer/记账,退出时的净占用就是审计结果。
- 接口统一的红利:换分配器=换测量仪器,不换被测代码。
- packed struct 压位消填充;
@bitCast零成本换型;writeInt/takeInt成对出现、端序一致。 - 两条 cast 之路只差对齐:信得过对齐
@ptrCast(@alignCast),信不过就bytesToValue吃一次拷贝。 - 对齐编进指针类型:隐式转换被拒,每条路都是显式决策。
- 端序三件套:readInt 解析、nativeToLittle/Big 写出、bytesToValue 整块拷贝。
- 基准读法:块大时 PageAllocator 也不慢;撞针分配快 5–8 倍;碎片免疫是长跑服务的隐藏收益。
10. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 接口=三函数指针 | Putting structures on stack and heap | text/40-fm-putting-structures-on-stack-and-heap.txt:213(搜「vtable」) |
| 包装与底层两模式 | Putting structures on stack and heap | text/40-fm-putting-structures-on-stack-and-heap.txt:806(搜「wrapper allocator」) |
| 计数器两格 | Putting structures on stack and heap | text/40-fm-putting-structures-on-stack-and-heap.txt:296(搜「running counter」) |
| alloc 先委托后记 | Putting structures on stack and heap | text/40-fm-putting-structures-on-stack-and-heap.txt:358(搜「middleman」) |
| free 先减账后归还 | Putting structures on stack and heap | text/40-fm-putting-structures-on-stack-and-heap.txt:427(搜「consistent」) |
| 500/1000/1008 流水账 | Putting structures on stack and heap | text/40-fm-putting-structures-on-stack-and-heap.txt:748(搜「Allocated 500」) |
| 换分配器=无损插桩 | Putting structures on stack and heap | text/40-fm-putting-structures-on-stack-and-heap.txt:866(搜「instrument」) |
| 故意缺线程安全与 resize | Putting structures on stack and heap | text/40-fm-putting-structures-on-stack-and-heap.txt:828(搜「no thread safety」) |
| packed struct 压位 | Reading Little-Endian integers from raw bytes | text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:39(搜「compress」) |
| smp_allocator 登场 | Reading Little-Endian integers from raw bytes | text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:81(搜「lock-free」) |
| {20,100} 往返 | Reading Little-Endian integers from raw bytes | text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:173(搜「Written」) |
| @alignCast 运行期断言 | Reading Little-Endian integers from raw bytes | text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:424(搜「runtime assertion」) |
| 奇地址教材 | Reading Little-Endian integers from raw bytes | text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:491(搜「misalignment」) |
| bytesToValue 的 memcpy 语义 | Reading Little-Endian integers from raw bytes | text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:537(搜「memcpy」) |
| 对齐编进指针类型 | Reading Little-Endian integers from raw bytes | text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:612(搜「encoded」) |
| 0x12345678→78563412 | Putting structures on stack and heap | text/40-fm-putting-structures-on-stack-and-heap.txt:2245(搜「Native」) |
| 四分配器读数方法 | Reading Little-Endian integers from raw bytes | text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:1113(搜「numbers deserve」) |
| 碎片免疫 | Reading Little-Endian integers from raw bytes | text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:1237(搜「immune」) |
本篇为第 3 章拆解的下篇,依据原书第 3 章各小节。每条出处可用 node scripts/book-verify.mjs systems-programming-with-zig 回核。
Footnotes
-
出处:「Putting structures on stack and heap」第 213 段(
text/40-fm-putting-structures-on-stack-and-heap.txt:213,搜「vtable」)。 ↩ -
出处:「Putting structures on stack and heap」第 227 段(
text/40-fm-putting-structures-on-stack-and-heap.txt:227,搜「fully interchangeable」)。 ↩ -
出处:「Putting structures on stack and heap」第 515 段(
text/40-fm-putting-structures-on-stack-and-heap.txt:515,搜「indirect call」)。 ↩ -
出处:「Putting structures on stack and heap」第 824 段(
text/40-fm-putting-structures-on-stack-and-heap.txt:824,搜「stack a tracking」)。 ↩ -
出处:「Putting structures on stack and heap」第 818 段(
text/40-fm-putting-structures-on-stack-and-heap.txt:818,搜「backing allocator」)。 ↩ -
出处:「Putting structures on stack and heap」第 296 段(
text/40-fm-putting-structures-on-stack-and-heap.txt:296,搜「running counter」)。 ↩ -
出处:「Putting structures on stack and heap」第 318 段(
text/40-fm-putting-structures-on-stack-and-heap.txt:318,搜「parent-child」)。 ↩ -
出处:「Putting structures on stack and heap」第 358 段(
text/40-fm-putting-structures-on-stack-and-heap.txt:358,搜「middleman」)。 ↩ -
出处:「Putting structures on stack and heap」第 427 段(
text/40-fm-putting-structures-on-stack-and-heap.txt:427,搜「consistent」)。 ↩ -
出处:「Putting structures on stack and heap」第 453 段(
text/40-fm-putting-structures-on-stack-and-heap.txt:453,搜「exactly enough」)。 ↩ -
出处:「Putting structures on stack and heap」第 552 段(
text/40-fm-putting-structures-on-stack-and-heap.txt:552,搜「getter」)。 ↩ -
出处:「Putting structures on stack and heap」第 748 段(
text/40-fm-putting-structures-on-stack-and-heap.txt:748,搜「Allocated 500」)。 ↩ -
出处:「Putting structures on stack and heap」第 866 段(
text/40-fm-putting-structures-on-stack-and-heap.txt:866,搜「instrument」)。 ↩ ↩2 -
出处:「Putting structures on stack and heap」第 828 段(
text/40-fm-putting-structures-on-stack-and-heap.txt:828,搜「no thread safety」)。 ↩ ↩2 -
出处:「Reading Little-Endian integers from raw bytes」第 39 段(
text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:39,搜「compress」)。 ↩ -
出处:「Reading Little-Endian integers from raw bytes」第 133 段(
text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:133,搜「writeInt」)。 ↩ -
出处:「Reading Little-Endian integers from raw bytes」第 103 段(
text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:103,搜「Allocating」)。 ↩ -
出处:「Reading Little-Endian integers from raw bytes」第 81 段(
text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:81,搜「lock-free」)。 ↩ -
出处:「Reading Little-Endian integers from raw bytes」第 173 段(
text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:173,搜「Written」)。 ↩ -
出处:「Reading Little-Endian integers from raw bytes」第 600 段(
text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:600,搜「strict hardware requirement」)。 ↩ -
出处:「Reading Little-Endian integers from raw bytes」第 365 段(
text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:365,搜「@alignOf」)。 ↩ -
出处:「Reading Little-Endian integers from raw bytes」第 491 段(
text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:491,搜「misalignment」)。 ↩ -
出处:「Reading Little-Endian integers from raw bytes」第 537 段(
text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:537,搜「memcpy」)。 ↩ -
出处:「Reading Little-Endian integers from raw bytes」第 612 段(
text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:612,搜「encoded」)。 ↩ -
出处:「Putting structures on stack and heap」第 2245 段(
text/40-fm-putting-structures-on-stack-and-heap.txt:2245,搜「Native」)。 ↩ -
出处:「Putting structures on stack and heap」第 2189 段(
text/40-fm-putting-structures-on-stack-and-heap.txt:2189,搜「not a stylistic choice」)。 ↩ -
出处:「Reading Little-Endian integers from raw bytes」第 1052 段(
text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:1052,搜「2711」)。 ↩ -
出处:「Reading Little-Endian integers from raw bytes」第 1105 段(
text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:1105,搜「simplicity」)。 ↩ -
出处:「Reading Little-Endian integers from raw bytes」第 1179 段(
text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:1179,搜「load-shaped」)。 ↩ -
出处:「Reading Little-Endian integers from raw bytes」第 1141 段(
text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:1141,搜「rounding error」)。 ↩ -
出处:「Reading Little-Endian integers from raw bytes」第 1211 段(
text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:1211,搜「reasonable price」)。 ↩ -
出处:「Reading Little-Endian integers from raw bytes」第 1237 段(
text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:1237,搜「immune」)。 ↩ ↩2 -
出处:「Reading Little-Endian integers from raw bytes」第 680 段(
text/41-fm-reading-little-endian-integers-from-raw-bytes.txt:680,搜「optimization lever」)。 ↩ -
出处:「Putting structures on stack and heap」第 1123 段(
text/40-fm-putting-structures-on-stack-and-heap.txt:1123,搜「overlay approach is tempting」)。 ↩