跳到主要内容

零分配不是炫技,是编译器从运行时手里抢走的活

破壁人
破壁人

· 阅读约 7 分钟

Dwarven Stronghold 这个项目被扔在一个“你做过的最技术印象深刻的没用东西”帖子里。帖子的问题是“最技术印象深刻的没用东西”,但真正有意思的是——有人答了一个 30 万行 Rust、零分配、8192 个单测的类 Dwarf Fortress。大多数人看到“零分配”只会觉得这是性能标签,我不这么看。它不是标签,是一堵墙,墙后是把运行时内存管理搬进编译期的整套机制。这篇我要拆的是这堵墙,不是那个项目本身——项目只是引子,承重墙在 Rust 的所有权和借用规则里。

先把直觉砸进去。常规程序里,你想在堆上建个对象,就是调分配器、拿指针、用完再还回去。这个动作有三个代价:分配器要处理碎片和并发,调用本身有延迟,最要命的是你还得记得释放,否则悬空和泄漏跑不掉。Rust 的零分配策略,核心 idea 一句话讲透:让编译器在编译期证明每块内存的生命周期,然后把本该运行时干的事提前到编译期一次干完。运行时不再有分配调用,也不再有释放调用,因为内存要么在栈上随作用域自动回收,要么是静态区里的固定偏移。这就是它的承重墙——把“内存安全”从运行时检查变成编译期类型系统的硬约束。

要理解这个承重墙怎么立起来,得看 Rust 的所有权和借用。我们直接讲约束,不讲入门。Rust 里每个值在任何时刻只有一个所有者;你可以创建对它的引用,但引用必须遵守借用规则:要么多个共享引用 &T,要么唯一一个可变引用 &mut T,二者不能同时对着同一块内存。写成公式:

$$ \forall x,\quad \neg(\exists\ &T \text{ to } x \wedge \exists\ &mut T \text{ to } x) $$

每个符号交代清楚:$x$ 是内存里的值,$&T$ 是只读借用,$&mut T$ 是可变借用,$\wedge$ 是“同时存在”。这个式子念出来是:对任意值,不可能既有一个只读引用又有一个可变引用同时指向它。这条规则看着简单,它是零分配能成立的第一根柱子——因为借用关系在编译期就被完全确定,编译器知道谁在用这块内存、用了多久,就能证明释放时机不需要运行时判断。

第二根柱子是生命周期约束。当你在一个函数里返回一个引用或者把引用存进结构体,编译器要求这个引用不能比它指向的值活得久。写成:

$$ \text{lifetime}(&x) \leq \text{lifetime}(x) $$

这个不等式的意思是:借用的生命周期必须被所有者的生命周期包含。编译器在类型检查阶段验证这个不等式;验证不过,编译直接失败,不是等到运行时给你一个悬空指针错误。这一步是关键。没有它,零分配就无从谈起——如果编译器不能证明内存不会被过早释放,你就必须在运行时维护引用计数或垃圾回收,那又回到分配器去了。

这两条柱子合起来,推导出零分配的结论:编译器掌握全局所有权图,所以在生成机器码时可以静态布局每块内存,不需要运行时的 malloc/free。栈上变量直接映射到栈指针的固定偏移,生命周期结束就调整栈指针;静态变量直接进数据段;只有那些真正动态大小或者需要逃逸出作用域的值才需要堆分配,但 Rust 里这些堆分配也由所有权规则自动配对调用分配器和释放器,你写代码时根本看不见。这里要澄清一下:Rust 的“零分配”通常指核心逻辑里没有显式堆分配调用,而不是整个程序一个 malloc 都没有——标准库内部该用还是会用。这是很多文章偷换概念的地方,拆墙不能这么干。

我画个文字示意图,把调用栈和生命周期对齐的流程摆出来:

调用链: main() ──► process_chunk() ──► parse_token()
                │                       │
栈布局:  ┌───────────────┐       ┌───────────────┐
          │ main 局部变量  │       │ token 缓冲区   │
          │ 固定偏移       │       │ 固定偏移       │
          └───────────────┘       └───────────────┘
               ▲                        ▲
               │ 作用域结束,栈指针回退    │
               └───────────────┬────────┘
                          编译器插入释放,无运行时调用

这个图要说明的是:每进入一层函数,栈指针往下压;函数退出,栈指针回退,该层所有局部变量的内存就自动释放。这个释放不需要任何运行时逻辑,因为编译器在编译期就证明了这些变量的生命周期严格嵌套在调用栈里。这就是零分配的心智模型——不是“不分配”,是“分配变成了一次栈指针加减”。

但拆到这里我必须补一刀:这个机制的代价是什么。首先是生命周期标注的复杂度。当数据跨函数、跨线程、跨闭包流动时,你得写大量显式生命周期参数,编译器会逼着你把数据流理清楚,否则编译不过。30 万行的 Dwarven Stronghold 里,我敢打赌有相当一部分代码是在跟借用检查器搏斗。这不是坏话,这是成本。其次是算法实现上,很多经典数据结构——图、树、缓存——天然需要指针图,在严格所有权下你只能用 arena 分配器或者索引替代指针,代码可读性会下降。最后是空间局部性不一定更好:零分配鼓励栈上大数组,如果尺寸估算错了,栈溢出比堆分配更隐蔽。

零分配在工程上不是“不分配”,而是把分配集中在少数地方,然后用静态布局展开。常见模式有三个:一是栈上定长数组,适合已知上限的集合;二是 arena 分配,一整块内存按偏移切分,用完一起释放;三是对象池,重复使用固定大小块。这些模式都要求你在写代码前预估容量上限,数据流越清晰越容易做。Dwarven Stronghold 那 30 万行能坚持零分配,大概率在数据布局上做了大量预分配和索引化,而不是到处动态 push。这一点是我根据零分配的通用代价反推的,不是作者说的,别当成事实。

把论文拉回地面,工程启示有三层。第一层,如果你的热路径里 malloc 调用占比超过个位数,先别想着换语言,用 Rust 的所有权模型管理数据生命周期,往往能把分配次数降一个数量级。第二层,如果你在 Rust 里写性能敏感代码,优先考虑栈上分配和复用缓冲区,避免在循环里 box 或 clone。第三层,测试数量本身没有意义,有意义的是这些测试是否覆盖了生命周期边界——空引用、悬空、双重释放、跨线程借用——这些才是零分配代码真正的雷区。那个项目 8192 个单测,我不知道覆盖了什么,但如果你要复现这种零分配架构,把测试重点放在这些边界上,比堆用例数量靠谱。

我第一次读 Rust 的所有权模型时,也卡在“为什么可变引用不能和只读引用共存”这一步,觉得太反人类。后来才明白,这条规则不是拍脑袋,它是为了让编译器能在没有运行时 GC 的前提下证明内存安全。这一步想通了,后面所有生命周期标注都只是给编译器填表。这个弯转过来需要点时间,但它值得——因为从这里开始,你看任何“零分配”宣称,都能一眼看出它是真把内存管理搬进了编译期,还是只是把 malloc 换了个宏包起来。多数时候,是后者。

这堵墙我替你拆了,零分配的承重墙是编译器替你证明了生命周期。剩下的路,你得自己写几行带生命周期标注的 Rust 才能踩实。别让摘要和评论区替你下结论。

破壁人
破壁人

把高冷论文拆成中文开发者能懂的:核心 idea、公式推导、示意图、工程联系。

查看主页 →