深度解析塑造未来的技术文章。

Goose 语言打赌:系统代码不需要堆

Goose 是一门内存安全的系统语言,没有堆、没有 GC,也没有生命周期标注。它的纯栈设计能说明 Rust 与区域内存管理的哪些道理?

抽象插图:一棵由小节点组成的树,节点是发光的圆点,由短弧线相连,全部漂浮在一块扁平半透明的板块之中
由自相对链接构成的树,在任何地址上字节都相同——持久化就变成了 memcpy。

这里有个观点,大概会让至少两个语言社区不太高兴:借用检查器其实是一段弯路。不是错误——Rust 的所有权模型解决了真实的问题,也推动整个行业前进——但它是绕过了一个更古老、更简单的想法的弯路,而我们现在正在重新发现这个想法。这个想法就是区域(regions):内存分配在一块块类似栈的大竞技场里,作用域结束时整体释放。Goose 是本周热门讨论的一门新系统语言,它是我见过的对这一思路最激进的现代尝试,而它的设计迫使我们面对一个令人不安的问题:我们在 C++ 和 Rust 里忍受的那些东西,有多少是真正承重的,又有多少只是“堆”在作怪?

Goose 没有堆分配器。没有垃圾回收,没有引用计数,没有析构函数,没有生命周期标注,也没有 unsafe。所有动态值都内联放在编译器静态分配的少数几个数据栈之一上。增长只是移动一下指针。释放一个包含百万元素的嵌套结构,只需要对栈顶做一次写入。作者报告的基准测试显示,它比惯用的 C++ 快 3.3 倍,比安全的 Rust 快 1.12 倍,而且占用内存更少。John Carmack 公开的反应则点出了我觉得最有意思的部分:用相对引用让数据结构本身就是它的文件格式。Goose 最终能否用于生产软件还是未知数,但它是一个认真的思想实验,展示了当你删掉堆并且毫不抱歉时,系统语言会是什么样子。

内存模型就是语言的全部

大多数语言特性都是可以商量的。语法、模块系统,甚至错误处理——你可以想象两种同样合理的语言做出截然相反的选择。内存模型则不一样。一旦选定,下游的一切都被迫跟着走:你的数据结构、并发方案、FFI、错误信息。Goose 明白这一点,并且完全朝这个方向押注。

规则陈述起来很简单:程序拥有原生调用栈、静态数据区,以及 N 个数据栈,其中 N 由编译器推导得出。每个栈上最多只有一个可调整大小的值处于活跃状态,并且它始终位于栈顶。这一个不变量带来了惊人的好处。因为可增长的值永远不会移动——它上方没有需要被重定位的东西——所以指向一个不断增长的数组的引用永远有效。在 C++ 中,std::vector<T> 一旦重新分配,就会让所有指向它的指针和迭代器失效,这也是为什么一代又一代 C++ 程序员都曾因为在 push_back 期间持有引用而栽跟头。安全的 Rust 直接让这种写法无法表达来规避这个问题;你得改用 u32 索引,代价是边界检查和使用体验上的妥协。Goose 则只是……让你保留这个引用。类型和生命周期规则在编译期检查,编译器会推断每个引用“根植”于哪里,并拒绝那些可能比其所有者活得更久的引用——但不需要标注,没有别名规则,也没有独占性约束。

struct Item { id: i32, weight: f32 }
var items: Item[>..] = [];
let first .= items.push(Item { 1, 0.5 });
for i: i32 in 2..1000001 {
items.push(Item { i, 0.0 });
}
first.weight = 99.5; // still valid, a million pushes later

如果你用过 Rust 的借用检查器,看到这里会觉得像变魔术。生命周期去哪了?诚实的答案是:它们变成了一种限制。Rust 的所有权系统能够证明任意共享模式的安全性——图、环、跨作用域的共享可变结构。Goose 只能证明一个窄得多的模式集合的安全性,其余的则干脆拒绝。这种用表达力换取简单性的取舍,是语言设计中最古老的一种,也是我这里想继续深挖的线索。如果你读过我们关于Rust 借用检查器中“健全性”到底意味着什么的文章,那么 Goose 就是它的另一面:它实现的健全性不是靠更强大的分析,而是靠一门更小的语言。

