60K 行代码:Bun 想给 JS 真·多线程

javascriptwebkitbunruntime

数据源:HN + Lobsters · HN

Jarred-Sumner 在两周前向 oven-sh/WebKit 提交了一个 PR:#249。151 个 commit,279276 行新增,4272 行删除。标题里带着一个坦诚的括号——“experimental, not working yet”。

但真正让这个 PR 在 HN 上拿到 137 分、284 条评论的原因,不是行数。而是它做的事情:在 JavaScriptCore 上实现共享内存的多线程。

不是 Worker + postMessage,不是 SharedArrayBuffer 上手工搭个内存分配器。new Thread(fn) 把函数扔到另一个核上运行,同一个堆,同一个对象。笔者仔细读完了 PR 说明和社区的讨论,下面做一个技术拆解。

一个 API 说清楚差异

先看代码。这是今天的 JavaScript 在 Worker 里做并行计算的”标准答案”:

const src = `self.onmessage = e => self.postMessage((${heavy.toString()})(e.data))`;
const worker = new Worker(URL.createObjectURL(new Blob([src])));
const result = await new Promise((resolve, reject) => {
  worker.onmessage = e => resolve(e.data);
  worker.onerror = reject;
});
worker.terminate();

把函数 toString 塞进一个 Blob URL,交给 Worker 去 eval。闭包消失了,import 不可见,异常变成 ErrorEvent。这是两个进程之间的序列化管道——数据需要序列化后传过去。

而这个 PR 给出的版本:

const result = new Thread(heavy, input).join();

heavy 是一个真实的闭包,它看得到你的 import、类、周边变量。抛出异常时 join() 重新抛出同一个异常对象,带着真实的调用栈。没有 structured clone,没有消息协议,没有 Blob URL——“you share an object by sharing the object”。

这个差异是整个 PR 的核心命题。Worker 给 JavaScript 提供了一个多进程模型,SharedArrayBuffer 在上面开了个字节数组的洞。但这个 PR 想做的是多线程模型——让 Map 是真正的共享 Map,让 Atomics 能直接操作对象属性,让条件变量握手发生在 JS 对象之间而不是整数的 wait/notify 上。

技术路线:Pizlo 的 2017 年设计复活

这个 PR 不是凭空冒出来的。它是对 Filip Pizlo 在 2017 年发表的博客文章 Concurrent JavaScript: It Can Work! 的一次工程化实现。

Pizlo 当时是 WebKit 的 JSC 核心工程师(后来去了 Apple,现在在 Bloomberg)。他在那篇文章里提出了一个非常具体的实现方案,核心思想可以概括为一句话:不让不共享的对象为共享买单

怎么做到的?有三个关键机制:

TID 标记的蝴蝶(Butterfly)。JSC 内部用 butterfly 来存储对象的属性和数组元素——一个紧凑的内存布局,类似一个”胖指针”。PR 在 butterfly 指针的空闲位里塞入了线程 ID。对象被创建后,只有创建它的线程能零成本访问。当另一个线程第一次写入时,对象才”转换”为共享状态。这给了 JIT 一个推测机会:编译时假设对象是线程局部的,运行时如果发现共享再重新编译。

分段蝴蝶(Segmented Butterfly)。对象变成共享后,它的属性存储从一段连续内存切换成不可变脊骨(immutable spine)加片段(fragment)的架构。扩容时追加新片段(而非 realloc + copy),这是让并发 resize 安全的要害——读操作全程不用加锁。

每个对象两比特的 cell lock。用于元数据操作的互斥——比如字典模式的属性添加、某些删除操作。删除的槽位会被隔离直到 GC 安全点,防止并发读看到重复利用的内存。

两阶段的 bring-up 策略

PR 的工程策略值得单独说。实现分两个阶段:

Phase 1:在全局锁下完成所有机制。先让 tagged/segmented butterfly、cell lock、watchpoint、共享堆服务器全部就位,但 JS 执行仍然串行在一个全局锁下。目标是用 GIL 作为”语义预言机”——所有并发对象模型路径都能被确定性地验证,跑 ThreadSanitizer 清到零告警。

Phase 2:拆锁(ungil)。移除全局锁,实现真正的并行。包括每个线程的 VM 入口、stop-the-world conductor 协议(用于 watchpoint 触发、haveABadTime、OSR 等真正需要同步的场景)、每个线程独立的微任务队列、以及一个反复迭代了 32 次的线程退出协议。

Phase 2 的 bring-up 日志几乎是一部 JSC 内部架构的教科书。排查过程中暴露的 root cause 序列包括:

  • 栈溢出检查。每个 tier 生成代码里比较栈指针的方式是读出 VM 全局的栈限制——线程 B 在检查线程 A 的栈。修复涉及 LLInt(三个后端)、Baseline、DFG、FTL、thunks、Yarr,以及每个 C++ 读取路径。
  • 异常状态链ThrowScope/ExceptionScope 链锚定在启动线程的栈上——子线程抛出异常时在走另一个线程的栈帧。110 个测试中 104 个的失败源头是这一个机制。
  • stop-the-world 同步Atomics.wait 里休眠的线程没有轮询新的每线程停止标记——stop-the-world 等 30 秒等不到世界停下来。
  • OSR exit 缓冲区。两个线程同时做 DFG exit 把寄存器文件写进了同一块 scratch buffer——线程 B 带着线程 A 的对象指针恢复 Baseline 执行。这是最可怕的崩溃签名(活对象上的垃圾 structure ID)的来源。

每个问题都绑定了工程化验证:flag-off 生成字节级一致的机器码,Phase 1(GIL)作为绿色回退,串行性能浮动控制在 1% 以内。

性能数据:诚实且刺眼

PR 附带了面对真实压力的基准测试。作者写了一个文档索引和查询引擎,分别用 JavaScript(这个分支)、Go、Java 实现,跑 1–32 线程,输出 checksum 验证正确性。

先看”扁平版本”(Int32Array 替代字符串/Map/BigInt,但线程、锁、GC 完全一致):

线程数JS(扁平)JavaGoGo(GOGC=off)
13759 ms1974 ms1836 ms
81010 ms939 ms535 ms
16872 ms976 ms422 ms354 ms
32870 ms1022 ms378 ms

16 线程时 JS 是 Java 的 0.89×,离 Go 的无 GC 极限 2.46× 差距明显。但 JS 跨过了负扩展的陷阱——从 8 到 32 线程保持住了收益。

再看”规格精确版”(用真实的字符串、Map<string>、任意精度 BigInt——就像真实代码那样写):

线程数JSJavaGo
1~19600 ms1974 ms1836 ms
16~13000 ms976 ms422 ms

JS 在 16 线程下比 Java 慢约 13×。PR 用区分臂(discriminating arms)把这个差距拆解了:约 40% 来自堆分配的 BigInt(缺乏原生 u64 路径),约 50% 来自字符串 + Map 查询(Java 的 String/HashMap 更快),只有约 10% 来自线程机制本身。

这个数据在今天很难看。但它在两阶段改进了三个数量级——“四星期前第一个测量版本在所有线程数上都是负扩展”。

争议的根源不在技术

PR 本身的工程质量非常高——每个设计决策都有 frozen spec 支撑(GIL 移除规范经历了约 30 轮评审、32 次修订),每个已知问题都有文档记录。但它在 HN 上引发了远超技术讨论的争议。

主要原因之一是 Bun 最近从 Zig 迁移到 Rust 的重写风波。一个 1800 文件的 PR 由 AI 生成、一个人 oversee 的信息公开方式,让部分社区成员对项目的信任产生了根本动摇。

