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提供了waitnotify机制,这是实现互斥锁、条件变量等同步原语的基础:

// 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的推测执行特性来读取本应被保护的内存。攻击的核心原理是:

  1. CPU会推测执行某些分支指令,即使分支预测错误,推测执行产生的缓存副作用仍然存在
  2. 攻击者可以通过测量内存访问时间来判断某个地址是否被缓存
  3. 结合推测执行和时间测量,攻击者可以逐字节地读取受害者进程的内存

在浏览器环境中,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线程不是独立的功能,而是几个组件的组合:

  1. Web Workers:作为底层线程的实现载体
  2. SharedArrayBuffer:作为共享内存的机制
  3. 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平台的设计者们始终在安全与功能之间寻找平衡,而这个平衡点随着威胁模型的变化而不断移动。

参考资料