在过去的一年半里,构建 Roc 编译器的团队一直在将我们的 30 万行 Rust 代码重写为 Zig,原因我将在下文简述。最近,我们通过了一个令人兴奋的里程碑:实现了与原编译器功能对等(feature parity)!
由于 Bun 项目最近分享了他们向相反方向重写(从 Zig 到 Rust,尽管这只是我们重写之间差异的冰山一角)的经验报告,现在似乎是一个反思我们从 Rust 转向 Zig 进展如何的好时机。
通过功能对等
达到这一里程碑使得更新 Brendan Hansknecht 迷人的 2024 WASM-4 游戏 Rocci Bird(美术由 Luke DeVault 制作)以使用新编译器成为可能。这是一个很好的例子,因为整个游戏只有不到一千行 Roc 代码,你可以在 itch.io 上或直接在这里通过 WebAssembly 游玩:
点击或轻触游戏,然后按空格键(或轻触)来扇动翅膀。在手机上你没有右箭头键,所以请刷新页面以重新开始游戏。
Rocci Bird 更新后的源代码比原来更简洁,现在 roc build --opt=size 输出一个 31KB 的 wasm 二进制文件(原编译器生成的二进制文件是这个大小的两倍多)。Rocci Bird 绝不是一个大型代码库,但要让它运行起来,需要在新编译器中落地许多功能。当我们将所有功能跑通,终于看到那些粗糙的紫色像素时,我的脸上露出了笑容!
需要澄清的是,这只是一个里程碑,而不是正式发布。(我们的目标是在今年晚些时候发布 0.1.0 版本。)即便如此,能达到这个里程碑依然棒极了,我非常感谢所有共同努力实现这一目标的人!我想特别感谢一些在将语言和编译器推向这一阶段的过程中提供极大帮助的人:
- Anthony Bullard 和 Sam Mohr 合作开发了新的解析器(parser)
- Jared Ramirez 开发了新的类型检查器(type-checker)(以及许多其他工作!)
- Ayaz Hafiz 开发了新的 lambda 集解析系统(lambda set resolution system),以及原编译器的许多部分
- Aurélien Geron 在他最初创建的 Roc Exercism 课程中手动更新了 108 个(!)初学者练习
- Stephan 让编译器的全新 "echo" 平台在浏览器中运行,因此任何人现在都可以从 roc-lang.org 主页通过一个 2.5MB 的 WebAssembly 二进制文件编写并运行基础的 Roc 程序!
- Niclas Åhdén,Roc 最多产的生产环境用户,耐心提交了有用的 Bug 报告,并对升级过程提供了可操作的反馈
- JRI98 系统地复现和调查了模糊测试(fuzzer)错误和其他 Bug,关闭了不再复现的问题等
- Jasper Woudenberg 针对使用新编译器的用户空间包(userspace packages)迭代了 API 设计
- Folkert de Vries, Brendan Hansknecht, Brian Carroll, Josh Warner, Agus Zubiaga 和 Jelle Teeuwissen 构建了原编译器的基础,没有他们,新编译器根本不可能存在
- 我把新编译器公认的最大贡献者留在了最后:Anton-4 和 Luke Boswell,他们做了太多事情,以至于我都数不过来——编译器工作、内置功能、平台、包、示例、修复 Bug、在 Roc Zulip 上帮助初学者……把所有这些都列出来可能会占用整整第二篇帖子!看到你们构建了这么多的东西,真是令人难以置信。
非常感谢大家!能让你们在这个项目上投入这么多宝贵的时间,我感到非常荣幸。同时也要感谢我们过去和现在的赞助商——rwx、Lambda Class、ohne-makler、martian、tweede golf、Vendr、NoRedInk 以及许多慷慨的个人赞助商,他们通过支持我们的贡献者帮助我们走到了今天。
说到时间:我们 487 天的重写时间比 Bun 用 11 天将约 50 万行 Zig 重写为 Rust 多了 476 天。造成这种差异有很多与 Rust 或 Zig 无关的原因,包括他们的重写是直接移植,而我们因为要做出巨大改变而决定推倒重写。他们使用的技术在我们的情况下是行不通的。
由于我们进行了大量的修改,这意味着将我们原有的 Rust 代码库和新的 Zig 代码库进行直接对比是不公平的(非“苹果对苹果”的对比)。尽管如此,我们已经到了反思这次重写进展如何的极佳时间节点,无论是从它为 Roc 程序员解锁了哪些新功能,还是从我们使用 Rust 和 Zig 的体验对比来看。
让我们深入了解一下!
热代码重载 + 交叉编译二进制文件
Roc 的新编译器在开发过程中会自动进行热代码重载(hot code loading)。例如,我可以运行 roc server.roc 来启动一个 Web 服务器,然后在它运行时修改其部分代码。下次该服务器处理请求时,它将自动使用新代码来处理。这里是它在服务器和简单 2D 游戏中的实际演示:
[下载热重载演示视频。]
热重载对于像 Python 这样的解释型语言来说是标准行为,但对于像 Roc 这样高性能的编译型语言来说却并非如此。当我准备部署时,运行 roc build server.roc 可以为我生成一个经 LLVM 优化、自包含的二进制文件,我可以将其直接放到机器上运行。
Roc 还支持交叉编译;构建一个运行在 Alpine Linux 上的静态二进制文件就像运行 roc build --target=x64musl 一样简单,并且该命令在 Mac 或任何其他系统上运行时,都会产生相同的输出字节(对于相同的输入源码字节)——并非所有编译器都能保证这一点。
带有字符串插值的模式匹配
视频中的 HTTP 请求处理逻辑看起来像这样:
match (verb, path) {
("GET", "/users/${id}/${page}") => match page {
"" | "profile" => ok(id)
"settings" => ok(with_default(user_agent, id))
"posts/${post_id}" => ok("Post ID: ${post_id}")
_ => not_found
}
("GET", "/users/${id}") => ok(id)
("POST", "/posts/new") => created(with_default(…))
_ => not_found
}
这使用了我们在新编译器中引入的几项功能。例如,"/users/${id}" 语法并不是通过在运行时解析模板字符串来实现的,而是通过一项新的语言特性:模式匹配内部的字符串插值。
这不仅在编译时是类型安全的,而且这整个代码片段执行了零堆分配(zero heap allocations)。我预计带热代码重载的典型语言在此处平均每行代码需要接近 1 次分配……但 Roc 在人体工程学、类型安全和性能方面有着很高的目标!
你可以在新的 roc-lang.org 主页上体验这一语法——如果你向下滚动一点,页面上就有一个编译器的 WebAssembly 构建版,你可以用它来试用该语言。
顺便说一句,如果你对一篇关于我们如何利用新编译器的纯函数编译时执行将 HTTP 请求路由降低到零分配的技术细节文章感兴趣,请在 Roc Zulip 上告诉我。
为什么要推倒重写?
与 Rust、C 和 Zig 不同,Roc 不是系统级语言;它具有自动内存管理机制(使用引用计数,既能避免追踪式垃圾回收器的停顿,也能进行 Perceus 优化和像 Koka 语言那样的机会性变异)。如果 Roc 需要为每次闭包捕获(closure capture)进行一次堆分配(就像大多数非系统语言那样),它将会有多得多的堆分配,但我们的闭包捕获不需要堆分配,因为 Roc 是第一个通过 Lambda 集特化(lambda set specialization)实现多态去功能化(polymorphic defunctionalization)的非学术界语言。
这听起来可能像是一个小众优化,但在像 Roc 这样的函数式语言中,去功能化被证明与内联类似,因为它解锁了大量后续优化的宝藏。尽管这个系统对 Roc 的运行时性能极其有益,但对我们来说,要正确实现它也极其困难。我们在最初的实现中饱受棘手 Bug 的折磨,直到 Ayaz Hafiz 在 OCaml 中构建了一个新架构的原型后,我们才终于在新编译器中把事情做对。
Ayaz 的原型表明,我们问题的根源在于跨多个编译器阶段的架构问题,而修复它需要重写编译器的大部分内容。这是我们最初决定重写的原因之一——此外,几位贡献者独立提到,由于其他原因,他们计划重写编译器的各个部分。我们意识到,反正我们已经快要重写几乎整个编译器了,因此将完全重写作为“特修斯之船”方式的替代方案是合理的。
编译器开发比较特殊,在成功的项目中推倒重写是常态。这通常是实现自举(self-host)的唯一方法,尽管并非所有编译器都会重写为自己的语言;例如,TypeScript 重写为 Go。我的立场一直都是 Roc 编译器不应该自举,所以坦率地说,有一天重写的收益看起来会超过其声名狼藉的成本这种想法,我从未产生过。
我们讨论得越多,就越觉得做基本上当今所有主流编译器在某个阶段都做过的事是合理的:从头推倒重写。
为什么选择 Zig?
一旦我们决定推倒重写,接下来的问题就是是否再次选择 Rust。基于我们使用 Rust 和 Zig 的经验(我们已经在标准库中的一些基元中使用了 Zig),我们决定这次用 Zig 构建整个编译器。
我喜欢 Rust,我曾讲过一门关于它的课程,并且在 Zed 的日常工作中我也乐于使用它。尽管互联网上的评论可能让我们产生相反的看法,但对于一个项目来说某门语言是最佳选择,而对于另一个项目来说另一门语言是最佳选择,这是极其正常的。一种尺码并不能适合所有人!
我已在其他地方(文章中、播客中等)深入探讨过我们选择 Zig 的原因,而且我们只认真考虑过 Rust 和 Zig,因为这是我们团队足够熟悉的仅有的系统级语言。在 Rust 和 Zig 之间抉择时,我们考虑最多的因素是:
- 编译时间。 我们的
cargo 编译时间是一个主要的痛点,即使是增量编译也是如此,而且随着我们代码库的增长而变得越来越糟。我们预期使用 Zig 重写后的编译时间会快得多。
- 内存控制。 我们在整个编译过程中使用各种不同的内存分配器(尤其是 arena),并且到处使用结构体数组(struct-of-arrays)布局。Rust 的生态系统一致假设只有一个全局分配器,包括
soa_rs。而 Zig 的整个生态系统都假设使用细粒度的分配器,并且对结构体数组的支持是标准配置。
- 生态相关性。 总体而言,Rust 的生态系统比 Zig 大得多……但这两个生态系统中的包几乎都与我们的特定需求无关。对于我们想要开箱即用的那些小众事物——例如比封装 LLVM 的 C++ 库更快的发射 LLVM bitcode 的方法——存在于 Zig 中的此类代码比 Rust 中的更多。
- 内存不安全辅助。 Rust 旨在将内存不安全的代码隔离在极少数的
unsafe 块中,并使用像 miri 或 Valgrind 这样的工具进行审查。然而,对我们来说,内存不安全的代码并不罕见(稍后会详细介绍),我们最终使用了大约 1,200 处 unsafe(在我们 30 万行 Rust 代码中;相比之下,rustc 的 350 万行代码中大约有 4 万处 unsafe,请记住,对于像 roc 和 rustc 这样发射机器码的编译器来说,做内存不安全的事情是工作的重要组成部分)。Zig 拥有比 Rust 更多的特性来让内存不安全的代码正确运行,这是我们最希望获得帮助的领域。
在重写了一年半之后,我们对 Zig 带来好处的预期与我们得到的实际情况符合得如何?一旦我们无法再使用 Rust 的某些特性,我们最终会想念它们的哪些部分?
没有借用检查的生活
让我们从内存安全开始。微软 2019 年的一篇著名演示文稿在第 10 页幻灯片上写道:
每年通过安全更新解决的漏洞中,约有 70% 仍然是内存安全问题。
该演示文稿的下一张幻灯片按内存安全问题的类型进行了细分,在具体涉及到 Rust 和 Zig 时,描绘了以下画面:
- 2018 年通过安全更新解决的漏洞中,有 83.6% 根本不会受到选择 Rust 还是 Zig 的影响,因为这两种语言处理所有这些场景(越界读写、不安全转换、未初始化读取、栈溢出和非内存安全问题)的方式相同。
- 16.4% 的漏洞具体是释放后使用(use-after-free, UAF)错误。这些本可以通过 Zig 的
ReleaseSafe 运行时内存安全检查、Rust 的借用检查器或 Fil-C 使用的检查来捕获……现代语言有各种方法来帮助捕获 UAF,尽管 2018 年的这些 CVE 几乎肯定来自 C 或 C++ 代码。
ReleaseSafe 通过运行时检查来捕获释放后使用错误,如果程序试图使用已释放的内存,该检查就会引发恐慌(panic)。与 Rust 的安全子集相比,Zig 的检查不够全面,有运行时开销,并且会引发恐慌。即便如此,带有 ReleaseSafe 的 Zig 在 TigerBeetle 数据库的实践中表现极佳。TigerBeetle 最近接受了传奇般严苛的 Jepsen 报告,报告只发现了两个安全 Bug,且均与内存安全无关。
ReleaseFast 在生产构建中跳过这些检查以避免开销,但在调试构建和测试中保留它们,以便在开发过程中捕获内存安全问题。如果你的测试覆盖了每一个现实世界的代码路径,ReleaseFast 将为你提供与 ReleaseSafe 相同的安全性,但这种级别的测试覆盖率在实践中极少可行;真正的悬念在于,在实践中有什么会成为测试覆盖的漏网之鱼。Bun 谈到了他们在释放后使用方面的挣扎,但其他广泛使用 ReleaseFast 进行构建的项目,在其 Zig 代码中并没有因内存不安全而导致的 CVE。Ghostty 是一个例子,Zig 编译器本身是另一个。
Rust 代码在内存安全漏洞方面有不同的来源:几乎每个 Rust 程序在其依赖项的某处都包含 unsafe 部分。不安全的 Rust 具有 ReleaseFast Zig 代码的所有内存不安全风险,但在开发过程中没有任何运行时检查来捕获问题。Rust 生态系统有 miri 来查找非 FFI(外部函数接口)不安全代码中的 Bug,Valgrind 也能提供帮助,但很少有 Rust 项目会使用这两者。即便如此,极少使用 unsafe 并对其进行格外仔细审计的文化规范,在实践中运作良好,使 Rust 赢得了内存安全方面的极高声誉。
当然,Rust 的内存不安全错误在实践中仍然会成为漏网之鱼。Deno 是 Bun 的竞争对手,用 Rust 编写,它曾出现过内存不安全 CVE,包括越界读取以及释放后使用,两者都涉及不安全 Rust 的使用。Rocket(一个 Rust Web 框架)曾出现过释放后使用的 CVE,而 Actix 在其 unsafe 使用率异常高的一段时期内,也曾出现过各种内存不安全 CVE。
当我们为新编译器在 Rust 和 Zig 之间做抉择时,我们很清楚这一切。我们知道 Rust 在内存安全方面享有盛誉,但内存不安全仍然可能发生,我们已经在原编译器中亲身体会到了这一切。我们还知道我们将比典型的 Rust 项目更频繁地使用 unsafe,尽管我们已经在使用 Valgrind,但从 Zig 的附加检查中获得对本质上内存不安全代码的帮助听起来很有吸引力。我们希望让困难的事情变得更容易,并且我们不担心在主要通过具有简单生命周期的 arena 进行分配的编译器中出现释放后使用的问题。
我们知道引人瞩目的 Zig 项目在实践中实现了极佳的性能和内存安全,我们决定努力成为这些成功案例中的另一个。
重写后的内存安全
对某一技术选择在实际中如何表现进行理论推导是很容易的,但实际检验标准是最终用户在现实世界使用中会遇到什么。那么,采用 ReleaseFast 的 Zig 在我们的实践中运作得如何?自从将我们的编译器从 Rust 重写为 Zig 以来,我们遇到了多少次(因释放后使用或任何其他原因引起的)内存损坏事件?
以下是 Roc 的问题追踪器中 Bug 报告的明细,由 Claude Opus 4.8 分类:
| Roc 编译器中的 Bug 类型 |
Rust |
Zig |
| 发生内存损坏的 Bug |
21 |
10 |
| 未发生内存损坏的 Bug |
2,575 |
421 |
| 总计 |
2,596 |
431 |
你可能会纳闷,基于 Rust 的编译器怎么会有内存损坏 Bug,甚至比基于 Zig 的编译器总数的两倍还要多?这又是由于那个讨厌的不安全 Rust(Unsafe Rust)引起的吗?
其实不然。这 21 个内存损坏 Bug 没有一个发生在编译器本身的逻辑中,这证明了 Rust 的借用检查器确实如预期般发挥了作用。我们在基于 Rust 的编译器中出现内存损坏 Bug 的原因是它是一个编译器。
编译器发射机器指令。当机器执行这些指令时,它们可能会导致内存损坏,从而导致经历这些情况的人提交内存损坏 Bug 报告。无论是哪个进程发生了 Bug——编译器还是被编译的程序——在这两种情况下,处理器之所以会做出坏事,都是因为编译器让它这么做的。在这两种情况下,解决方法都是相同的:必须修改编译器的代码,因为正是该代码导致了内存损坏。
就像所有编译器一样,Roc 的编译器也有 Bug,其中一些是导致内存损坏的误编译(miscompilations)。即便如此,虽然基于 Zig 的编译器中的 10 个内存损坏 Bug 中有 8 个也是误编译,但剩下的 2 个确实存在于编译器本身中。两者都是错误报告中的释放后使用(UAF)Bug,症状相同:错误消息中的文件名(一个在 roc check 中,另一个在 roc bundle 中)被渲染为无用的菱形问号字符。Rust 的借用检查器本可以捕获这两者。
现在,假设我们在重写时选择了 Rust,或者选择了带有 ReleaseSafe 的 Zig。在其他条件相同的情况下,在实践中会有什么影响?
| 工具链选择 |
实践中的内存安全影响 |
| Zig ReleaseFast |
2 个 Bug 报告:部分错误无法渲染文件名 |
| Zig ReleaseSafe |
2 个 Bug 报告:部分错误引发恐慌且不渲染 |
| Rust 借用检查器 |
无以上两个 Bug 报告 |
经过 18 个月的开发、总共数百个 Bug 报告以及数十万行代码,我对这个表格进行回顾的主要感悟是,选择不同的行对项目不会产生明显的实质差异。到目前为止,我们的选择得到了我们所希望的结果。
正如我之前指出的,每个项目都有不同的需求。当 Bun 向相反方向重写(从 Zig 到 Rust)时,他们随附的帖子指出:
对于 Bun 来说,正确处理垃圾回收值(来自 JavaScript)和手动管理值的生命周期一直是稳定性的主要来源——最常见的是轻微的内存泄漏,偶尔会导致崩溃。每一次内存分配都必须经过细致的审查。这些字节在哪里被释放?我们如何确保它只被释放一次?我们是否正确检查了 JavaScript 异常?这个垃圾回收指针对于保守的栈扫描器是否可见?这是垃圾回收内存还是手动管理的内存?
Roc 的编译器没有这些特定的挑战,因为它不与 JavaScript 或任何其他追踪式垃圾回收器进行交互。对于 Bun 来说,“释放后使用、双重释放和‘忘记释放’”错误占了“Bug 的很大比例”,而此类错误在 Roc 的 Bug 中只占很小的比例。当然,Roc 的编译器面临着 Bun 没有的其他挑战。不同的项目有不同的需求!
在我们的案例中,我不确定回顾实际发生的情况,如何能得出“我们需要在工具链上进行更大投入以防止编译器本身的内存安全 Bug”的结论。一个更有说服力的理由是,我们将受益于更好的工具来捕获我们编译输出中的内存安全 Bug,而这一直是在借用检查器的范围之外。
编译时间
我们希望从 Zig 获得更快的编译速度。我们得到了吗?
好消息是,zig build --watch -fincremental 可以在大约 35 毫秒内重新构建我们当前约 45 万行 Zig 代码的改动。这甚至比我们当时将 Zig 的编译速度视为重写卖点时的预期还要快!
坏消息是,Zig 当前稳定的 0.16.0 版本有一个 Bug,这会破坏我们代码库上的 -fincremental。修复方案已经落地,但要获取它,我们必须基于开发版(nightly)的 0.17.0 预发布版本进行构建(这包含破坏性的语言修改),并同时将受影响的依赖项进行 Vendor 导入并升级到 0.17.0。我们决定等待下一个稳定版本。
在我们的代码库中包含 Rust 源码的最后一次提交时,以下是在运行 Ubuntu 26 的 Intel 台式机上,冷启动构建(无缓存,但包已下载到本地)与在对解析器进行微小修改后进行增量重建的时间对比:
| Roc 编译器版本 |
行数 (LoC) |
冷启动构建 |
增量重建 |
| 原编译器基于 Rust 1.85.0 |
354K |
32.4 秒 |
10.0 秒 |
| 原编译器基于 Rust 1.97.0 |
354K |
25.4 秒 |
3.4 秒 |
| 功能对等时的重写版基于 Zig 0.16.0 |
320K |
39.6 秒 |
8.6 秒 |
| 今天的重写版基于 Zig 0.17.0 |
464K |
32.1 秒 |
0.035 秒 |
请注意,在功能对等提交时,我们的 Zig 构建配置在每次构建中都会重新构建很少发生改变的产物,后来我们决定仅在需要时才重新构建它们。这就是为什么今天的冷启动构建比当时在 30 万行代码时还要快的原因,尽管自那以后我们的代码行数增加了约 50%。
Rust 1.97 是目前的稳定版本,而 1.85 是 487 天前的稳定版本(即我们的重写达到功能对等所需的时间)。因此,如果我们在这期间一直使用 Rust,我们本可以见证我们的增量构建时间从 10 秒减少到 3.4 秒。这是一个巨大的飞跃!我非常赞赏 Rust 贡献者为缩短构建时间所付出的辛勤努力。如果留在 Rust,在 18 个月内消除 2/3 的增量构建时间将是一个非常受欢迎的变化,这比我在这 18 个月里预期的改进还要大。太棒了!
尽管这一改进令人瞩目,但 Zig 的 35 毫秒仍然遥遥领先。这不仅是 3.4 秒的百分之一,而且属于不同的性能量级——并且这 35 毫秒是在一个比耗时 3.4 秒的 Rust 代码库多出约 50% 代码行的 Zig 代码库上实现的。我预计 Roc 的代码库会继续增长,这一差距也会随之继续扩大;我从未在 Rust 的路线图中听说过任何可与 -fincremental 媲美的计划。
因此,虽然我们决定保留在稳定的 0.16.0 版本(以及我们的许多贡献者使用的是搭载 ARM 处理器的 Mac 笔记本;-fincremental 目前仅在 x86-64 CPU 上工作)意味着我们尚未尝到选择 Zig 进行重写所带来的预期编译时间红利,但我们在下一个稳定的 Zig 版本中无疑非常值得期待!
内存控制:零解析反序列化
Roc 全新的磁盘缓存系统使用了一种技术,我最初是从 Zig 编译器中了解到这种技术的,Casey Muratori 曾告诉我这在游戏编程中是常见做法。它依赖于一个令人欣喜的巧合:如果你以在现代硬件上运行最快的方式来组织内存,你也可以将其直接从磁盘加载到内存中并开始使用,而无需进行任何解析。
它是这样运作的:
- 我们所有的编译器数据结构都表示为基于指针的 32 位索引数组(通常采用结构体数组(struct-of-arrays)的形式)。
- 这不仅节省了内存并运行得更快,还意味着我们的数据结构可以直接写入磁盘,而无需先序列化为不同的格式。
- 更大的好处是,这让我们可以将它们反序列化回内存,而无需对磁盘上的字节进行任何解析。我们将字节加载到内存中,进行一些重定位(relocations),使我们现有的数据结构指向新加载的数组,然后就可以开始工作了。
- 这意味着我们的反序列化速度取决于将字节从磁盘加载到内存中的速度——因此,这实际上受限于 I/O(I/O bound)。如果这些字节已经存在于操作系统的磁盘缓存中,这意味着我们加载先前构建的缓存工作的速度大致相当于
memcpy。
当你连续两次运行 roc check 时,第一次它会使用这种策略将所有输出缓存到磁盘上。第二次,如果输入的源文件没有发生改变,所有解析、类型检查等数据结构会直接从磁盘跃入内存。这速度极快。roc test 类似地缓存纯函数(它们是确定性的)测试的结果,并且所有这些都是在文件级别进行的,因此如果你修改了一个文件,你只需承担重新处理该文件以及依赖它的其他文件的开销。
这种零解析反序列化(zero-parse deserialization)策略之所以可行,仅仅是因为我们所有的编译器数据结构都遵循这种“无指针编程”(programming without pointers)风格。如果我们在所有地方都使用指针(像几乎所有编译器那样),反序列化就不可能实现零解析。
然而,这种方法存在安全风险。类似于内存中的指针可能指向错误的地址(例如导致释放后使用),任何索引都可能在运行时被用作错误数组的查找,此时你最终会得到发生在该位置的任何随机字节。Rust 的借用检查器旨在帮助解决指针生命周期问题,但它并不试图回答“哪个索引对应哪个数组?”这个问题,因为这从未在它的设计范围内。
如果你预先确切知道需要多少个这样的数组,Rust 的 crate compact_arena 可以通过使用宏生成类型标签来帮助你避免索引到错误的数组中。不幸的是,如果你无法预先确切知道需要多少个(例如,因为这取决于模块的数量,正如在我们的用例中那样),这种技术就行不通了。这就是为什么 compact_arena 将 SmallArena::new 标记为 unsafe 的原因。
就我个人而言,我不会将 SmallArena::new 标记为 unsafe。unsafe 应该用来标记代码库中需要格外仔细审计的部分,而创建一个空的 arena 不需要审计,因为它不会导致不安全。不幸向,潜在的不安全操作是索引到数组中,这经常出现。“格外仔细地审计代码库的每一部分”并不是一个好建议,在 Zig 本身已经证明在完全采用这种方式的同时,能够实现零内存安全 CVE 记录的情况下,“避免使用这种能大幅提升性能的技术”也同样不是个好建议。
安全 Rust 在实践中是有效的,因为它假设你的代码库中不安全 Rust(Unsafe Rust)的量很小且被隔离,而这一假设对绝大多数 Rust 代码库都是成立的。但是,如果 unsafe 将像在我们的案例中那样无处不在,这一假设就不再成立,选择一个比不安全 Rust 更安全的语言开始听起来更有吸引力。
生态相关性
Bun 的帖子谈到了 Rust 的 Drop 如何帮助应对他们不同寻常的 JavaScript 互操作挑战:
……Zig 的其他用户没有我们遇到的 Bug,将垃圾回收与手动管理内存相结合对软件需求来说是一种很不常见的事情,以至于没有哪种语言真正为此进行设计。……减少这类问题的一种常见方法是确保清理代码对于需要它的代码总是恰好运行一次。Zig 被设计为一门没有隐藏控制流的简单语言,因此它更倾向于使用显式的 defer 关键字在作用域结束时运行代码,而不是 C++ 隐式的 ~Destructor 析构函数或 Rust 隐式的 Drop。
我们处于相反的境地:Drop 对我们来说一直是一个痛点,因为 Rust 生态系统是围绕着“每个人都在使用全局分配器并使用 Drop 进行隐式释放”这一假设构建的。但我们想要做的几乎恰恰相反:为每个模块和编译阶段使用独立的 arena。Zig 的生态系统一致传递分配器,这正是我们所需要的,而开箱即用的 Rust crate 几乎总是假设存在一个单一的全局分配器。
简单地说,Rust 的生态系统针对 Bun 所期望的编写方式进行了优化,而 Zig 的生态系统则是为 Roc 所期望的编写方式而设计的。
另外,还存在我们可以开箱即用获取哪些相关代码的问题。LLVM 是我们优化器(我们自己进行优化,但 LLVM 会在此之上做更多工作)的关键依赖,但它也是一个定期进行重大破坏性 API 更改的项目。升级到新的 LLVM 版本对 Roc 来说一直是痛苦和浪费时间的主要来源,但我们一直在这样做,因为我们想要全新的优化。
事实证明,LLVM 实际上有一个稳定且向后兼容的 API,可以通过访问它来绕过这种升级痛苦:其序列化的 "bitcode"(字节码)格式。如果你编写了自己的 LLVM bitcode 序列化器,那么你就可以让每个新版本的 LLVM 去消费它,然后你就可以顺利运行了。
当然,要采用这种策略,你需要一个手写的 LLVM bitcode 序列化器,它与 LLVM C++ 库及其破坏性更改解耦。我只知道在野外有这样一个实现:Zig 的编译器,它当然是用 Zig 编写的。现在野外有两个实现了,因为 Roc 的新编译器正在复用相同的 Zig代码。(感谢 Zig 团队的分享!)
你可能已经注意到,我们对 Zig 生态系统最感兴趣的依赖项最大来源正是 Zig 编译器本身。这很不寻常,但 Roc 是一个有着不寻常需求的不寻常项目。当我在 2019 年写下编译器的第一行代码时,我绝不会猜到以下结论会被证实为真:“在未来,该项目最丰富的可复用代码金矿将是一个用你还没听过的语言编写的开源编译器。”
生活总是充满惊喜!
我想念 Rust 的地方
尽管我不再将 Rust 用于 Roc,但我仍然沉浸在 Rust 的世界中,因为我在 Zed 工作,我们在那里几乎所有事情都使用它。所以当我说我在用 Zig 构建时想念 Rust 的某些东西(反之亦然)时,这不仅仅是对遥远过去的粉饰记忆;它更像是当天早些时候的记忆。
让我感到意外地开始想念 Rust 的一件事是测试中的自动分配和释放。
正如前面所讨论的,在编译器的实现中,完全控制分配和释放正是我所想要的。而在测试中,我也很赞赏测试分配器能够检测到泄漏——它甚至能检测出编译后的 Roc 代码中的泄漏!不幸的是,要获得这种好处,需要在测试中编写大量“初始化此项,延迟释放此项(init this, defer deinit)”的代码,这些代码必须正确无误,否则测试就会因内存泄漏而失败。在 Rust 中,这些都不需要。与测试看起来更漂亮相比,我更在乎编译器的实现符合我的期望,但在一个完美的世界里,我希望能两者兼得。
**参数多态(parametric polymorphism)和特设多态(ad hoc polymorphism)**都与 comptime 重叠,因此 Zig 没有它们是合乎逻辑的,但我确实很想念它们。例如,Rust 的 Allocator trait 的 allocate 函数将其第一个参数设为 self,而在 Zig 中,像 ArenaAllocator 这样的分配器实现需要接收一个 anyopaque 指针,然后将其转换为自身。
我也很想念私有结构体字段(private struct fields)。我理解不拥有它们的理由,但如果我使用了一些被标记为“不应该像这样直接访问,尽管如果你真的想的话也是可以做到的”内容时,我确实很想念编译报错。这在审查代码差异(diff)时会遇到,因为在 diff 中我只看到字段访问;我看不见原始结构体定义上的文档,而且我不想每次都费尽心思去防御性地查阅它们。
偶尔我会想念所有的函数、变量和常量都使用蛇形命名法(snake_case)。
我确实很想念 unsafe 和借用检查器的某些方面,尽管它们的优势与我不怀念的劣势打包在一起。我不认为 Zig should 应该加入这两者,但与此同时,只在 unsafe 块内担心某些类别的问题确实让人感到安心。我可能会怀念那种感觉,即使在这个项目中我不想付出相应的代价。
我不确定这在多大程度上是因为 comptime 的工作方式,但我确实发现自己比以前在 Rust 中更容易在我们的 Zig 代码库中发现死代码(dead code)(这既没有被 Zig 内置的工具链捕获,也没有被 TigerBeetle 的 tidy.zig 捕获——顺便说一句,感谢 TigerBeetle 团队开源了它!)。死 Zig 代码不会影响最终用户,因为编译器甚至不会将其发射到二进制文件中,但显而易见,如果我们能更早地发现它们,对我们的代码库会更好。
最后,Rust 团队在发布版本中的向后兼容性方面做出了令人钦佩的工作。升级到新的次要版本几乎不费吹灰之力,甚至版本(edition)升级也大多是无痛的。向后兼容性并非 Zig 在其当前开发阶段的目标,这是我们在加入时就知道并预料到的。这对我们来说不是什么大问题,但我是否想念我们在 Rust 中拥有的微不足道的升级过程?那是当然的!
我喜欢 Zig 的地方
我一直很喜欢函数式编程中的“减法”方面。你可能会认为,从我习惯使用的工具箱中减去一些工具(例如变异、无限制的副作用、对象和类)会令人沮丧……但一旦我适应了不同的技术,我真的开始享受我解锁的新属性(可缓存性、非脆弱的测试、并发的便利性、重新排序操作而完全不用担心其输出可能会改变等),并且再也不想放弃这些属性了。
我对 Zig 也有类似的感受。我喜欢它没有宏。我可能会想念特设多态,但与此同时,我很享受有多少问题(包括参数多态)可以通过 comptime 和/或普通函数来解决。
我喜欢对数据布局的控制。能够开箱即用地使用非 2 的幂的数字类型(例如 u7 和 u5),而无需自己进行任何位级操作,这太棒了。开箱即用的紧凑结构体(packed structs)、在调用处而不是声明处内联函数的选项……这些是你可以通过使用宏从 Rust crate 中获得的东西,但我真的很喜欢在不需要单独依赖项的情况下就能拥有它们。
Zig 的构建工具链是无与伦比的,这大概就是为什么 Uber 即使不使用 Zig 语言也在使用它的原因。为 Alpine Linux 和 WebAssembly 等系统构建自包含的二进制文件进行得非常顺利,即使我们正在做一些奇怪的事情,例如将我们代码库的一部分(“builtins”——实质上是 Roc 的标准库)编译成不透明的二进制数据块(binary blob),并将其包含在最终的可执行文件中。
我也非常喜欢 Zig 的错误处理策略,尤其是堆分配失败如何成为普通的用户空间错误。Roc 也有类似的“错误自然累积”策略(除了使用可以携带负载的匿名和类型),我喜欢这两个策略都胜过 Rust 中使用 Result 的 anyerror、thiserror 或无依赖的原生错误处理。(即便如此,我确实更喜欢 Rust 的后置一元 ? 运算符,而不是 Zig 的 try 关键字,这就是为什么我们在 Roc 中采用了后置一元 ? 运算符的原因。)
然后当然是我之前提到的所有特定于项目的东西:到处都是基于分配器的 API、我们在其他任何地方都找不到的高性能编译器好物的生态系统等等。我不会在这里一一赘述,但除了赞赏它们给项目带来的好处之外,我非常享受它们。
总的来说,我在 Zig 方面获得了非常积极的体验,回顾过去,我真的很开心我们在重写时选择了它!
Roc 的下一步计划
我们的目标是在今年晚些时候落地新编译器的 0.1.0 版本,这将是 Roc 有史以来第一个带版本号的发布。欢迎在此之前尝试开发版(Nightly)构建,尽管在当前状态下,你仍然会遇到各种 Bug、不完整的功能和未完成的文档。在发布之前,我还有大量的文档需要编写!
顺便说一句,Roc 编程语言基金会是一个 501(c)(3) 非营利组织,因此如果你愿意进行捐赠,它在美国将是可以抵税的,我们主要使用捐赠来补偿贡献者。如果你知道有组织愿意在资金或其他方面赞助我们的工作,请与我们取得联系!(另外,如果你认识 GitHub 的任何人,能帮我们加入针对非营利组织的 GitHub 项目(GH for Nonprofits),那将对我们积压的 CI 任务带来巨大帮助。)
再次感谢帮助该语言达到这一里程碑的每一个人。我对下一个里程碑:我们有史以来第一个带版本号的发布,感到无比兴奋!如果你想关注我们的进展、提出问题,或者只是来打个招呼,欢迎随时在 Roc Zulip 上与我们聊天。
How Our Rust-to-Zig Rewrite is Going
加入我们
Zig 中文社区是一个开放的组织,我们致力于推广 Zig 在中文群体中的使用,有多种方式可以参与进来:
- 供稿,分享自己使用 Zig 的心得
- 改进 ZigCC 组织下的开源项目
- 加入微信群、Telegram 群组
在过去的一年半里,构建 Roc 编译器的团队一直在将我们的 30 万行 Rust 代码重写为 Zig,原因我将在下文简述。最近,我们通过了一个令人兴奋的里程碑:实现了与原编译器功能对等(feature parity)!
由于 Bun 项目最近分享了他们向相反方向重写(从 Zig 到 Rust,尽管这只是我们重写之间差异的冰山一角)的经验报告,现在似乎是一个反思我们从 Rust 转向 Zig 进展如何的好时机。
通过功能对等
达到这一里程碑使得更新 Brendan Hansknecht 迷人的 2024 WASM-4 游戏 Rocci Bird(美术由 Luke DeVault 制作)以使用新编译器成为可能。这是一个很好的例子,因为整个游戏只有不到一千行 Roc 代码,你可以在 itch.io 上或直接在这里通过 WebAssembly 游玩:
点击或轻触游戏,然后按空格键(或轻触)来扇动翅膀。在手机上你没有右箭头键,所以请刷新页面以重新开始游戏。
Rocci Bird 更新后的源代码比原来更简洁,现在
roc build --opt=size输出一个 31KB 的 wasm 二进制文件(原编译器生成的二进制文件是这个大小的两倍多)。Rocci Bird 绝不是一个大型代码库,但要让它运行起来,需要在新编译器中落地许多功能。当我们将所有功能跑通,终于看到那些粗糙的紫色像素时,我的脸上露出了笑容!需要澄清的是,这只是一个里程碑,而不是正式发布。(我们的目标是在今年晚些时候发布 0.1.0 版本。)即便如此,能达到这个里程碑依然棒极了,我非常感谢所有共同努力实现这一目标的人!我想特别感谢一些在将语言和编译器推向这一阶段的过程中提供极大帮助的人:
非常感谢大家!能让你们在这个项目上投入这么多宝贵的时间,我感到非常荣幸。同时也要感谢我们过去和现在的赞助商——rwx、Lambda Class、ohne-makler、martian、tweede golf、Vendr、NoRedInk 以及许多慷慨的个人赞助商,他们通过支持我们的贡献者帮助我们走到了今天。
说到时间:我们 487 天的重写时间比 Bun 用 11 天将约 50 万行 Zig 重写为 Rust 多了 476 天。造成这种差异有很多与 Rust 或 Zig 无关的原因,包括他们的重写是直接移植,而我们因为要做出巨大改变而决定推倒重写。他们使用的技术在我们的情况下是行不通的。
由于我们进行了大量的修改,这意味着将我们原有的 Rust 代码库和新的 Zig 代码库进行直接对比是不公平的(非“苹果对苹果”的对比)。尽管如此,我们已经到了反思这次重写进展如何的极佳时间节点,无论是从它为 Roc 程序员解锁了哪些新功能,还是从我们使用 Rust 和 Zig 的体验对比来看。
让我们深入了解一下!
热代码重载 + 交叉编译二进制文件
Roc 的新编译器在开发过程中会自动进行热代码重载(hot code loading)。例如,我可以运行
roc server.roc来启动一个 Web 服务器,然后在它运行时修改其部分代码。下次该服务器处理请求时,它将自动使用新代码来处理。这里是它在服务器和简单 2D 游戏中的实际演示:[下载热重载演示视频。]
热重载对于像 Python 这样的解释型语言来说是标准行为,但对于像 Roc 这样高性能的编译型语言来说却并非如此。当我准备部署时,运行
roc build server.roc可以为我生成一个经 LLVM 优化、自包含的二进制文件,我可以将其直接放到机器上运行。Roc 还支持交叉编译;构建一个运行在 Alpine Linux 上的静态二进制文件就像运行
roc build --target=x64musl一样简单,并且该命令在 Mac 或任何其他系统上运行时,都会产生相同的输出字节(对于相同的输入源码字节)——并非所有编译器都能保证这一点。带有字符串插值的模式匹配
视频中的 HTTP 请求处理逻辑看起来像这样:
这使用了我们在新编译器中引入的几项功能。例如,
"/users/${id}"语法并不是通过在运行时解析模板字符串来实现的,而是通过一项新的语言特性:模式匹配内部的字符串插值。这不仅在编译时是类型安全的,而且这整个代码片段执行了零堆分配(zero heap allocations)。我预计带热代码重载的典型语言在此处平均每行代码需要接近 1 次分配……但 Roc 在人体工程学、类型安全和性能方面有着很高的目标!
你可以在新的 roc-lang.org 主页上体验这一语法——如果你向下滚动一点,页面上就有一个编译器的 WebAssembly 构建版,你可以用它来试用该语言。
顺便说一句,如果你对一篇关于我们如何利用新编译器的纯函数编译时执行将 HTTP 请求路由降低到零分配的技术细节文章感兴趣,请在 Roc Zulip 上告诉我。
为什么要推倒重写?
与 Rust、C 和 Zig 不同,Roc 不是系统级语言;它具有自动内存管理机制(使用引用计数,既能避免追踪式垃圾回收器的停顿,也能进行 Perceus 优化和像 Koka 语言那样的机会性变异)。如果 Roc 需要为每次闭包捕获(closure capture)进行一次堆分配(就像大多数非系统语言那样),它将会有多得多的堆分配,但我们的闭包捕获不需要堆分配,因为 Roc 是第一个通过 Lambda 集特化(lambda set specialization)实现多态去功能化(polymorphic defunctionalization)的非学术界语言。
这听起来可能像是一个小众优化,但在像 Roc 这样的函数式语言中,去功能化被证明与内联类似,因为它解锁了大量后续优化的宝藏。尽管这个系统对 Roc 的运行时性能极其有益,但对我们来说,要正确实现它也极其困难。我们在最初的实现中饱受棘手 Bug 的折磨,直到 Ayaz Hafiz 在 OCaml 中构建了一个新架构的原型后,我们才终于在新编译器中把事情做对。
Ayaz 的原型表明,我们问题的根源在于跨多个编译器阶段的架构问题,而修复它需要重写编译器的大部分内容。这是我们最初决定重写的原因之一——此外,几位贡献者独立提到,由于其他原因,他们计划重写编译器的各个部分。我们意识到,反正我们已经快要重写几乎整个编译器了,因此将完全重写作为“特修斯之船”方式的替代方案是合理的。
编译器开发比较特殊,在成功的项目中推倒重写是常态。这通常是实现自举(self-host)的唯一方法,尽管并非所有编译器都会重写为自己的语言;例如,TypeScript 重写为 Go。我的立场一直都是 Roc 编译器不应该自举,所以坦率地说,有一天重写的收益看起来会超过其声名狼藉的成本这种想法,我从未产生过。
我们讨论得越多,就越觉得做基本上当今所有主流编译器在某个阶段都做过的事是合理的:从头推倒重写。
为什么选择 Zig?
一旦我们决定推倒重写,接下来的问题就是是否再次选择 Rust。基于我们使用 Rust 和 Zig 的经验(我们已经在标准库中的一些基元中使用了 Zig),我们决定这次用 Zig 构建整个编译器。
我喜欢 Rust,我曾讲过一门关于它的课程,并且在 Zed 的日常工作中我也乐于使用它。尽管互联网上的评论可能让我们产生相反的看法,但对于一个项目来说某门语言是最佳选择,而对于另一个项目来说另一门语言是最佳选择,这是极其正常的。一种尺码并不能适合所有人!
我已在其他地方(文章中、播客中等)深入探讨过我们选择 Zig 的原因,而且我们只认真考虑过 Rust 和 Zig,因为这是我们团队足够熟悉的仅有的系统级语言。在 Rust 和 Zig 之间抉择时,我们考虑最多的因素是:
cargo编译时间是一个主要的痛点,即使是增量编译也是如此,而且随着我们代码库的增长而变得越来越糟。我们预期使用 Zig 重写后的编译时间会快得多。soa_rs。而 Zig 的整个生态系统都假设使用细粒度的分配器,并且对结构体数组的支持是标准配置。unsafe块中,并使用像miri或Valgrind这样的工具进行审查。然而,对我们来说,内存不安全的代码并不罕见(稍后会详细介绍),我们最终使用了大约 1,200 处unsafe(在我们 30 万行 Rust 代码中;相比之下,rustc 的 350 万行代码中大约有 4 万处unsafe,请记住,对于像roc和rustc这样发射机器码的编译器来说,做内存不安全的事情是工作的重要组成部分)。Zig 拥有比 Rust 更多的特性来让内存不安全的代码正确运行,这是我们最希望获得帮助的领域。在重写了一年半之后,我们对 Zig 带来好处的预期与我们得到的实际情况符合得如何?一旦我们无法再使用 Rust 的某些特性,我们最终会想念它们的哪些部分?
没有借用检查的生活
让我们从内存安全开始。微软 2019 年的一篇著名演示文稿在第 10 页幻灯片上写道:
该演示文稿的下一张幻灯片按内存安全问题的类型进行了细分,在具体涉及到 Rust 和 Zig 时,描绘了以下画面:
ReleaseSafe运行时内存安全检查、Rust 的借用检查器或Fil-C使用的检查来捕获……现代语言有各种方法来帮助捕获 UAF,尽管 2018 年的这些 CVE 几乎肯定来自 C 或 C++ 代码。ReleaseSafe通过运行时检查来捕获释放后使用错误,如果程序试图使用已释放的内存,该检查就会引发恐慌(panic)。与 Rust 的安全子集相比,Zig 的检查不够全面,有运行时开销,并且会引发恐慌。即便如此,带有ReleaseSafe的 Zig 在 TigerBeetle 数据库的实践中表现极佳。TigerBeetle 最近接受了传奇般严苛的 Jepsen 报告,报告只发现了两个安全 Bug,且均与内存安全无关。ReleaseFast在生产构建中跳过这些检查以避免开销,但在调试构建和测试中保留它们,以便在开发过程中捕获内存安全问题。如果你的测试覆盖了每一个现实世界的代码路径,ReleaseFast将为你提供与ReleaseSafe相同的安全性,但这种级别的测试覆盖率在实践中极少可行;真正的悬念在于,在实践中有什么会成为测试覆盖的漏网之鱼。Bun 谈到了他们在释放后使用方面的挣扎,但其他广泛使用ReleaseFast进行构建的项目,在其 Zig 代码中并没有因内存不安全而导致的 CVE。Ghostty 是一个例子,Zig 编译器本身是另一个。Rust 代码在内存安全漏洞方面有不同的来源:几乎每个 Rust 程序在其依赖项的某处都包含
unsafe部分。不安全的 Rust 具有ReleaseFastZig 代码的所有内存不安全风险,但在开发过程中没有任何运行时检查来捕获问题。Rust 生态系统有miri来查找非 FFI(外部函数接口)不安全代码中的 Bug,Valgrind也能提供帮助,但很少有 Rust 项目会使用这两者。即便如此,极少使用unsafe并对其进行格外仔细审计的文化规范,在实践中运作良好,使 Rust 赢得了内存安全方面的极高声誉。当然,Rust 的内存不安全错误在实践中仍然会成为漏网之鱼。Deno 是 Bun 的竞争对手,用 Rust 编写,它曾出现过内存不安全 CVE,包括越界读取以及释放后使用,两者都涉及不安全 Rust 的使用。Rocket(一个 Rust Web 框架)曾出现过释放后使用的 CVE,而 Actix 在其
unsafe使用率异常高的一段时期内,也曾出现过各种内存不安全 CVE。当我们为新编译器在 Rust 和 Zig 之间做抉择时,我们很清楚这一切。我们知道 Rust 在内存安全方面享有盛誉,但内存不安全仍然可能发生,我们已经在原编译器中亲身体会到了这一切。我们还知道我们将比典型的 Rust 项目更频繁地使用
unsafe,尽管我们已经在使用Valgrind,但从 Zig 的附加检查中获得对本质上内存不安全代码的帮助听起来很有吸引力。我们希望让困难的事情变得更容易,并且我们不担心在主要通过具有简单生命周期的 arena 进行分配的编译器中出现释放后使用的问题。我们知道引人瞩目的 Zig 项目在实践中实现了极佳的性能和内存安全,我们决定努力成为这些成功案例中的另一个。
重写后的内存安全
对某一技术选择在实际中如何表现进行理论推导是很容易的,但实际检验标准是最终用户在现实世界使用中会遇到什么。那么,采用
ReleaseFast的 Zig 在我们的实践中运作得如何?自从将我们的编译器从 Rust 重写为 Zig 以来,我们遇到了多少次(因释放后使用或任何其他原因引起的)内存损坏事件?以下是 Roc 的问题追踪器中 Bug 报告的明细,由 Claude Opus 4.8 分类:
你可能会纳闷,基于 Rust 的编译器怎么会有内存损坏 Bug,甚至比基于 Zig 的编译器总数的两倍还要多?这又是由于那个讨厌的不安全 Rust(Unsafe Rust)引起的吗?
其实不然。这 21 个内存损坏 Bug 没有一个发生在编译器本身的逻辑中,这证明了 Rust 的借用检查器确实如预期般发挥了作用。我们在基于 Rust 的编译器中出现内存损坏 Bug 的原因是它是一个编译器。
编译器发射机器指令。当机器执行这些指令时,它们可能会导致内存损坏,从而导致经历这些情况的人提交内存损坏 Bug 报告。无论是哪个进程发生了 Bug——编译器还是被编译的程序——在这两种情况下,处理器之所以会做出坏事,都是因为编译器让它这么做的。在这两种情况下,解决方法都是相同的:必须修改编译器的代码,因为正是该代码导致了内存损坏。
就像所有编译器一样,Roc 的编译器也有 Bug,其中一些是导致内存损坏的误编译(miscompilations)。即便如此,虽然基于 Zig 的编译器中的 10 个内存损坏 Bug 中有 8 个也是误编译,但剩下的 2 个确实存在于编译器本身中。两者都是错误报告中的释放后使用(UAF)Bug,症状相同:错误消息中的文件名(一个在
roc check中,另一个在roc bundle中)被渲染为无用的菱形问号字符。Rust 的借用检查器本可以捕获这两者。现在,假设我们在重写时选择了 Rust,或者选择了带有
ReleaseSafe的 Zig。在其他条件相同的情况下,在实践中会有什么影响?经过 18 个月的开发、总共数百个 Bug 报告以及数十万行代码,我对这个表格进行回顾的主要感悟是,选择不同的行对项目不会产生明显的实质差异。到目前为止,我们的选择得到了我们所希望的结果。
正如我之前指出的,每个项目都有不同的需求。当 Bun 向相反方向重写(从 Zig 到 Rust)时,他们随附的帖子指出:
Roc 的编译器没有这些特定的挑战,因为它不与 JavaScript 或任何其他追踪式垃圾回收器进行交互。对于 Bun 来说,“释放后使用、双重释放和‘忘记释放’”错误占了“Bug 的很大比例”,而此类错误在 Roc 的 Bug 中只占很小的比例。当然,Roc 的编译器面临着 Bun 没有的其他挑战。不同的项目有不同的需求!
在我们的案例中,我不确定回顾实际发生的情况,如何能得出“我们需要在工具链上进行更大投入以防止编译器本身的内存安全 Bug”的结论。一个更有说服力的理由是,我们将受益于更好的工具来捕获我们编译输出中的内存安全 Bug,而这一直是在借用检查器的范围之外。
编译时间
我们希望从 Zig 获得更快的编译速度。我们得到了吗?
好消息是,
zig build --watch -fincremental可以在大约 35 毫秒内重新构建我们当前约 45 万行 Zig 代码的改动。这甚至比我们当时将 Zig 的编译速度视为重写卖点时的预期还要快!坏消息是,Zig 当前稳定的 0.16.0 版本有一个 Bug,这会破坏我们代码库上的
-fincremental。修复方案已经落地,但要获取它,我们必须基于开发版(nightly)的 0.17.0 预发布版本进行构建(这包含破坏性的语言修改),并同时将受影响的依赖项进行 Vendor 导入并升级到 0.17.0。我们决定等待下一个稳定版本。在我们的代码库中包含 Rust 源码的最后一次提交时,以下是在运行 Ubuntu 26 的 Intel 台式机上,冷启动构建(无缓存,但包已下载到本地)与在对解析器进行微小修改后进行增量重建的时间对比:
请注意,在功能对等提交时,我们的 Zig 构建配置在每次构建中都会重新构建很少发生改变的产物,后来我们决定仅在需要时才重新构建它们。这就是为什么今天的冷启动构建比当时在 30 万行代码时还要快的原因,尽管自那以后我们的代码行数增加了约 50%。
Rust 1.97 是目前的稳定版本,而 1.85 是 487 天前的稳定版本(即我们的重写达到功能对等所需的时间)。因此,如果我们在这期间一直使用 Rust,我们本可以见证我们的增量构建时间从 10 秒减少到 3.4 秒。这是一个巨大的飞跃!我非常赞赏 Rust 贡献者为缩短构建时间所付出的辛勤努力。如果留在 Rust,在 18 个月内消除 2/3 的增量构建时间将是一个非常受欢迎的变化,这比我在这 18 个月里预期的改进还要大。太棒了!
尽管这一改进令人瞩目,但 Zig 的 35 毫秒仍然遥遥领先。这不仅是 3.4 秒的百分之一,而且属于不同的性能量级——并且这 35 毫秒是在一个比耗时 3.4 秒的 Rust 代码库多出约 50% 代码行的 Zig 代码库上实现的。我预计 Roc 的代码库会继续增长,这一差距也会随之继续扩大;我从未在 Rust 的路线图中听说过任何可与
-fincremental媲美的计划。因此,虽然我们决定保留在稳定的 0.16.0 版本(以及我们的许多贡献者使用的是搭载 ARM 处理器的 Mac 笔记本;
-fincremental目前仅在 x86-64 CPU 上工作)意味着我们尚未尝到选择 Zig 进行重写所带来的预期编译时间红利,但我们在下一个稳定的 Zig 版本中无疑非常值得期待!内存控制:零解析反序列化
Roc 全新的磁盘缓存系统使用了一种技术,我最初是从 Zig 编译器中了解到这种技术的,Casey Muratori 曾告诉我这在游戏编程中是常见做法。它依赖于一个令人欣喜的巧合:如果你以在现代硬件上运行最快的方式来组织内存,你也可以将其直接从磁盘加载到内存中并开始使用,而无需进行任何解析。
它是这样运作的:
memcpy。当你连续两次运行
roc check时,第一次它会使用这种策略将所有输出缓存到磁盘上。第二次,如果输入的源文件没有发生改变,所有解析、类型检查等数据结构会直接从磁盘跃入内存。这速度极快。roc test类似地缓存纯函数(它们是确定性的)测试的结果,并且所有这些都是在文件级别进行的,因此如果你修改了一个文件,你只需承担重新处理该文件以及依赖它的其他文件的开销。这种零解析反序列化(zero-parse deserialization)策略之所以可行,仅仅是因为我们所有的编译器数据结构都遵循这种“无指针编程”(programming without pointers)风格。如果我们在所有地方都使用指针(像几乎所有编译器那样),反序列化就不可能实现零解析。
然而,这种方法存在安全风险。类似于内存中的指针可能指向错误的地址(例如导致释放后使用),任何索引都可能在运行时被用作错误数组的查找,此时你最终会得到发生在该位置的任何随机字节。Rust 的借用检查器旨在帮助解决指针生命周期问题,但它并不试图回答“哪个索引对应哪个数组?”这个问题,因为这从未在它的设计范围内。
如果你预先确切知道需要多少个这样的数组,Rust 的 crate
compact_arena可以通过使用宏生成类型标签来帮助你避免索引到错误的数组中。不幸的是,如果你无法预先确切知道需要多少个(例如,因为这取决于模块的数量,正如在我们的用例中那样),这种技术就行不通了。这就是为什么compact_arena将SmallArena::new标记为unsafe的原因。就我个人而言,我不会将
SmallArena::new标记为unsafe。unsafe应该用来标记代码库中需要格外仔细审计的部分,而创建一个空的 arena 不需要审计,因为它不会导致不安全。不幸向,潜在的不安全操作是索引到数组中,这经常出现。“格外仔细地审计代码库的每一部分”并不是一个好建议,在 Zig 本身已经证明在完全采用这种方式的同时,能够实现零内存安全 CVE 记录的情况下,“避免使用这种能大幅提升性能的技术”也同样不是个好建议。安全 Rust 在实践中是有效的,因为它假设你的代码库中不安全 Rust(Unsafe Rust)的量很小且被隔离,而这一假设对绝大多数 Rust 代码库都是成立的。但是,如果
unsafe将像在我们的案例中那样无处不在,这一假设就不再成立,选择一个比不安全 Rust 更安全的语言开始听起来更有吸引力。生态相关性
Bun 的帖子谈到了 Rust 的
Drop如何帮助应对他们不同寻常的 JavaScript 互操作挑战:我们处于相反的境地:
Drop对我们来说一直是一个痛点,因为 Rust 生态系统是围绕着“每个人都在使用全局分配器并使用Drop进行隐式释放”这一假设构建的。但我们想要做的几乎恰恰相反:为每个模块和编译阶段使用独立的 arena。Zig 的生态系统一致传递分配器,这正是我们所需要的,而开箱即用的 Rust crate 几乎总是假设存在一个单一的全局分配器。简单地说,Rust 的生态系统针对 Bun 所期望的编写方式进行了优化,而 Zig 的生态系统则是为 Roc 所期望的编写方式而设计的。
另外,还存在我们可以开箱即用获取哪些相关代码的问题。LLVM 是我们优化器(我们自己进行优化,但 LLVM 会在此之上做更多工作)的关键依赖,但它也是一个定期进行重大破坏性 API 更改的项目。升级到新的 LLVM 版本对 Roc 来说一直是痛苦和浪费时间的主要来源,但我们一直在这样做,因为我们想要全新的优化。
事实证明,LLVM 实际上有一个稳定且向后兼容的 API,可以通过访问它来绕过这种升级痛苦:其序列化的 "bitcode"(字节码)格式。如果你编写了自己的 LLVM bitcode 序列化器,那么你就可以让每个新版本的 LLVM 去消费它,然后你就可以顺利运行了。
当然,要采用这种策略,你需要一个手写的 LLVM bitcode 序列化器,它与 LLVM C++ 库及其破坏性更改解耦。我只知道在野外有这样一个实现:Zig 的编译器,它当然是用 Zig 编写的。现在野外有两个实现了,因为 Roc 的新编译器正在复用相同的 Zig代码。(感谢 Zig 团队的分享!)
你可能已经注意到,我们对 Zig 生态系统最感兴趣的依赖项最大来源正是 Zig 编译器本身。这很不寻常,但 Roc 是一个有着不寻常需求的不寻常项目。当我在 2019 年写下编译器的第一行代码时,我绝不会猜到以下结论会被证实为真:“在未来,该项目最丰富的可复用代码金矿将是一个用你还没听过的语言编写的开源编译器。”
生活总是充满惊喜!
我想念 Rust 的地方
尽管我不再将 Rust 用于 Roc,但我仍然沉浸在 Rust 的世界中,因为我在 Zed 工作,我们在那里几乎所有事情都使用它。所以当我说我在用 Zig 构建时想念 Rust 的某些东西(反之亦然)时,这不仅仅是对遥远过去的粉饰记忆;它更像是当天早些时候的记忆。
让我感到意外地开始想念 Rust 的一件事是测试中的自动分配和释放。
正如前面所讨论的,在编译器的实现中,完全控制分配和释放正是我所想要的。而在测试中,我也很赞赏测试分配器能够检测到泄漏——它甚至能检测出编译后的 Roc 代码中的泄漏!不幸的是,要获得这种好处,需要在测试中编写大量“初始化此项,延迟释放此项(init this, defer deinit)”的代码,这些代码必须正确无误,否则测试就会因内存泄漏而失败。在 Rust 中,这些都不需要。与测试看起来更漂亮相比,我更在乎编译器的实现符合我的期望,但在一个完美的世界里,我希望能两者兼得。
**参数多态(parametric polymorphism)和特设多态(ad hoc polymorphism)**都与
comptime重叠,因此 Zig 没有它们是合乎逻辑的,但我确实很想念它们。例如,Rust 的Allocatortrait 的allocate函数将其第一个参数设为self,而在 Zig 中,像ArenaAllocator这样的分配器实现需要接收一个anyopaque指针,然后将其转换为自身。我也很想念私有结构体字段(private struct fields)。我理解不拥有它们的理由,但如果我使用了一些被标记为“不应该像这样直接访问,尽管如果你真的想的话也是可以做到的”内容时,我确实很想念编译报错。这在审查代码差异(diff)时会遇到,因为在 diff 中我只看到字段访问;我看不见原始结构体定义上的文档,而且我不想每次都费尽心思去防御性地查阅它们。
偶尔我会想念所有的函数、变量和常量都使用蛇形命名法(snake_case)。
我确实很想念
unsafe和借用检查器的某些方面,尽管它们的优势与我不怀念的劣势打包在一起。我不认为 Zig should 应该加入这两者,但与此同时,只在unsafe块内担心某些类别的问题确实让人感到安心。我可能会怀念那种感觉,即使在这个项目中我不想付出相应的代价。我不确定这在多大程度上是因为
comptime的工作方式,但我确实发现自己比以前在 Rust 中更容易在我们的 Zig 代码库中发现死代码(dead code)(这既没有被 Zig 内置的工具链捕获,也没有被 TigerBeetle 的tidy.zig捕获——顺便说一句,感谢 TigerBeetle 团队开源了它!)。死 Zig 代码不会影响最终用户,因为编译器甚至不会将其发射到二进制文件中,但显而易见,如果我们能更早地发现它们,对我们的代码库会更好。最后,Rust 团队在发布版本中的向后兼容性方面做出了令人钦佩的工作。升级到新的次要版本几乎不费吹灰之力,甚至版本(edition)升级也大多是无痛的。向后兼容性并非 Zig 在其当前开发阶段的目标,这是我们在加入时就知道并预料到的。这对我们来说不是什么大问题,但我是否想念我们在 Rust 中拥有的微不足道的升级过程?那是当然的!
我喜欢 Zig 的地方
我一直很喜欢函数式编程中的“减法”方面。你可能会认为,从我习惯使用的工具箱中减去一些工具(例如变异、无限制的副作用、对象和类)会令人沮丧……但一旦我适应了不同的技术,我真的开始享受我解锁的新属性(可缓存性、非脆弱的测试、并发的便利性、重新排序操作而完全不用担心其输出可能会改变等),并且再也不想放弃这些属性了。
我对 Zig 也有类似的感受。我喜欢它没有宏。我可能会想念特设多态,但与此同时,我很享受有多少问题(包括参数多态)可以通过
comptime和/或普通函数来解决。我喜欢对数据布局的控制。能够开箱即用地使用非 2 的幂的数字类型(例如
u7和u5),而无需自己进行任何位级操作,这太棒了。开箱即用的紧凑结构体(packed structs)、在调用处而不是声明处内联函数的选项……这些是你可以通过使用宏从 Rust crate 中获得的东西,但我真的很喜欢在不需要单独依赖项的情况下就能拥有它们。Zig 的构建工具链是无与伦比的,这大概就是为什么 Uber 即使不使用 Zig 语言也在使用它的原因。为 Alpine Linux 和 WebAssembly 等系统构建自包含的二进制文件进行得非常顺利,即使我们正在做一些奇怪的事情,例如将我们代码库的一部分(“builtins”——实质上是 Roc 的标准库)编译成不透明的二进制数据块(binary blob),并将其包含在最终的可执行文件中。
我也非常喜欢 Zig 的错误处理策略,尤其是堆分配失败如何成为普通的用户空间错误。Roc 也有类似的“错误自然累积”策略(除了使用可以携带负载的匿名和类型),我喜欢这两个策略都胜过 Rust 中使用
Result的anyerror、thiserror或无依赖的原生错误处理。(即便如此,我确实更喜欢 Rust 的后置一元?运算符,而不是 Zig 的try关键字,这就是为什么我们在 Roc 中采用了后置一元?运算符的原因。)然后当然是我之前提到的所有特定于项目的东西:到处都是基于分配器的 API、我们在其他任何地方都找不到的高性能编译器好物的生态系统等等。我不会在这里一一赘述,但除了赞赏它们给项目带来的好处之外,我非常享受它们。
总的来说,我在 Zig 方面获得了非常积极的体验,回顾过去,我真的很开心我们在重写时选择了它!
Roc 的下一步计划
我们的目标是在今年晚些时候落地新编译器的 0.1.0 版本,这将是 Roc 有史以来第一个带版本号的发布。欢迎在此之前尝试开发版(Nightly)构建,尽管在当前状态下,你仍然会遇到各种 Bug、不完整的功能和未完成的文档。在发布之前,我还有大量的文档需要编写!
顺便说一句,Roc 编程语言基金会是一个 501(c)(3) 非营利组织,因此如果你愿意进行捐赠,它在美国将是可以抵税的,我们主要使用捐赠来补偿贡献者。如果你知道有组织愿意在资金或其他方面赞助我们的工作,请与我们取得联系!(另外,如果你认识 GitHub 的任何人,能帮我们加入针对非营利组织的 GitHub 项目(GH for Nonprofits),那将对我们积压的 CI 任务带来巨大帮助。)
再次感谢帮助该语言达到这一里程碑的每一个人。我对下一个里程碑:我们有史以来第一个带版本号的发布,感到无比兴奋!如果你想关注我们的进展、提出问题,或者只是来打个招呼,欢迎随时在 Roc Zulip 上与我们聊天。
加入我们
Zig 中文社区是一个开放的组织,我们致力于推广 Zig 在中文群体中的使用,有多种方式可以参与进来: