2009年,Web Workers作为HTML5规范的一部分首次引入浏览器。十五年后,尽管多核CPU已成标配,但大多数Web应用依然在单线程的泥潭中挣扎。问题不在于开发者不知道Web Workers的存在——而在于当他们真正尝试使用时,发现数据传输的开销可能比计算本身更令人头疼。

一个简单的场景:你的应用需要处理一个10MB的JSON数据集。你满怀期待地将计算逻辑移到Worker中,结果发现主线程被postMessage的序列化过程卡住的时间,比原来在主线程直接计算还要长。这不是笑话,这是每天都在发生的真实困境。

结构化克隆:看不见的复制机器

JavaScript天生不支持共享内存。当你在主线程创建一个对象,然后通过postMessage发送给Worker时,这个对象必须被完整复制。浏览器使用的复制机制叫做结构化克隆算法(Structured Clone Algorithm)。

这个算法比你想象的复杂得多。它不仅仅是简单的深拷贝,而是要处理循环引用、内置类型、以及Web平台特有的对象类型。

支持什么,不支持什么

根据HTML规范,结构化克隆支持以下JavaScript类型:

  • 所有原始类型(除Symbol外)
  • BooleanStringNumber对象
  • Date
  • RegExp(但lastIndex属性不会被保留)
  • ArrayObject(仅限普通对象)
  • MapSet
  • ArrayBufferTypedArrayDataView
  • Error及其子类型

不支持的关键类型

  • Function——尝试传递会抛出DataCloneError
  • DOM节点——同样会抛出异常
  • Symbol
  • 原型链不会被复制
  • 属性描述符、getter/setter会丢失
  • 类的私有字段不会被复制

这意味着当你传递一个带有方法的类实例时,方法会消失,原型链会断裂,对象会变成一个"裸"的数据容器。

为什么这么慢

Surma在他的深度分析中提供了关键的基准测试数据。他测试了不同大小和复杂度的对象在Worker间传输的性能:

PostMessage性能与负载大小的相关性

图片来源: Is postMessage slow? — surma.dev

数据揭示了一个清晰的规律:传输时间与对象的JSON字符串化大小成正比。但这只是故事的一半。

更关键的是,结构化克隆是一个两步过程:

  1. 序列化StructuredSerialize):在发送端执行,遍历整个对象树,将每个属性转换为可传输的格式
  2. 反序列化StructuredDeserialize):在接收端执行,重建整个对象结构

Chrome和Safari做了一个聪明的优化:延迟反序列化。它们不会在消息到达时立即反序列化,而是等到你访问event.data属性时才执行。Firefox则选择立即反序列化。这两种行为都符合规范,但意味着在不同浏览器中,你感知到的性能瓶颈出现在不同地方。

核心结论:即使在最慢的设备上,你可以安全地传输100KB以内的对象,并保持在100ms的响应预算内。如果你的应用有JS驱动的动画(16ms帧预算),那么超过10KB的传输就可能成为问题。

可转移对象:零拷贝的诱惑

当你传递ArrayBuffer时,有一个选项可以完全避免复制——把它转移(transfer)给接收方。

const buffer = new ArrayBuffer(1024 * 1024); // 1MB
worker.postMessage({ data: buffer }, [buffer]);

// 此后,buffer在当前上下文中变为"neutered"(无效)
console.log(buffer.byteLength); // 0

转移的代价是发送方失去了对数据的访问权。这听起来很极端,但对于大数据处理场景,这是唯一能保持性能的方法。

Nolan Lawson在2016年的经典测试中对比了不同传输方式的性能,发现在当时的Chrome版本中,使用JSON.stringify手动序列化后再传输,反而比直接传递对象更快。这是因为早期Chrome的结构化克隆实现存在性能问题。

更新:现代Chrome已经优化了结构化克隆,手动JSON序列化的优势已不再明显。但对于某些特定场景,这仍然是一个值得考虑的优化策略。

可转移对象的陷阱

转移听起来完美,但有几个实际问题:

  1. 单一所有权:一旦转移,发送方就失去了数据。如果你需要保留副本,必须提前复制。
  2. 类型限制:只有ArrayBufferMessagePort和少数几种类型可以转移。
  3. 数据结构转换:如果你的数据不是二进制格式,需要先转换。这个转换本身可能抵消零拷贝的收益。

David Griebel在生产环境中发现,将普通对象转换为ArrayBuffer再转移,对于Breeze.js的查询结果来说,总耗时约8ms序列化 + 8-14ms传输,而结构化克隆需要150ms以上。

SharedArrayBuffer:真正的共享内存

如果可转移对象是"搬家",那么SharedArrayBuffer就是"合租"。