一切内联:数据布局是一等特性

第二个大想法是:根本没有单一的数组类型。Goose 有一组数组种类,它们的区别只在于大小如何变化:编译期固定、构造后冻结、在内联容量内可增长、只增不减、可增可减。它们全都是 [元数据][元素...] 的形式——连续、紧凑,从不指向一个独立的元素块。字符串就是 u8 数组,因此结构体可以按声明顺序内联包含可变长度的部分。一条带有可变长度字符串字段和可变长度子记录列表的记录,就是一段零间接寻址的连续字节。

它给出的数字很吸引人:在 Goose 中,一条订单记录只有 29 字节且零次分配;而用 C++ 的 std::string + std::vector<Item> 实现则是 160 字节、一次分配;Rust 则是 153 字节、四次分配。不过,讨论中很快就指出了一个公平的修正:Goose 教程里“字符串向量”的例子忽略了小字符串优化(SSO),而主流的 STL 实现都有这一优化。短字符串在 C++ 中并不会分配内存。这并没有推翻其论点——SSO 只对大约 15 到 22 字节以内的字符串有帮助,对嵌套可变大小数据的一般情况毫无作用——但它提醒我们:即便语言本身很好,语言主页上的基准测试表格依然是营销材料。好在作者还是把劣势部分也摆了出来,这一点值得称赞,毕竟大多数项目不会这么做。

代价是真实存在的,Goose 也没有回避:一个由可变大小元素组成的数组是顺序的。你可以遍历它,但不能按索引访问——第 k 个元素在哪里,取决于字节落在什么位置。如果需要随机访问,你就得另外维护一个引用数组作为索引。数据导向设计的人几十年来一直手工做这种取舍(这基本上就是列式格式和 FlatBuffers 类布局的工作方式),但在这里它是默认行为,而选择退出则意味着额外的工作。这究竟是特性还是束缚,完全取决于你的访问模式。

相对引用:你的数据结构就是它的文件格式

在 Goose 中,我最喜欢的想法是相对引用,Carmack 也正是盯上了它。普通引用是一个机器地址:8 个字节,绝对的,在进程之外毫无意义。相对引用则把同样的链接存成 1、2 或 4 字节的偏移量——要么相对于字段自身的位置,要么相对于某个命名池的基址。由自相对链接构成的结构是位置无关的,无论放在内存的哪个位置,它的含义都不变。这意味着保存一个数据结构就是一次 write(),加载它就是一次 read() 加上一轮校验。

最后那个分句比听起来更重要。这个技巧的朴素版本——把一个装满指针的文件直接内存映射——是经典的安全灾难。Goose 的版本则会做校验:from_bytes 在字节转化为值之前,会检查框架结构、每个标签、每个长度、每个链接,因此损坏或恶意的文件只会得到 false,而不是一个野指针。你既获得了 mmap 结构的速度,又获得了类似解析器那种程度的安全性。

Carmack 的说法非常到位:“‘解析’文件和网络数据通常比它需要的速度慢 100 倍。” 任何分析过反序列化热路径的人都懂这一点。到目前为止,业界的答案是基于 schema 的零拷贝格式——Cap'n Proto、FlatBuffers——它们确实有效,但需要在项目里额外引入 schema 编译器和运行时库。Goose 的回答是:内存布局和线上布局在构造上就是同一回事,因为这门语言从一开始就没有指向绝对地址的指针。那个二叉搜索树的例子几乎有点调皮:节点只有 12 字节,链接也包含在内,插入只是一次 push 加一次写入,而整棵树只需把字节写出去就完成了持久化。Lisp 爱好者会认出这种哲学上的动作——把一个所有人都以为是根本性的区分折叠掉——这也是为什么 Lisp 在六十年后依然重要,它是一个值得参考的设计坐标。

我们以前来过这里:区域、竞技场与 ML Kit

