Bun 快速 Rust 重写背后的 AI 经验教训

嗨,我是格尔盖利,今天为大家带来《实用工程师》通讯的额外免费特辑。在每一期中,我都会从资深工程师和工程领导者的角度,深度剖析大型科技公司及初创企业的发展现状。

今天,我们将聚焦上周《脉动》专题中的四个主题之一。全订阅用户早在七天前就已收到下方的文章。如果你是通过邮件转发收到此消息,欢迎点击此处订阅。

上周,我在旧金山与 JavaScript 运行时引擎 Bun 的开发者贾雷德·萨姆纳会面,当时我非常想深入了解 Bun 从 Zig 重写为 Rust 的全过程。

然而,当时贾雷德并不愿多谈太多,因为用于迁移的工具 Fable 正因美国政府实施出口管制而暂时停用。上周,我和贾雷德在 Anthropic 总部相聚。

幸运的是,目前局势已经得到妥善解决,Fable 已经在全球范围内正式上线,贾雷德也发布了一篇详尽的项目回顾文章。在深入探讨迁移过程之前,我们先来了解一下背景:Bun 是一个复杂且庞大的项目,许多生产级软件都依赖于它。

Bun 本身承担着诸多功能:JavaScript、TypeScript 和 CSS 的转译、压缩与打包;一个测试运行器;一个兼容 npm 的包管理器;此外,还包含模块解析、WebSocket 客户端、Node.js 实现以及众多其他模块。

如今,Bun 的月下载量已达到 2200 万次,诸如 Claude Code 和 OpenCode 等软件都依赖于它;而 Vercel、Railway 和 DigitalOcean 等托管服务商则为 Bun 提供了原生支持。

那么,为什么要进行重写呢?Zig 并不是一种内存安全的语言,但内存相关错误却屡见不鲜。贾雷德在 Bun 的最新版本中列出了多处内存相关的错误:内存泄漏、因内存问题导致的崩溃、堆越界写入等等。