SharedArrayBuffer允许多个线程同时访问同一块内存。理论上,你只需要传输一次,之后所有线程都可以直接读写。

const sharedBuffer = new SharedArrayBuffer(1024);
const sharedArray = new Int32Array(sharedBuffer);

// 主线程写入
sharedArray[0] = 42;

// Worker可以立即读取
worker.postMessage({ buffer: sharedBuffer });

Spectre的阴影

2018年1月,Spectre漏洞被披露。这是一种利用CPU推测执行的侧信道攻击。SharedArrayBuffer因为可以用于高精度计时,成为攻击的潜在工具。所有主流浏览器在几周内禁用了这个API。

直到2020年,浏览器才通过跨源隔离机制重新启用SharedArrayBuffer。要使用它,你的网站必须设置以下HTTP响应头:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

这两个头的含义是:

  • COOP: same-origin:将你的页面放在一个独立的浏览上下文组中,防止跨源窗口直接访问你的window对象
  • COEP: require-corp:要求所有加载的资源(包括iframe中的)都明确声明可以被跨源嵌入

你可以在JavaScript中检查隔离状态:

if (self.crossOriginIsolated) {
  // 可以使用SharedArrayBuffer
  const buffer = new SharedArrayBuffer(1024);
}

Atomics:同步的代价

共享内存带来一个经典问题:竞态条件。如果主线程正在写入一个值,Worker同时读取,会发生什么?

AtomicsAPI提供了解决方案:

const sharedBuffer = new SharedArrayBuffer(4);
const counter = new Int32Array(sharedBuffer);

// 原子递增
Atomics.add(counter, 0, 1);

// 原子读取
const value = Atomics.load(counter, 0);

更强大的同步原语是Atomics.waitAtomics.notify

// Worker中等待
const result = Atomics.wait(int32Array, index, expectedValue, timeout);
// 返回 'ok'(被唤醒)、'not-equal'(值不匹配)或 'timed-out'

// 主线程中唤醒
Atomics.notify(int32Array, index, count);

关键限制Atomics.wait是阻塞操作,不能在主线程使用(会抛出TypeError)。这是有道理的——阻塞主线程会导致整个页面冻结。

Chrome 87引入了Atomics.waitAsync,这是一个非阻塞版本,返回一个Promise:

const result = Atomics.waitAsync(int32Array, 0, 0);
if (result.async) {
  result.value.then(value => {
    // value 是 'ok' 或 'timed-out'
  });
}

V8团队提供了使用这些原语实现互斥锁的完整示例:

class AsyncLock {
  static INDEX = 0;
  static UNLOCKED = 0;
  static LOCKED = 1;

  constructor(sab) {
    this.sab = sab;
    this.i32a = new Int32Array(sab);
  }

  lock() {
    while (true) {
      const oldValue = Atomics.compareExchange(
        this.i32a, AsyncLock.INDEX,
        AsyncLock.UNLOCKED,
        AsyncLock.LOCKED
      );
      if (oldValue === AsyncLock.UNLOCKED) return;
      Atomics.wait(this.i32a, AsyncLock.INDEX, AsyncLock.LOCKED);
    }
  }

  unlock() {
    Atomics.compareExchange(
      this.i32a, AsyncLock.INDEX,
      AsyncLock.LOCKED,
      AsyncLock.UNLOCKED
    );
    Atomics.notify(this.i32a, AsyncLock.INDEX, 1);
  }

  async executeLocked(callback) {
    // 非阻塞版本,可在主线程使用
    while (true) {
      const oldValue = Atomics.compareExchange(
        this.i32a, AsyncLock.INDEX,
        AsyncLock.UNLOCKED,
        AsyncLock.LOCKED
      );
      if (oldValue === AsyncLock.UNLOCKED) {
        callback();
        this.unlock();
        return;
      }
      await Atomics.waitAsync(
        this.i32a, AsyncLock.INDEX,
        AsyncLock.LOCKED
      ).value;
    }
  }
}

补丁传输:只发送变化

PROXX——Google Chrome团队开发的一个扫雷游戏PWA——提供了一个优雅的解决方案。

游戏的棋盘状态是一个40×40的二维数组,每个格子包含多个属性。完整状态的JSON表示约为134KB。当用户点击一个格子时,可能需要更新80%的格子——这会生成约70KB的补丁。

他们的解决方案是补丁传输:不发送完整状态,只发送变化。

使用Immer库可以轻松实现:

// worker.js
import { produceWithPatches } from 'immer';

const [nextState, patches] = produceWithPatches(state, draft => {
  // 修改 draft
  draft.cells[x][y].revealed = true;
});

postMessage({ patches });

// main.js
import { applyPatches } from 'immer';