对 Goose 最懂历史的回应,在评论区及时出现了:“我记得 ML 那帮人走过这条路,我想他们管它叫区域(regions)。” 没错。基于区域的内存管理可以追溯到 1990 年代的 ML Kit 工作——Tofte 和 Talpin 的区域推断,让 ML 编译器能够用静态确定的、类似栈的区域来取代垃圾回收,整块地分配和释放区域,无需任何逐对象的记账。历史上客观的结论是,它被认为是一条死胡同:区域推断确实可以工作,但真实程序表现出足够多病态的内存增长——区域把已经死亡的数据长时间保留在内存里——以至于研究界基本上转向了别处。垃圾回收器保住了王冠。

但这个想法从未真正死去,只是换了工作。竞技场分配在游戏引擎、编译器和高性能服务器中是标准做法。Apache 的内存池、Web 服务器中的每请求竞技场,以及游戏循环里“每帧重置临时缓冲区”的模式——这些都是换了名字的区域,只不过因为语言不帮你,只能手动管理。Goose 与 ML Kit 的不同之处值得精确说明:它不是为一门通用函数式语言推断区域(困难且不可预测),而是直接暴露栈模型,并把程序员的意图纳入类型之中。T[>..] 的意思是“这个会增长,引用保持有效”。编译器的任务是检查一个声明好的纪律,而不是去发现它。这是一个容易得多的问题,这也正是 Rust 能在早期自动生命周期方案挣扎的地方成功的原因:程序员愿意接受他们看得见的契约。

这里还有一个关于生产语言如何吸收研究成果的更广泛的道理。作为 GC 的全自动替代方案,区域推断失败了。作为手动系统编程模式,区域无处不在。借用检查器取得了巨大成功,而它本身也大量借鉴了区域思想和线性类型。Erlang 式的不共享进程模型得到了验证,而 Goose 的线程正是如此:工作单元被编译为独立的程序,各自拥有内存,值通过类型化队列以 memcpy 的方式传递,数据竞争则根本无法表示。没有人能一次就把全貌看清。想法总要在几十年间流转,才会落到一个真正可用的形态上。

模型失效的地方

我说过要以一个逆向观点开篇,然后承认它在哪里失效。这就是那个让步。如果你的程序共享模式确实是任意的,借用检查器就不是弯路——而大量程序恰恰具有这种形态。Goose 讨论中最尖锐的问题也是最简单的:如何构建一个将键映射到字符串的哈希表,而程序会在随机时间插入和删除随机字符串?删除是致命的。只增不减的栈无法回收中间的空洞。建议的变通方案——每个尺寸类别一个栈、定期压缩、带有空闲链表的池(“释放”一个槽位只意味着它可以被复用)——都可行,而池的设计确实很巧妙(过期的槽位读到的是正确类型的另一个值,而不是损坏内存)。但每一种方案,都是程序员手动为那个数据结构重新实现了一部分内存管理器。

同样的问题还有内存占用。没有 GC,一个会增长的栈会一直持有它曾经增长到的所有数据,直到作用域结束。长期运行、生命周期不规则的程序——讨论中提到的 JSON 文档问题、缓存、会话状态——要么适合作用域纪律的模式,要么不适合。而不适合的时候,再多的优雅也救不了你。ML Kit 的失败模式(死亡数据被钉在活跃区域中)在 Goose 里并没有消失;它只是变得可见且需要手动处理,这或许更好,但它依然存在。有位评论者提出了正确的定位:Goose 是为受限用途而设计的语言,这没问题。面向数据的工作负载、编译器、游戏实体系统,以及任何呈流水线形态的东西——读取、转换、输出、重置——都非常契合。而长期存活、可变的对象图则不适合。

关于标题数字,还有一点需要说明。自我托管的基准测试套件是有已知失败模式的一类东西:你是在拿自己最好的 Goose 代码,去对比别人“惯用”的 C++,而所谓惯用,往往正是争吵开始的地方。SSO 的疏漏说明对比代码并没有被刻意调优到极致。相比 3.3 倍,那个 1.12 倍(相对安全 Rust)的数字更值得关注,恰恰因为它很小——它说明内存模型的优势来自布局和消除分配,而不是什么魔法;而一个使用竞技场和索引、经过调优的 Rust 版本,很可能会弥补大部分差距。这本身就是重点:你确实可以写基于竞技场的 Rust,但语言让它变得笨拙,而笨拙恰恰决定了实际会写出什么样的代码。

两条飞行路径在暗色天空中划出的光迹,一条长而蜿蜒,一条短而笔直,汇聚于一点
通往同一种安全的不同路线:Rust 约束共享,Goose 约束内存模型本身。

生产语言真正会从 Goose 偷走什么

在未来几年,几乎没有人会用 Goose 写生产代码——由一个人开发、通过单一 C 后端编译的语言不会很快成熟,作者自己也承认了它的现状。这并不是有意思的问题。有意思的问题是:哪些想法会溢出到我们已经在用的语言里,因为事情向来就是这样发生的。我的预测,按把握程度从高到低排列:

  1. 位置无关的结构与经过校验的加载。 带有安全校验的零拷贝序列化太有价值,不会一直停留在小众领域。预计会出现更多语言层面集成的方案,去做 FlatBuffers 目前笨拙完成的事情,尤其是在数据密集型服务持续承受解析开销的情况下。
  2. 数组是一个族,而不是一种类型。 “大小会发生什么变化?”是比“栈还是堆?”更好的组织性问题。固定、冻结、容量受限、只增不减——这些区别在每个代码库里都以非正式约定的形式存在。把它们做成类型成本很低,还能捕获真实的 Bug。
  3. 编译期函数值。 Goose 把函数值视为编译期实体,因此 xs.filter() { it > 0 } 会编译成它看起来的那个循环。C++ 模板和 Rust 单态化早已做到这一点;区别在于 Goose 让这成为唯一的方式,彻底消除了运行时函数指针这个陷阱。
  4. 静态检查的多帧返回。 return err from load 会从向上 N 层的函数帧返回,并在编译期检查,没有 unwinder,没有 Result 的金字塔,也不用在每次调用后都写 ?。效应系统正从另一个方向逼近同样的思路。其中某一种会在这十年内进入主流语言。

“不共享”的线程模型也值得一提,主要是因为它最不新颖,却最被验证:它就是 Erlang 的进程,就是 Dart 的 isolate,就是去掉了意识形态的 Actor 模型。它被一再重新发明,说明业界在共享可变状态上的共识,在理论上早已确立,实践中却被无视。Goose 至少让无视它变得不可能。

真正的论点:通过限制实现简单

退后一步看,Goose 的设计只是一个动作的重复:缩小可表示的程序集合,直到安全性变得廉价可证。没有堆,就不会有悬空的堆指针。只增不减的栈意味着引用不会失效。工作线程彼此分离,意味着数据竞争不可能存在。免拷贝的构造意味着没有意外的临时对象。每一条限制都删掉了一整类 Bug,同时也删掉了一整类为管理这些 Bug 而存在的语言机制——借用检查器的别名规则、析构函数、unwinder。

这是 Rob Pike 的“少即是指数级的多”论点,只是没戴手套,它碰到的正是 Pike 的 Go 所面对的同一个张力:限制在你的程序没有被它排除在外之前,都让人感觉像是解放,而一旦程序被限制排除了,它们就变成了一堵墙。Go 十年不引入泛型,但也因此排除了大量正确的程序。Goose 排除的要多得多。尽管如此,仍值得关注它,原因在于Pike 关于简单性的那些老规则历久弥新,恰恰是因为复杂性预算是真实存在的——语言自动提供的每一项保证,都是使用它的程序员不必再记在脑子里的东西。

所以:借用检查器是不是一条弯路?对于通用系统编程,不是——Rust 的表达力在任意共享的场景中确实必不可少,而它的成功也为整个行业提供了讨论所有权的词汇。但对于那大量安静的系统代码——呈流水线形态、竞技场形态、请求形态的代码——呢?Goose 证明了我们或许可以用少得多的仪式感,同时获得内存安全、速度和小体积——前提是我们愿意对更多程序说“不”。语言设计的艺术,就是选择禁止哪些程序。Goose 禁止的程序与我们习惯的不同,结果是它成为我这几年读过的最有意思的系统语言草图。我不指望会用它上线产品,但我确定会一直从它那里偷师。

Goose 的赌注是:一门语言可以比 Rust 禁止更多的程序,却依然有用——基准测试表明这值得认真对待,包括它的劣势在内。