此前,Bun 团队对 Zig 编译器进行了修复,以大幅降低内存相关问题,并在全链路中引入了内存泄漏检测机制。正如贾雷德所说:“我们的漏洞修复清单简直让人头疼,而且我早已厌倦了每晚辗转反侧,担心 Bun 中的崩溃问题。我并不责怪 Zig——其他使用 Zig 的用户并没有我们遇到过的那些问题,而将垃圾回收与手动管理的内存相结合,这种做法在软件开发中其实并不常见,甚至几乎没有任何语言会特意为此设计。”(……对于 Bun 来说,正确处理垃圾回收值与手动管理值的生命周期,一直是稳定性问题的主要来源——最常见的是小规模的内存泄漏,偶尔也会引发崩溃。每一次内存分配都必须经过细致的审查:这些字节究竟在哪里被释放?我们该如何确保它们只被释放一次?我们是否充分检查了 JavaScript 异常?这个由垃圾回收机制管理的指针,是否能被保守的栈扫描器识别?这些内存究竟是由垃圾回收机制管理的,还是由手动管理的?如果转向一种内存安全且性能卓越的语言,就能有效杜绝这类错误。Rust 正是这样一种语言——它完全符合我们的需求。贾雷德表示:“这份清单上大部分的错误,都源于‘使用后释放’、‘双重释放’,以及在错误路径中‘忘记释放’的情况。而在安全的 Rust 中,这些问题都可以通过编译器错误来解决,并借助 RAII 风格的自动清理机制——通过 Drop 函数实现。编译器错误所带来的反馈回路,远比风格指南更能发挥实际作用。”不过,全面重写 Rust 一直是个糟糕的主意——至少,过去是这样。毕竟,重写程序的耗时实在太长了:重写工作存在两大弊端:耗时过长,而且耗时太长!一位曾参与过重写的开发者大概深知事情的走向:你得先大致估算一下需要多长时间——比如,九个月。然而,九个月之后,由于新功能不断被添加到原始代码库中,又得再花上大约六个月的时间来完成这些新增功能!等到十五个月时,依然还有好几个月的工期,原因依旧相同!最终,你只能勉强将项目“冻结”两个月,运气好的话,大概能在十八个月内完成重写。原本预计的九个月时间,往往要耗费两年甚至更久。贾雷德将 Bun 从 Zig 重写的过程比作这样:“从历史上看,重写从来都不是一件好事。除去注释,Bun 一共有 535,496 行 Zig 代码。若要改写成另一种语言,一支小型工程师团队可能需要整整一年的时间。然而,一年内完全没有面向用户的改动,这显然不是我们真正能考虑的现实方案。因此,通过调整代码风格来解决稳定性问题,成了我们最好的选择——这也是我们在 Bun 的代码库中加入受 Rust 启发的智能指针时所制定的计划。但说实话,我其实并不想这么做。自研的智能指针在人体工学方面远不如 Rust,而且根本无法提供任何保障。那如果我花上一周时间,去测试 Anthropic 的新模型 [Fable] 是否能将 Bun 重写为 Rust 呢?”用 Fable 重写 Bun 毫无意外地发现,这次重写远没有想象中那么简单——你不能只是简单地输入一句提示语:“Claude,把 Bun 重写为 Rust。不要犯任何错误。”相反,贾雷德的重写之路是这样的:第一步:前期准备。贾雷德详细解释道:“在开始编写任何代码之前,我花了大约三个小时与 Claude 讨论如何将 Zig 代码库中的各种模式精准映射到 Rust 中。Claude 将这次讨论整理成一份 PORTING.md 文档,这份文档后来登上了 Hacker News,成为了 Zig → Rust 的移植指南。”这份指南长达 600 行,其中包含了大量指导性内容,例如:基本规则:禁止使用 tokio、rayon、hyper、async-trait、futures;禁止使用 std::fs、std::net、std::process。Bun 自己负责事件循环和系统调用。(Rust 核心/标准库中的 slice、iter、mem、fmt,以及 core::ffi 都可以使用——只有那些涉及 I/O 的模块被严格禁止。禁止使用 async fn。一切皆基于回调函数与状态机,与 Zig 完全一致。允许对借用检查器进行重塑。当匹配 Zig 流程时出现重叠的 &mut 时,将所需的标量(如 .len()、index)捕获到局部变量中,释放借用,然后再重新借用。切勿为了单纯消除借用检查而直接接触原始指针;请务必保留 // PORT 注意:为了便于借用检查器的解读,我们对流程进行了重塑,以避免 B 阶段的读者产生混淆。这一系列指令,对于精通 Rust 的人来说,其实相当清晰易懂。如果你想进一步了解,我们还特别邀请 Alice Ryhl 为大家讲解 Rust 的基础知识,以及 Rust 为何与众不同。第二步:试运行 + 对抗式评审。我们请 Claude 重写 1,448 个文件中的三个文件。重写完成后,贾雷德又分别与 Claude 进行了两次对抗式评审,对结果进行细致的批判——这两次评审的时机,与 Claude 修改代码的那次评审时间截然不同。第三步:将任务分解为 64 个 AI 代理。贾雷德将工作拆分为多个独立的代理,让它们并行开展任务,各自独立完成自己的工作。第四步:在运行过程中逐步解决各类问题(约 1 天)。当贾雷德尝试全面运行所有这些任务时,各代理之间却频频发生冲突:“我请 Claude 对全部 1,448 个 Zig 文件进行流程循环,大约运行了两分钟,就有个 Claude 在提交代码前先执行了 git stash。另一个则直接执行了 git stash pop,随后又执行了 git reset HEAD --hard。他们竟然互相踩踏!如果我把每个 Claude 分别放入不同的工作树中,就会因为 Bun 的 Git 仓库体积过大而耗尽磁盘空间,最终这些更改不得不被合并在一起进行编译和查看。于是,我请求 Claude 调整工作流,指示他永远不要执行 git stash、git reset,或任何不会一次性提交特定文件的 Git 命令。也不再使用任何载荷。完全杜绝缓慢的命令。随后,Claude 重新启动了工作流,而这一切果然奏效!只是速度有点慢,于是我将工作流拆分成 4 个独立的工作单元,每个工作单元都有自己的工作树(总共 4 个工作树),每个工作树下有 16 个 Claude,负责提交和推送文件。”第五步:让其运行,并等待约 2 天。并行运行的代理们开始投入工作,在短短两天内完成了 535,496 行 Zig 代码的重写。每次提交都会经过两次对抗式评审,然后才正式提交。第七步:修复约 1,600 个编译器错误(约 12 小时)。重写工作虽然已完成,但仍有部分代码未能成功编译。贾雷德逐一逐个地将代码导入到各个 crate 中——“crate”是 Rust 中用来指代顶级编译单元的概念——让 Claude 来修复编译器错误。对一名工程师来说,这本就是一项艰巨的任务,但对于 Claude 来说却轻而易举:“通过修复循环依赖关系,我们发现了大约 16,000 个编译器错误。对于一个人而言,这是一个庞大的数字;但对于 64 个 Claude 来说,这其实不算什么大数目。为了最大化……

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论