worker.onmessage = ({ data }) => {
  state = applyPatches(state, data.patches);
};

补丁的格式非常紧凑:

[
  { "op": "replace", "path": ["cells", 10, 15, "revealed"], "value": true },
  { "op": "add", "path": ["cells", 10, 16, "adjacentMines"], "value": 3 }
]

分块传输:把大象装进冰箱

当补丁本身也太大时,PROXX使用了分块传输。Worker迭代棋盘时,每当收集的更新超过阈值,就发送一个批次:

const patches = [];
const CHUNK_SIZE = 1000;

for (const cell of cellsToUpdate) {
  patches.push({ op: 'replace', path: cell.path, value: cell.value });
  
  if (patches.length >= CHUNK_SIZE) {
    postMessage({ patches: patches.splice(0) });
    // 给主线程喘息的机会
  }
}

// 发送剩余的补丁
if (patches.length > 0) {
  postMessage({ patches });
}

这样做的好处是:即使Worker处理需要很长时间,主线程也能在每批消息之间响应用户交互。在功能手机上,这创造了一种"渐进式揭示"的动画效果——不是故意设计的,而是性能约束带来的意外之美。

二进制格式:彻底告别JSON

当性能成为瓶颈时,二进制格式是终极武器。

FlatBuffers:零解析的魔力

FlatBuffers是Google开发的一种序列化格式,它的独特之处在于:不需要解析

传统格式(JSON、Protocol Buffers)需要先解析整个数据结构,才能访问其中的字段。FlatBuffers的数据布局设计成可以直接在内存中访问:

// 定义schema
table Monster {
  name: string;
  hp: int16;
  mana: int16;
}

// 直接访问,无需解析
const monster = Monster.getRootAsMonster(buffer);
const name = monster.name(); // 直接从buffer中读取

当你用ArrayBuffer传输FlatBuffers数据时,接收方可以立即开始工作——没有序列化/反序列化的开销。

WebAssembly:以内存布局为协议

更激进的方案是完全跳过序列化。如果你使用WebAssembly,可以直接利用语言的内存布局:

#[repr(C)]
pub struct State {
    counters: [u8; 100],
}

#[wasm_bindgen]
impl State {
    pub fn serialize(&self) -> Vec<u8> {
        let size = std::mem::size_of::<State>();
        let mut r = Vec::with_capacity(size);
        unsafe {
            std::ptr::copy_nonoverlapping(
                self as *const State as *const u8,
                r.as_mut_ptr(),
                size
            );
        }
        r
    }
}

这种方法的传输时间是常数级的——与状态大小无关。但它也有明显限制:结构体不能包含指针(如VecString),否则在新的内存上下文中会变得无效。

MessageChannel:更灵活的通信拓扑

除了Worker的默认消息端口,Web平台还提供了MessageChannelAPI,允许你创建任意拓扑的通信管道:

const channel = new MessageChannel();

// 将一个端口发送给Worker
worker.postMessage({ port: channel.port2 }, [channel.port2]);

// 主线程保留另一个端口
channel.port1.onmessage = (event) => {
  console.log('Received:', event.data);
};

// Worker中
self.onmessage = (event) => {
  if (event.data.port) {
    const port = event.data.port;
    port.postMessage('Hello from worker!');
  }
};

这对于多Worker协作场景特别有用。你可以建立Worker之间的直接通信通道,而不需要主线程作为中转。

Shared Worker:跨标签页的协调

当你需要多个浏览器标签页共享状态时,SharedWorker是答案。它在所有连接的上下文之间共享同一个Worker实例:

// 在多个标签页中创建,但只有一个Worker实例
const worker = new SharedWorker('./shared-worker.js');

worker.port.onmessage = (event) => {
  // 接收来自其他标签页的消息
};

worker.port.postMessage({ type: 'update', data: newState });

Shared Worker的通信必须通过port进行,而不是直接的postMessage。每个连接都会创建一个新的MessagePort,Worker内部需要管理这些端口的集合。

实践中的权衡

让我们回到最初的问题:什么时候Web Workers的通信开销会成为瓶颈?

你需要担心的场景

  1. 高频小消息:每秒数百次传递小对象,序列化的累积开销可能超过计算本身
  2. 大对象传输:超过100KB的对象在低端设备上可能突破响应预算
  3. JS驱动动画:如果你依赖requestAnimationFrame做动画,每帧只有16ms,任何超过5-10KB的传输都可能导致丢帧
  4. 复杂数据结构:深层嵌套的对象比扁平结构需要更多的序列化时间