“1800 files change PRs created by Anthropic overseen by one person is not necessarily adding to the package. Even if that’d be the best code and design in the world, I won’t use it. I don’t trust it.”——这段 HN 评论获得了大量认同。

另一个声音来自实际用户:他们遇到的使用后释放(use-after-free)bug、0 代替正确返回值的未实现功能、流式响应导致 OOM 的回压问题——“I’d prefer Bun to work on stability rather than fancy features like image processing or threads.”

这种张力是 2026 年的 JavaScript 运行时格局里绕不过去的。Bun 在 DX(开发体验)上的优势(TypeScript 开箱即用、ESM/CJS 互操作、内置测试工具、极快的启动速度)是真实的。但信任积累需要时间——尤其是在核心基础设施软件上。Node.js 的治理结构花了十多年才建立,Deno 也走了好几年才从”玩具”变成可用。Bun 才四年,又经历 Zig→Rust 的重构换血,再叠加 AI 生成代码的不确定性,质疑并不意外。

这个 PR 在 JavaScript 引擎格局中的位置

即使不考虑 Bun 的生态争议,这个 PR 本身的技术选择也值得放到更宽的图景里看。

V8 的选择是 Worker + SharedArrayBuffer —— 多进程模型加一个字节数组逃生舱。这个选择保守但务实:V8 需要服务 Chrome 浏览器,浏览器的安全模型天然排斥共享堆——两个标签页不能共享 JS 对象是安全基线的组成部分。Bun 作为一个服务端运行时,没有这个约束,可以探索浏览器不能走的方向。

SpiderMonkey 在 Firefox 中引入了 arena 分配的 StructuredClone 优化路径,但也止步于 SharedArrayBuffer。三大引擎里,JSC 是唯一一个有过共享堆线程 formal proposal 的——Pizlo 2017 年的设计一直在等一个实现者。

这个 PR 就是那个实现者。它基本遵循了 Pizlo 的设计,但做了自己的工程选择:比如在多线程活跃时暂时牺牲并发标记(v1 使用同步 stop-the-world GC),比如 Promise 回调在 resolve 线程上跑而非注册线程上跑(registrant-affinity 模式被推迟了)。

还有一个细节值得注意:这个 PR 引入了一个 Thread.restrict(obj) 方法,把一个对象钉死在当前线程,其他线程访问会抛 ConcurrentAccessError。这是”opt-in sharing”的设计思路——默认共享,但你可以选择不共享。这和 Rust 的所有权模型有某种精神上的相似,虽然实现方式完全不同。

可能不会合并的 60K 行

PR 说明里有一句话:“This PR exists so the design and the code can be read and argued with. It may never merge.”

这不是谦虚。60K 行侵入 JSC 的对象模型、全部四个 JIT tier、GC 和 VM 生命周期,意味着每次合并上游 WebKit 的变更都会是一场冲突海啸。维护成本是”反对合并的最强论据”——作者自己说的。

但即使不合并,这个 PR 已经做了几件有价值的事情:

  1. 证明了 Pizlo 2017 年的设计是可工程化的——Phase 2 跑了 93 个测试,通过了全部 JIT tier,TSAN 清零。
  2. 提供了一套可度量的基准(discriminating arms),能精确区分对象模型开销和线程机制开销,这对于未来任何想在 JSC 上做并行的尝试都有参考价值。
  3. 暴露了 JSC 内部上百个”默默假设只有一个线程”的细节——从栈限制到异常链到 OSR exit 缓冲区到每个 VM 单例的 scratch 空间。这份清单本身就是前面说的”JSC 架构教科书”。

对于 JavaScript 开发者来说,这个 PR 真正的意义不在”Bun 能不能跑多线程”。而是一个长期没被回答的工程问题——“如果 JavaScript 引擎真的支持共享堆多线程,代价是什么?“——有了一个迄今为止最认真的回答。

以上分析基于目前的公开信息和社区讨论。如果有不同视角或补充信息,欢迎交流。