151 个 commit,279276 行新增,4272 行删除——Jarred-Sumner 在两周前向 oven-sh/WebKit 提交了 PR #249。标题里带一个坦诚的括号:“experimental, not working yet”。但真正让这个 PR 在 HN 上拿到 137 分、289 条评论的原因,是它做的事情——在 JavaScriptCore 上实现共享内存的真·多线程。
不是 Worker + postMessage,不是在 SharedArrayBuffer 上手工搭一个内存分配器。new Thread(fn) 把函数扔到另一个核上运行,同一个堆,同一个对象。没有 structured clone,没有消息协议——“你共享一个对象的方式就是共享那个对象”。
这个 PR 在 JavaScript 运行时生态里撕开了一道裂缝。一边的人说 JS 等了二十年终于等到了真正的并行。另一边的人说这是给一门以”不用操心线程”为核心价值的语言塞进了最危险的武器。笔者仔细读完了 PR 说明、Pizlo 2017 年的原始设计、以及 HN 上近三百条评论,尝试把两边的论证放到同一张桌子上。
从 Worker 到 Thread:一个 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。heavy 不能再引用外部作用域里的任何东西——它现在是一段字符串。
而这个 PR 给出的版本只有一行:
const result = new Thread(heavy, input).join();
heavy 是真实的闭包,能看见 import、类、周边变量。抛出异常时 join() 重新抛出同一个异常对象,带着真实的调用栈。差别不只是语法糖。PR 里给出了一个并行 map 的例子——11 行代码、原子计数器直接操作普通对象属性、结果写进共享数组、零拷贝;而 Worker 版本”是一个项目”。
Worker 给 JavaScript 提供了一个多进程模型,SharedArrayBuffer 在上面开了个字节数组的洞。Thread 想做的是多线程模型——让 Map 是真正的共享 Map,让条件变量握手发生在 JS 对象之间而不是整数的 Atomics.wait/notify 上。
信使与消息:Pizlo 2017 年的设计终于等到实现者
这个 PR 不是凭空冒出来的。它的设计基础来自 Filip Pizlo 在 2017 年发表的 WebKit 博客文章 Concurrent JavaScript: It Can Work!。Pizlo 当时是 JSC 核心工程师,那篇文章提出了一个具体的实现方案,但八年来从未被工程化。
设计核心可以用一句话概括:不让不共享的对象为共享买单。实现的关键机制有三层:
TID 标记的 Butterfly。 JSC 用 butterfly 结构存储对象属性和数组元素——一个紧凑的”胖指针”布局。PR 在 butterfly 指针的空闲位里塞入线程 ID。对象创建后,只有创建线程能零成本访问;另一个线程首次写入时,对象才”转换”为共享状态。这给了 JIT 一个关键的推测机会——编译时假设对象是线程局部的,运行时发现共享再重新编译。
分段 Butterfly(Segmented Butterfly)。 对象变成共享后,属性存储从连续内存切换成不可变脊骨(immutable spine)加片段(fragment)的架构。扩容时追加新片段而非 realloc + copy,让并发 resize 安全——读操作全程不用加锁。
每对象两比特的 cell lock。 用于元数据操作的互斥,删除的槽位被隔离直到 GC 安全点,防止并发读看到重复利用的内存。
HN 上有评论精准地点出了 PR 与原始设计的关系:“This is an implementation of the design Filip Pizlo published in 2017.” Pizlo 本人也出现在讨论中,确认了这一点,并坦言”甚至一个 LLM 能做这种事让我印象深刻”。
技术的诚实:性能数据及其解读
PR 附带了一份罕见的诚实基准。作者用 JavaScript(这个分支)、Go、Java 分别实现了一个文档索引和查询引擎,跑 1–32 线程,输出 checksum 验证正确性。
扁平版本(用 Int32Array 替代字符串/Map/BigInt,但线程、锁、GC 完全一致):
| 线程数 | JS | Java | Go | Go(GOGC=off) |
|---|---|---|---|---|
| 1 | 3759ms | 1974ms | 1836ms | — |
| 16 | 872ms | 976ms | 422ms | 354ms |
| 32 | 870ms | 1022ms | 378ms | — |
16 线程时 JS 是 Java 的 0.89×,离 Go 的无 GC 极限 2.46× 有差距。但 JS 跨过了负扩展的陷阱——从 8 到 32 线程保持住了收益。这在第一版实现中不容易。
而”规格精确版”(真实字符串、Map<string>、任意精度 BigInt)显示了现实的残酷——16 线程下 JS 比 Java 慢约 13×。PR 用区分臂(discriminating arms)拆解了这个差距:约 40% 来自堆分配的 BigInt(缺乏原生 u64 路径),约 50% 来自字符串 + Map 查询(Java 的 String/HashMap 更快),只有约 10% 来自线程机制本身。
从社区讨论来看,这个数据的解读方式本身就是分歧的缩影。乐观者会指出”四周前第一个测量版本在所有线程数上都是负扩展”——进步是三个数量级的。悲观者会反问:当核心数据结构(字符串、Map、BigInt)还没调优就比竞品慢一个数量级时,说”线程机制开销不大”有什么实际意义?
阵营 A:JS 需要真正的多线程
支持方的论据可以梳理成几条主线。
Worker 是妥协,不是答案。 在 Worker 模型下做并行计算,开发者面临两个选择:要么接受 postMessage 的序列化开销和协议复杂度,要么在 SharedArrayBuffer 上手工重建整个 JavaScript 运行时——字符串要自己编码到字节数组,对象要自己设计序列化格式,Map/Set 要从零实现。HN 上有人把这种处境概括得很准:“SharedArrayBuffer 是一个不错的原语,但语言中没有任何东西用它。想让现有 JS 对象代码在 SharedArrayBuffer 上多线程化,不如直接移植到 Go——那可能比重新实现 JS 对象模型更省力。”
新品类应用的可能性。 有评论者指出,SQL 数据库无法用 TypeScript 写的主要原因就是缺真正的多线程。共享堆线程 + 快速原子/锁原语,理论上可以让 TS 写一个性能有竞争力的多线程数据库。这在 Worker 模型下是不可能的——PostgreSQL 用多进程模型,因为它的架构不需要共享大量可变对象图。
模块图共享是质的区别。 Worker 每启动一个就重新 fetch、parse、compile、execute 一遍完整的依赖树。所有模块级副作用——注册表、schema 初始化、连接建立——在每个 Worker 里重复执行一次,这本身就是一类 bug 的来源。而 Thread 共享已执行的模块图,第 8 个线程的成本只是创建一个线程的成本(约 150KB–1MB 忙碌态、30–50KB 休眠态),不是另一份完整的应用启动开销。
设计路径已经过审慎验证。 Pizlo 2017 年的设计目标包括”不使用并发的代码零性能回退""线性扩展""兼容现有 DOM 模型”——这些是经过了 WebKit 社区级别审视的目标,不是拍脑袋列出来的。PR 的两阶段 bring-up 策略(Phase 1 全局锁验证、Phase 2 拆锁并行)也为正确性提供了工程锚点。
从公开信息判断,这条路线的核心论证是:Worker + SharedArrayBuffer 的现状已经让足够多的人感到痛苦,值得探索一个更激进的方案。
阵营 B:你正在给一门不需要线程的语言塞进最危险的武器
反对方的论证同样有力,而且分几个层次。
共享内存并发是步兵地雷,JS 开发者群体缺乏处理它的训练。 这是 HN 上被反复提起的观点。一条高赞评论引用了 Douglas Crockford 在《Coders at Work》中的原话:“我经历过的最糟糕的 bug 都是实时 bug,与多线程交互有关。我的应对方法是避免制造它们。我不喜欢线程,我认为线程是一种糟糕的编程模型。“即便有人反驳这是”对 Web 开发者的蔑视”,但工程事实是——即便是 C++/Rust 等系统编程语言的资深开发者,线程安全 bug 仍然是生产环境中最难定位、最难复现的故障类别。把锁、条件变量、内存序引入 JavaScript,等于把这类 bug 交付给一个从未被要求理解这些概念的开发者群体。
GC 的代价尚不可接受。 PR 明确说明:v1 使用同步 stop-the-world GC。在 HN 上,有 VM 背景的评论者立即抓住了这个点:“我就知道!并行 GC 是一个难度极高的问题。要实现 ZGC 那样的并发标记,需要多年的经验和精妙的实现。一个 stop-the-world GC 会在很多使用场景中杀死尾部延迟。“PR 作者回应称并发标记在多线程活跃时被暂时牺牲了——这是一个诚实的承认,但也意味着”真正的生产可用”比”测试通过”远得多。
Worker + SharedArrayBuffer 已经够用。 这个阵营的核心论证:JavaScript 的并发模型已经存在——共享内存(SharedArrayBuffer)、原子操作(Atomics)、消息传递(Worker/postMessage)——而且有人在生产中使用。有人贴出了在 SharedArrayBuffer 上实现无锁分配器的文章作为例证。“你可以做到。不方便不等于做不到。“——这种观点认为,把不便升级成”方便但危险”不是进步,是退步。
维护成本的数学不可能。 279K 行改动穿透了 JSC 的对象模型、全部四个 JIT tier、GC 和 VM 生命周期。每次合并上游 WebKit 的变更都会是一场冲突海啸。作者自己承认——“反对合并的最强论据是维护成本”。这是整个讨论中最务实的反对:即使代码质量完美,持续追上游的代价也可能超出 Bun 团队的承受能力。
从社区讨论判断,这个阵营并不否认 PR 的技术质量,而是质疑工程判断——在一个稳定性还没站稳的运行时上,用 AI 生成的方式,推进一个十年未有人成功工程化的改动,风险收益比是否成立。
信任危机的阴影
讨论中有一个无法回避的维度:这个 PR 发生在 Bun 从 Zig 迁移到 Rust 的重写风波之后。一个 1800 文件、过百万行 diff 的 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 评论获得了大量认同。
另一个声音来自 Bun 的实际用户:use-after-free bug、0 代替正确返回值的未实现功能、流式响应导致 OOM 的回压问题。“I’d prefer Bun to work on stability rather than fancy features like image processing or threads.”
也有不少人站出来为 Bun 辩护——TypeScript 开箱即用、ESM/CJS 互操作、内置测试工具、极快的启动速度,这些开发体验优势是真实的。“Faster I get. But easier to use? There is no comparison when running a TypeScript project with Bun vs with Node.”
从公开信息来看,这个维度的争论本质上是关于”谁在写代码、用什么样的过程写代码”的信任问题。而这种信任问题在基础设施软件上被放大了一个数量级。Node.js 的治理结构用了十多年才建立,Deno 也花了几年从”玩具”走到可用。Bun 才四年,叠加语言迁移和 AI 生成代码的双重不确定性,社区的警惕并不是非理性的。
引擎格局中的位置
把这个 PR 放到三大 JS 引擎的图景里看,路线分歧会更清晰。
V8 的选择是 Worker + SharedArrayBuffer——多进程模型加一个字节数组逃生舱。这个选择保守但务实:V8 需要服务 Chrome 浏览器,而浏览器的安全模型天然排斥共享堆——两个标签页不能共享 JS 对象是安全基线的组成部分。Bun 作为服务端运行时没有这个约束,可以探索浏览器不能走的方向。
SpiderMonkey 在 Firefox 中引入了 arena 分配的 structured clone 优化路径,但也止步于 SharedArrayBuffer。三大引擎里,JSC 是唯一一个有过共享堆线程 formal proposal 的——Pizlo 2017 年的设计一直在等一个实现者。
这个 PR 就是那个实现者。它基本遵循了 Pizlo 的设计,但做了自己的工程取舍:多线程活跃时暂时牺牲并发标记、Promise 回调在 resolve 线程上跑而非注册线程上跑(registrant-affinity 模式被推迟了)、引入了 Thread.restrict(obj) 将对象钉死在特定线程——既提供了”opt-out sharing”的渐进路径,也在精神上与 Rust 的所有权模型有某种遥远的呼应。
可能不会合并的 279K 行
PR 说明的最后有一句话:“This PR exists so the design and the code can be read and argued with. It may never merge.”
这不是谦虚。但即使不合并,这个 PR 已经完成了几件有价值的事情。它证明了 Pizlo 2017 年的设计是可工程化的——Phase 2 跑了测试,通过了全部 JIT tier,TSAN 清零。它提供了一套可度量的基准,能精确区分对象模型开销和线程机制开销。它暴露了 JSC 内部上百个”默默假设只有一个线程”的细节,这份清单本身就是给未来任何想在 JSC 上做并行的人留的路标。
对于 JavaScript 开发者来说,这个 PR 真正回答的问题不是”Bun 能不能跑多线程”。而是一个长期悬置的工程命题——“如果 JavaScript 引擎真的支持共享堆多线程,代价是什么?“——终于有了迄今为止最认真的回答。
那个回答是:代价不小,但不是不可能。剩下的问题是”值不值得”——而这个问题的答案,在 289 条评论里,正反双方都没有说服对方。
以上分析基于目前的公开信息和社区讨论。如果有不同视角或补充信息,欢迎交流。