2009年,Web Workers作为HTML5规范的一部分首次引入浏览器。十五年后,尽管多核CPU已成标配,但大多数Web应用依然在单线程的泥潭中挣扎。问题不在于开发者不知道Web Workers的存在——而在于当他们真正尝试使用时,发现数据传输的开销可能比计算本身更令人头疼。
一个简单的场景:你的应用需要处理一个10MB的JSON数据集。你满怀期待地将计算逻辑移到Worker中,结果发现主线程被postMessage的序列化过程卡住的时间,比原来在主线程直接计算还要长。这不是笑话,这是每天都在发生的真实困境。
结构化克隆:看不见的复制机器
JavaScript天生不支持共享内存。当你在主线程创建一个对象,然后通过postMessage发送给Worker时,这个对象必须被完整复制。浏览器使用的复制机制叫做结构化克隆算法(Structured Clone Algorithm)。
这个算法比你想象的复杂得多。它不仅仅是简单的深拷贝,而是要处理循环引用、内置类型、以及Web平台特有的对象类型。
支持什么,不支持什么
根据HTML规范,结构化克隆支持以下JavaScript类型:
- 所有原始类型(除
Symbol外) Boolean、String、Number对象DateRegExp(但lastIndex属性不会被保留)Array、Object(仅限普通对象)Map、SetArrayBuffer、TypedArray、DataViewError及其子类型
不支持的关键类型:
Function——尝试传递会抛出DataCloneError- DOM节点——同样会抛出异常
Symbol- 原型链不会被复制
- 属性描述符、getter/setter会丢失
- 类的私有字段不会被复制
这意味着当你传递一个带有方法的类实例时,方法会消失,原型链会断裂,对象会变成一个"裸"的数据容器。
为什么这么慢
Surma在他的深度分析中提供了关键的基准测试数据。他测试了不同大小和复杂度的对象在Worker间传输的性能:
数据揭示了一个清晰的规律:传输时间与对象的JSON字符串化大小成正比。但这只是故事的一半。
更关键的是,结构化克隆是一个两步过程:
- 序列化(
StructuredSerialize):在发送端执行,遍历整个对象树,将每个属性转换为可传输的格式 - 反序列化(
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序列化的优势已不再明显。但对于某些特定场景,这仍然是一个值得考虑的优化策略。
可转移对象的陷阱
转移听起来完美,但有几个实际问题:
- 单一所有权:一旦转移,发送方就失去了数据。如果你需要保留副本,必须提前复制。
- 类型限制:只有
ArrayBuffer、MessagePort和少数几种类型可以转移。 - 数据结构转换:如果你的数据不是二进制格式,需要先转换。这个转换本身可能抵消零拷贝的收益。
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.wait和Atomics.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
}
}
这种方法的传输时间是常数级的——与状态大小无关。但它也有明显限制:结构体不能包含指针(如Vec或String),否则在新的内存上下文中会变得无效。
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的通信开销会成为瓶颈?
你需要担心的场景
- 高频小消息:每秒数百次传递小对象,序列化的累积开销可能超过计算本身
- 大对象传输:超过100KB的对象在低端设备上可能突破响应预算
- JS驱动动画:如果你依赖
requestAnimationFrame做动画,每帧只有16ms,任何超过5-10KB的传输都可能导致丢帧 - 复杂数据结构:深层嵌套的对象比扁平结构需要更多的序列化时间
你可以放心的场景
- 一次性大数据处理:处理一个10MB的CSV文件,初始传输的延迟相比整体计算时间可以忽略
- 低频更新:用户操作触发的状态更新,100KB的预算绰绰有余
- CSS/合成器动画:如果动画跑在合成线程,主线程被阻塞也不影响流畅度
- 纯计算任务:如果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时,记住:目标是降低风险,而不是追求极致的并行效率。一个在低端设备上流畅响应的"慢"方案,远比一个在高端设备上偶尔卡顿的"快"方案更有价值。
参考资料
- Structured Clone Algorithm — MDN Web Docs
- Is postMessage slow? — surma.dev
- High-performance Web Worker messages — Nolan Lawson
- Making your website “cross-origin isolated” using COOP and COEP — web.dev
- Atomics.wait, Atomics.notify, Atomics.waitAsync — V8.dev
- Transferable objects — MDN Web Docs
- Web Workers — Chromium Projects
- Using Web Workers — MDN Web Docs
- Meltdown/Spectre — Chrome for Developers
- Deep-copying in JavaScript using structuredClone — web.dev
- Optimizing Performance with Web Workers — Salesforce Engineering
- Use web workers to run JavaScript off the browser’s main thread — web.dev