你可以放心的场景

  1. 一次性大数据处理:处理一个10MB的CSV文件,初始传输的延迟相比整体计算时间可以忽略
  2. 低频更新:用户操作触发的状态更新,100KB的预算绰绰有余
  3. CSS/合成器动画:如果动画跑在合成线程,主线程被阻塞也不影响流畅度
  4. 纯计算任务:如果Worker不做任何DOM操作,纯数学计算的收益远大于通信开销

决策框架

数据大小 < 10KB?
  └─ 是 → 直接 postMessage,无需优化
  └─ 否 → 需要动画流畅?
           └─ 是 → 考虑补丁传输或分块
           └─ 否 → 数据大小 < 100KB?
                    └─ 是 → 可以直接传输
                    └─ 否 → 考虑二进制格式或SharedArrayBuffer

浏览器差异:你不知道的雷区

不同浏览器对Web Workers的实现细节存在显著差异:

反序列化时机

  • Chrome/Safari:延迟反序列化,直到访问event.data
  • Firefox:立即反序列化

这导致一个微妙的问题:在Chrome中,你可以在message事件处理函数外部读取event.data,从而控制何时承担反序列化的开销。在Firefox中,这个选择被剥夺了。

时间戳精度

Spectre漏洞后,浏览器降低了performance.now()的精度:

浏览器 精度限制
Chrome (桌面) 5μs
Chrome (Android) 100μs
Firefox 1ms
Safari 1ms

这影响你的性能测量代码。高端桌面设备上的5微秒精度对于动画帧时间测量是足够的,但在移动端100微秒的精度下,你的测量可能充满噪音。

SharedArrayBuffer支持

浏览器 支持状态
Chrome 92+ 需要跨源隔离
Firefox 79+ 需要跨源隔离
Safari 15.2+ 需要跨源隔离

Atomics.waitAsync的支持更有限:

浏览器 支持版本
Chrome 87+
Firefox 不支持
Safari 不支持

现实案例:生产环境的经验

Salesforce的实验

Salesforce工程团队在2017年进行了详细的Web Worker性能测试。他们发现:

  • 对于大型数据集(10万+记录),将排序和过滤移到Worker可以显著提升响应性
  • 但初始化Worker和传输数据的开销,在数据量较小时会抵消并行计算的收益
  • 最佳策略是动态决策:根据数据大小决定是否使用Worker

PROXX的启示

Google Chrome团队的PROXX游戏证明了一个重要观点:降低性能风险比提升绝对性能更有价值

在没有使用Worker的版本中,低端设备上UI会完全冻结6秒。使用Worker后,处理时间增加到12秒,但用户能看到渐进式的更新——体验更好,虽然实际更慢。

这揭示了一个被忽视的真相:用户对"感觉快"的偏好胜过"实际快"。流畅的渐进更新比瞬间的卡顿更可接受。

未来展望

Web Workers的通信成本会永远存在吗?

WebAssembly线程

WebAssembly的线程支持(基于SharedArrayBuffer)提供了一条不同的路径。你可以在WASM模块内部使用传统的线程同步原语,而不用通过postMessage

use std::sync::Mutex;
use std::sync::Arc;

let data = Arc::new(Mutex::new(vec![1, 2, 3]));

// 多个线程可以安全地访问 data

WebAssembly模块的线性内存天然可以被共享。这意味着状态管理的复杂性被封装在WASM内部,JavaScript层只需传递指针。

OffscreenCanvas

OffscreenCanvasAPI允许你在Worker中直接进行Canvas渲染,然后将结果传输到主线程显示。这消除了"计算在Worker,渲染在主线程"的来回切换:

// main.js
const canvas = document.getElementById('canvas');
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);

// worker.js
self.onmessage = (event) => {
  const canvas = event.data.canvas;
  const ctx = canvas.getContext('2d');
  // 直接在Worker中渲染
  ctx.fillRect(0, 0, 100, 100);
};

这减少了主线程的渲染负担,同时避免了频繁的图像数据传输。

结论

Web Workers的通信开销是真实的,但不是致命的。关键在于理解权衡:

  • 结构化克隆是默认且足够好的选择,对于大多数应用场景
  • 可转移对象适合二进制数据,但会改变所有权模型
  • SharedArrayBuffer提供真正的共享内存,但需要安全头配置和同步原语
  • 补丁传输分块传输是应用层优化,不需要特殊API支持
  • 二进制格式WebAssembly是终极武器,但增加复杂性

真正的问题不是"Web Workers慢不慢",而是"你的架构是否正确评估了通信成本"。在单线程的世界里,每一次数据移动都有代价——Web Workers只是把这个代价显性化了。

当你下次考虑引入Worker时,记住:目标是降低风险,而不是追求极致的并行效率。一个在低端设备上流畅响应的"慢"方案,远比一个在高端设备上偶尔卡顿的"快"方案更有价值。


参考资料