JavaScript从诞生之初就背负着单线程的命运。这个设计决策在1995年看起来理所当然——浏览器只需要处理简单的表单验证和DOM操作。但三十年后,当Web应用需要在浏览器中运行视频编解码、物理模拟、甚至机器学习模型时,单线程的限制就成了一个无法回避的架构瓶颈。
Web平台花了十五年时间,才从"单线程加异步回调"的困境中突围。这条突围之路充满了曲折:Web Workers引入了真正的并行执行,postMessage却带来了序列化开销;SharedArrayBuffer解决了内存共享问题,却又在Spectre漏洞面前被迫退缩;COOP/COEP安全机制让功能得以恢复,但代价是配置复杂度的急剧上升。
理解这段技术演进,不仅是为了知道如何使用这些API,更是为了理解每一个设计决策背后的权衡——为什么有些问题看似简单却花了十年才解决,为什么某些优化反而带来了安全隐患,以及在没有完美方案的现实中,如何做出正确的架构选择。
Web Workers:第一个真正的突破口
2009年10月,W3C发布了Web Workers规范的第一个工作草案。这在当时是一个革命性的提案:允许JavaScript代码在独立的线程中运行,与主线程完全隔离。
Web Workers的设计哲学体现了Web平台一贯的安全优先原则。每个Worker都运行在独立的执行环境中,拥有自己的JavaScript引擎实例、自己的事件循环、自己的内存空间。主线程和Worker之间通过消息传递通信,而不是共享内存。这种Actor模型的设计从根本上避免了数据竞争——如果两个线程不能同时访问同一块内存,就不存在竞态条件。
// 主线程
const worker = new Worker('worker.js');
worker.postMessage({ type: 'compute', data: largeArray });
worker.onmessage = (e) => {
console.log('Result:', e.data);
};
// worker.js
self.onmessage = (e) => {
const result = heavyComputation(e.data.data);
self.postMessage(result);
};
这个设计看起来完美,但隐藏着一个致命的性能瓶颈。
postMessage的隐性代价
当主线程调用postMessage()时,数据并不是简单地"发送"到另一个线程。HTML规范定义了一个复杂的结构化克隆算法(Structured Clone Algorithm):
第一步,发送方调用StructuredSerialize(),将JavaScript对象序列化为一种内部格式。这个过程必须遍历整个对象图,处理循环引用、内置类型(Map、Set、ArrayBuffer等),并复制所有数据。发送方线程在这个期间是阻塞的。
第二步,接收方的事件队列中添加一个任务,执行StructuredDeserialize(),将序列化数据重建为JavaScript对象。这个过程同样需要遍历和分配内存。
Surma在2019年的基准测试中揭示了具体的数据:在一个深度为6、广度为6的嵌套对象上,低端设备的结构化克隆可能需要数十毫秒。更关键的是,传输时间与对象的JSON字符串表示长度近似成正比——这意味着100KB的数据在低端设备上可能需要接近100ms的传输时间,直接吃掉RAIL模型中为用户交互预留的整个预算。
这不是Web Workers的设计缺陷,而是架构权衡的必然结果。如果允许共享内存,就不需要复制数据,但会引入数据竞争。如果禁止共享内存,复制就不可避免。Web平台选择了安全,代价是性能。
对于小型消息,这个开销可以忽略不计。但对于状态管理、图像处理、大规模数据计算等场景,postMessage()本身就成了瓶颈。
SharedArrayBuffer:革命性的内存共享
2017年6月,SharedArrayBuffer作为ECMAScript 2017的一部分正式发布。这是Web平台第一次允许不同的JavaScript执行上下文共享同一块内存。
// 创建共享内存
const sab = new SharedArrayBuffer(1024 * 1024); // 1MB
const view = new Int32Array(sab);
// 传递给Worker(传递的是引用,不是复制)
const worker = new Worker('worker.js');
worker.postMessage({ buffer: sab });
// 主线程和Worker现在可以同时访问这块内存
view[0] = 42;
SharedArrayBuffer彻底改变了数据传输的成本模型。传递一个SharedArrayBuffer几乎是零成本的——只是传递一个引用,无论底层内存有多大。两个线程可以同时读写同一块内存,就像传统多线程编程一样。
但这里有一个问题:如果两个线程同时写入同一块内存,会发生什么?
Atomics:同步原语的基石
共享内存带来了数据竞争的风险。如果一个线程正在读取某个内存位置,另一个线程同时写入,读取的值可能是旧值、新值,或者是某种不确定的中间状态。
ECMAScript同时引入了Atomics对象,提供了一组原子操作:
const sab = new SharedArrayBuffer(4);
const view = new Int32Array(sab);
// 原子读取
const value = Atomics.load(view, 0);
// 原子写入
Atomics.store(view, 0, 42);
// 原子比较并交换(CAS操作)
const oldValue = Atomics.compareExchange(view, 0, 0, 1);
// 如果view[0] === 0,则设置为1,返回旧值
更重要的是,Atomics提供了wait和notify机制,这是实现互斥锁、条件变量等同步原语的基础:
// Worker 1:等待某个条件
Atomics.wait(int32Array, index, expectedValue, timeout);
// Worker 2:通知等待的线程
Atomics.notify(int32Array, index, count);
Atomics.wait会阻塞当前线程,直到另一个线程调用Atomics.notify唤醒它,或者超时。这是经典的管程(Monitor)模式在JavaScript中的实现。
然而,Atomics.wait有一个致命的限制:它不能在主线程上使用。主线程是浏览器UI的命脉,如果主线程阻塞,整个页面就会卡死。这意味着主线程无法使用传统的锁机制与Worker同步。
2020年,V8引擎在8.7版本中引入了Atomics.waitAsync,这是对主线程限制的回应:
// 可以在主线程上使用
const result = Atomics.waitAsync(int32Array, 0, 0, 1000);
if (result.async) {
result.value.then((status) => {
if (status === 'ok') {
// 被唤醒
}
});
}
waitAsync返回一个Promise而不是阻塞线程,这使得主线程能够参与共享内存的同步,同时保持响应性。
Spectre:安全风暴
就在SharedArrayBuffer开始被广泛采用时,2018年1月,Spectre漏洞被公开披露。
Spectre是一种侧信道攻击,利用CPU的推测执行特性来读取本应被保护的内存。攻击的核心原理是:
- CPU会推测执行某些分支指令,即使分支预测错误,推测执行产生的缓存副作用仍然存在
- 攻击者可以通过测量内存访问时间来判断某个地址是否被缓存
- 结合推测执行和时间测量,攻击者可以逐字节地读取受害者进程的内存
在浏览器环境中,Spectre攻击需要两个关键条件:高精度计时器和受害者数据进入攻击者的进程空间。
performance.now()是一个明显的计时器,浏览器迅速降低了它的精度。但SharedArrayBuffer提供了一个更隐蔽的计时器:一个简单的计数器循环,在独立线程中运行,可以提供纳秒级的时间精度。
// 使用SharedArrayBuffer作为高精度计时器
const sab = new SharedArrayBuffer(4);
const counter = new Int32Array(sab);
// Worker中持续递增计数器
setInterval(() => Atomics.add(counter, 0, 1), 0);
// 主线程可以通过读取计数器差值来测量时间
const start = Atomics.load(counter, 0);
// ... 执行某些操作 ...
const end = Atomics.load(counter, 0);
const elapsedNanoseconds = (end - start) * interval;
这种计时器精度远超浏览器对performance.now()的限制,而且难以在不破坏正常功能的前提下进行缓解。
2018年初,所有主流浏览器做出了一个前所未有的决定:全面禁用SharedArrayBuffer。
COOP/COEP:安全与功能的平衡
SharedArrayBuffer的禁用对WebAssembly生态造成了严重打击。FFmpeg.wasm、Google Earth等依赖多线程的应用被迫回退到单线程模式,性能大幅下降。
浏览器厂商开始寻找一种方法,既能保护普通用户免受Spectre攻击,又能让需要高级功能的网站获得这些能力。答案是一种选择性隔离机制:Cross-Origin Isolation(跨源隔离)。
跨源隔离通过两个HTTP头部来实现:
Cross-Origin-Opener-Policy (COOP):控制文档与其打开的窗口之间的关系。设置为same-origin时,文档会被放入一个独立的浏览上下文组,与跨源窗口完全隔离。
Cross-Origin-Embedder-Policy (COEP):控制文档可以加载哪些跨源资源。设置为require-corp时,所有跨源资源必须显式授权加载(通过CORS或CORP头部)。
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
当页面设置了这两个头部后,浏览器会将其放入一个隔离的进程空间。在这个隔离环境中,即使Spectre攻击成功,攻击者也只能读取同一进程中的数据——而同一进程中的数据本来就属于同一个源。
这种设计的美妙之处在于:只有明确选择隔离的网站才能使用SharedArrayBuffer,而这些网站同时也承担了隔离带来的兼容性成本。普通的网站不会受到任何影响,但也无法使用这些高级功能。
检查页面是否处于跨源隔离状态:
if (self.crossOriginIsolated) {
// 可以安全使用SharedArrayBuffer
const sab = new SharedArrayBuffer(1024);
} else {
// 需要配置COOP/COEP头部
}
Chrome的时间线反映了这一演进过程:
- Chrome 60(2017年7月):SharedArrayBuffer首次发布
- Chrome 68(2018年7月):Spectre后重新启用,但仅限桌面版且依赖Site Isolation
- Chrome 92(2021年5月):所有平台都需要COOP/COEP才能使用
WebAssembly线程:真正的并行计算
SharedArrayBuffer对WebAssembly的影响最为深远。WebAssembly从一开始就被定位为高性能计算的平台,而高性能计算离不开多线程。
WebAssembly线程不是独立的功能,而是几个组件的组合:
- Web Workers:作为底层线程的实现载体
- SharedArrayBuffer:作为共享内存的机制
- WebAssembly Atomics:作为同步原语
// 创建共享的WebAssembly内存
const memory = new WebAssembly.Memory({
initial: 256,
maximum: 256,
shared: true
});
// 在多个Worker中共享这块内存
const worker = new Worker('worker.js');
worker.postMessage({ memory });
对于C/C++开发者,Emscripten提供了pthread API的完整实现:
#include <pthread.h>
void* thread_func(void* arg) {
// 在Worker中执行
return NULL;
}
int main() {
pthread_t thread;
pthread_create(&thread, NULL, thread_func, NULL);
pthread_join(thread, NULL);
return 0;
}
编译时只需要添加-pthread标志:
emcc -pthread -s PTHREAD_POOL_SIZE=4 example.c -o example.js
对于Rust,Rayon库通过wasm-bindgen-rayon提供了数据并行支持:
use rayon::prelude;
fn sum_of_squares(data: &[i32]) -> i32 {
data.par_iter() // 并行迭代
.map(|x| x * x)
.sum()
}
WebAssembly线程的实际应用已经相当广泛。FFmpeg.wasm利用多线程将视频编码性能提升了3-4倍;Squoosh使用多线程加速图像压缩;Google Earth的Web版本依赖多线程进行3D渲染。
权衡与选择
理解Web平台多线程技术的演进,最终是为了在实践中做出正确的选择。
何时使用Web Workers:
- CPU密集型计算(图像处理、加密、压缩)
- 需要避免阻塞主线程的场景
- 数据量适中,postMessage开销可接受
何时使用SharedArrayBuffer:
- 高频数据交换场景
- 大数据量传输
- 需要真正的共享内存模型
- 可以接受COOP/COEP的配置成本
何时考虑WebAssembly线程:
- 需要将现有的C/C++/Rust多线程代码移植到Web
- 计算密集型任务需要充分利用多核CPU
- 性能要求极高,可以接受额外的复杂性
COOP/COEP的成本:
- 所有跨源资源需要CORS或CORP授权
- 某些第三方嵌入可能无法工作
- 开发环境需要额外配置
一个常见的模式是渐进式增强:
async function initApp() {
if (typeof SharedArrayBuffer !== 'undefined' && self.crossOriginIsolated) {
// 使用高性能多线程版本
const module = await import('./app-multithreaded.js');
await module.initThreadPool(navigator.hardwareConcurrency);
return module;
} else {
// 回退到单线程或普通Worker版本
return import('./app-singlethread.js');
}
}
未来展望
Web平台的多线程能力仍在演进中。Atomics.pause提案正在讨论中,旨在优化自旋锁的CPU缓存行为;WebAssembly的shared-everything-threads提案可能进一步扩展共享内存的能力范围。
更重要的是,Web平台正在形成一套完整的并发编程范式。从最初的回调地狱,到Promise和async/await,再到Worker和SharedArrayBuffer,开发者有了越来越多的工具来处理并发——每个工具都有其适用场景和权衡。
理解这些权衡,比记忆API更重要。没有银弹,只有在特定场景下的最优选择。十五年突围之路教会我们的是:Web平台的设计者们始终在安全与功能之间寻找平衡,而这个平衡点随着威胁模型的变化而不断移动。
参考资料
- W3C Web Workers规范 (2009): https://www.w3.org/TR/2009/WD-workers-20091029/
- MDN Web Workers API: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API
- web.dev: WebAssembly线程指南: https://web.dev/articles/webassembly-threads
- web.dev: COOP/COEP跨源隔离: https://web.dev/articles/coop-coep
- V8博客: Atomics.wait/waitAsync: https://v8.dev/features/atomics
- Surma: Is postMessage slow?: https://surma.dev/things/is-postmessage-slow/
- Chromium博客: SharedArrayBuffer时间线: https://blog.chromium.org/2021/05/adjusted-timeline-for-sharedarraybuffers.html
- Spectre论文: https://spectreattack.com/spectre.pdf
- MDN SharedArrayBuffer: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer
- Emscripten pthread支持: https://emscripten.org/docs/pthreads/index.html