Appearance
Web Worker 多线程机制
结论先行
Web Worker 不是"让 JS 变成多线程语言"。它是在独立线程中运行一份隔离的 JS 执行上下文。主线程和 Worker 之间没有共享内存(SharedArrayBuffer 例外),通信完全依赖消息传递。
Worker 的基线性能代价是消息序列化/反序列化。如果通信开销大于计算收益,Worker 不会让代码变快,反而会因为序列化成本变得更慢。
Worker 的三种类型
DedicatedWorker(专用 Worker)
最常用的类型。一个 new Worker() 创建一个独立的 Worker 实例,仅创建它的脚本可以与之通信。
js
// 主线程
const worker = new Worker('./worker.js')
worker.postMessage({ type: 'compute', data: largeArray })
worker.onmessage = (e) => {
console.log('result:', e.data)
}
worker.onerror = (e) => {
console.error('worker error:', e.message, 'at line', e.lineno)
}
// worker.js
self.onmessage = (e) => {
const result = heavyCompute(e.data)
self.postMessage(result)
}通信是单向通道:worker.postMessage() 发往 worker,self.postMessage() 发回主线程。不是双向绑定。
SharedWorker
多个页面/iframe/同一域下的其他 worker 共享同一个 SharedWorker 实例。通信通过 port 对象:
js
// 主线程
const worker = new SharedWorker('./shared-worker.js')
worker.port.postMessage(data)
worker.port.onmessage = (e) => { /* ... */ }
// shared-worker.js
self.onconnect = (e) => {
const port = e.ports[0]
port.onmessage = (event) => {
port.postMessage(handleData(event.data))
}
}每次 new SharedWorker(url) 不会新建线程——同源同 URL 下复用同一实例。onconnect 在每次新连接建立时触发,而不是每次 new 都触发。
ServiceWorker
严格来说不是"多线程"工具,是网络代理层。运行在独立的 worker context 中,但生命周期由浏览器管理(安装 → 激活 → 闲置 → 终止),不受页面控制。这里不展开。
消息传递:structured clone 和 transferable
structured clone 算法
postMessage() 传递的数据不是引用,是深拷贝。浏览器使用 structured clone 算法序列化数据:
js
// 主线程
const data = { arr: new Float64Array(1000000) }
worker.postMessage(data)
// worker.js
// e.data.arr 是主线程 arr 的完整副本,不是同一个对象structured clone 支持的类型:基本类型、Object、Array、Date、RegExp、Map、Set、ArrayBuffer、TypedArray、Blob、File、ImageData 等。不支持:Function、DOM 节点、Symbol、Error 对象(部分浏览器支持但不建议依赖)。
关键性能点:postMessage 一个大数组(如 1M 条记录),这个数组会被完整拷贝。对于 10MB 的 ArrayBuffer,结构化克隆耗时通常在 5-15ms 之间(取决于设备和数据复杂度)。如果主线程和 worker 之间频繁传递大块数据,序列化开销会吃掉多线程带来的收益。
Transferable objects:零拷贝传递
对于 ArrayBuffer,可以使用 transfer 方式传递所有权:
js
// 主线程将 buffer 的所有权转移给 worker
const buffer = new ArrayBuffer(1024 * 1024 * 100) // 100MB
worker.postMessage({ buffer }, [buffer])
// 这行之后,主线程的 buffer.byteLength === 0
// buffer 的所有权已转移到 workertransfer 的真正含义是所有权转移,不是复制。transfer 后原上下文中的 buffer 被 neutered(byteLength 变为 0)。transfer 的时间复杂度接近 O(1),与数据大小无关——只是把底层内存块的引用从一个执行上下文换到另一个。
适合 transfer 的场景:主线程生成数据 → 交给 worker 处理 → 处理完 transfer 回来。中间不需要双端同时持有数据时,transfer 是唯一正确的选择。
MessageChannel
MessageChannel 创建一对互相连接的 port,可以在任意两个上下文间建立直接通信:
js
const channel = new MessageChannel()
worker.postMessage({ port: channel.port2 }, [channel.port2])
channel.port1.onmessage = (e) => {
// worker 发来的消息
}适合需要"请求-响应"模式的场景:主线程发任务给 worker,worker 通过专用 port 回传结果,不干扰 worker 的主消息通道。
Worker 线程的事件循环
Worker 内部有自己的事件循环,与主线程完全独立:
text
Worker 线程的事件循环:
1. 从 worker 的 task queue 取 task
2. 执行 task(包括 onmessage 回调)
3. 清空 microtask queue
4. 检查是否需要渲染 ← Worker 中没有渲染步骤
5. 循环Worker 没有 DOM,没有渲染管线,所以事件循环中没有 Update the rendering 阶段。这意味着 Worker 中的 requestAnimationFrame 不存在(严格来说是 undefined),requestIdleCallback 也不存在。
Worker 中可以正常使用 Promise、async/await、setTimeout/setInterval。这些 API 的调度机制和主线程一致——setTimeout 的最小延迟同样是 4ms(嵌套超过 5 层时)。
主线程与 Worker 的事件循环关系
text
主线程 Worker 线程
─────────────────────────────────────────
执行同步代码
postMessage(data) ──────────→ 入队 worker task queue
继续执行主线程代码 │
microtask 清空 │
可能的渲染 ↓
执行 onmessage 回调
self.postMessage(result)
│
继续 worker 代码...
↓
←────── 入队主线程 task queue
│
│
主线程收到 onmessage这里的关键约束:worker.postMessage() 不立即触发 worker 中的 onmessage。消息进入 worker 的 task queue,排在当前正在执行的 task 之后。同理,worker 发回的消息进入主线程的 task queue,排在主线程当前 task 之后。
Worker 的浏览器进程模型
Chrome 中,每个 Worker 运行在独立的线程中,但共享同一个渲染进程:
text
渲染进程(Renderer Process)
├── 主线程(Main Thread)
│ ├── JavaScript 执行
│ ├── 样式计算 / 布局 / 绘制
│ └── DOM / 事件处理
├── Worker 线程 1
│ └── JavaScript 执行(无 DOM)
├── Worker 线程 2
│ └── JavaScript 执行(无 DOM)
├── Compositor 线程
└── IO 线程Worker 线程创建的实际系统调用(简化),在 Chromium 源码路径中:
WorkerMessagingProxy在渲染进程中创建WorkerThread对象WorkerThread::Start()调用base::Thread::Start()- 底层通过
pthread_create(Linux/macOS)或CreateThread(Windows)创建 OS 线程 - 新线程中初始化 V8 isolate + 执行 worker 脚本
一个 Worker 对应一个 V8 isolate(独立的 JS 执行环境),拥有独立的堆内存。Worker 的 GC 和主线程的 GC 互不阻塞——Worker 中的标记-清除不会暂停主线程的 JS 执行(除非是 SharedArrayBuffer 场景需要跨线程同步)。
线程数的实际约束
浏览器的同源 Worker 数量没有硬性上限,但操作系统有。一台 8 核机器的物理线程并发数有限,创建 100 个 Worker 不会让计算变快——线程切换开销 > 计算收益。一般建议 Worker 数量不超过 navigator.hardwareConcurrency - 1(给主线程留一个核)。
何时用 Worker:计算 vs 通信的盈亏平衡
Worker 不是万能加速器。把任务丢给 Worker 之前,先算一下盈亏:
text
总耗时 = 消息序列化耗时 + Worker 计算耗时 + 消息反序列化耗时 + 事件循环调度延迟如果 总耗时 > 主线程直接计算耗时,Worker 是负优化。
适合 Worker
- CPU 密集型纯计算:图像/视频处理、加密/解密、数据压缩/解压、大数组的数值计算、正则表达式匹配大量文本
- 不依赖 DOM 的解析任务:解析 CSV/JSON 大文件、语法高亮、Markdown 编译
- WebSocket 数据处理:把 WebSocket 连接放在 Worker 中,数据解析不阻塞主线程
不适合 Worker
- 计算结果需要立即更新 DOM:通信延迟 + 序列化成本 > 直接在主线程计算
- 小规模计算:几百次循环的简单计算,Worker 的启动和通信成本比计算本身高
- 需要访问 DOM / localStorage / sessionStorage:Worker 中没有这些 API
实际性能基准(Chrome 132,8 核 M1 Pro)
| 任务 | 主线程直接执行 | Worker 执行 | 差异 |
|---|---|---|---|
| 1M 次 Math.sqrt | ~2ms | ~8ms | Worker 更慢(通信开销 > 计算收益) |
| 10M 次 Math.sqrt | ~18ms | ~10ms | Worker 快 1.8x |
| 字符串 hash(1MB 文本) | ~35ms | ~20ms | Worker 快 1.75x |
| JSON 解析(5MB) | ~45ms | ~28ms | Worker 快 1.6x |
| 图像卷积(1024x1024) | ~120ms | ~25ms | Worker 快 4.8x |
结论:只有当计算耗时 > 5-10ms 时,Worker 才有正向收益。对于 < 5ms 的计算,序列化成本会吃掉所有多线程优势。
Worker 的生命周期管理
终止 Worker
js
// 主线程终止
worker.terminate()
// Worker 内部终止
self.close()worker.terminate() 是强制终止——Worker 中的代码立即停止,不等待当前 task 执行完。self.close() 在 Worker 内部调用,允许当前 task 执行完毕,不再处理后续 task。
两者的共同点:终止后 Worker 的 onmessage / onerror 不再触发。Worker 实例不能复用——terminate 后需要 new Worker() 重新创建。
Worker 创建开销
new Worker(url) 的实际开销包括:
- 启动新 OS 线程(~5-15ms,取决于系统负载)
- 初始化 V8 isolate + context(~10-30ms,取决于 worker 脚本大小)
- 加载并编译 worker 脚本(与脚本大小成正比)
- 首次执行 worker 顶层代码
总启动时间在 20-100ms 之间。对于只需要用一次的短命 worker,启动成本可能 > 计算本身。对于频繁使用的 worker,保持存活比反复创建销毁更高效。
Worker 池模式
js
class WorkerPool {
constructor(scriptUrl, size = navigator.hardwareConcurrency - 1) {
this.workers = Array.from({ length: size }, (_, i) => ({
worker: new Worker(scriptUrl),
busy: false,
id: i,
}))
}
async run(data, transfer = []) {
const worker = this.workers.find(w => !w.busy)
if (!worker) {
// 池已满 — 等待第一个空闲的 worker
return new Promise(resolve => {
const check = setInterval(() => {
const free = this.workers.find(w => !w.busy)
if (free) {
clearInterval(check)
resolve(this._execute(free, data, transfer))
}
}, 10)
})
}
return this._execute(worker, data, transfer)
}
_execute(worker, data, transfer) {
worker.busy = true
return new Promise((resolve, reject) => {
worker.worker.onmessage = (e) => {
worker.busy = false
resolve(e.data)
}
worker.worker.onerror = (e) => {
worker.busy = false
reject(e)
}
worker.worker.postMessage(data, transfer)
})
}
terminate() {
this.workers.forEach(w => w.worker.terminate())
}
}Worker 池的优势:避免反复创建/销毁线程的开销,自动排队超过池容量的任务,控制最大并发数避免线程爆炸。
错误处理
Worker 内部错误
Worker 中未捕获的异常会触发主线程的 worker.onerror 事件:
js
worker.onerror = (e) => {
console.error({
message: e.message, // 错误信息
filename: e.filename, // worker 脚本 URL
lineno: e.lineno, // 错误行号
colno: e.colno, // 错误列号
})
// e.preventDefault() 可以阻止默认的错误日志(console 输出)
}⚠️ Worker 中的未捕获异常不会冒泡到主线程的 window.onerror 或 unhandledrejection。必须在 worker 实例上单独监听。
Worker 脚本加载失败
如果 new Worker('xxx.js') 的脚本文件不存在或加载失败(404、网络错误),触发 onerror 事件:
js
const worker = new Worker('./non-existent.js')
worker.onerror = (e) => {
// e.message === 'Uncaught NetworkError: ...'
}主线程的 try { new Worker(url) } catch 捕获不到这个问题——Worker 构造函数本身不抛错(因为网络请求是异步的)。
一些实际判断
不一定要把 WebSocket 放 Worker 里。 虽然 Worker 中的 WebSocket 不阻塞主线程,但 WebSocket 的数据接收和处理本身就是异步的(在主线程的事件循环中不会阻塞渲染)。只有在 WebSocket 接收的数据需要大量解析(如 10MB JSON 每 5s 到达一次)时,移到 Worker 才有意义。
SharedArrayBuffer + Atomics 是 Worker 通信的另一种范式,但门槛很高。 它允许多个 worker + 主线程共享同一块内存,真正的共享内存并发。但需要手动管理同步(Atomics.wait / Atomics.notify),没有锁保护的内存竞态问题需要自己解决。而且 SharedArrayBuffer 有严格的安全限制——需要 COOP/COEP 头部配置,实际项目中的引入成本不低。
不要在所有浏览器上用 Worker 实现"并行计算"的假象。 移动端浏览器(尤其是低端 Android)通常只有 2-4 个小核,创建额外线程的收益远低于桌面端。在移动端上,长任务拆分为多个小块、配合 requestIdleCallback 分批执行,比 Worker 更实用。
importScripts 是同步阻塞的。 Worker 中加载外部脚本使用 importScripts('./lib.js')。这个调用会同步阻塞 Worker 线程直到脚本加载并执行完毕。如果需要异步加载,改用 ES module worker:
js
const worker = new Worker('./worker.js', { type: 'module' })
// worker.js 中可以使用 import 语句
import { heavyLib } from './lib.js'ES module worker 中的 import 是异步的,不阻塞线程。但兼容性要求 Chrome 80+。
与相关方案的对比
| 方案 | 执行位置 | 是否有独立事件循环 | DOM 访问 | 适用场景 |
|---|---|---|---|---|
| DedicatedWorker | 独立线程 | 是 | 否 | CPU 密集型计算 |
| SharedWorker | 独立线程 | 是 | 否 | 多页面共享状态/连接 |
| setTimeout 分片 | 主线程 | 共用 | 是 | 可拆分的轻量长任务 |
| requestIdleCallback | 主线程 | 共用 | 是 | 非关键低优先级任务 |
| WASM + Worker | Worker 线程 | 是 | 否 | 数值密集(音视频/游戏) |
| Web Worker + Atomics | 独立线程 | 是 | 否 | 需要共享状态的高频计算 |
关键区分:setTimeout(fn, 0) 分片不会减少总计算时间——只是把长任务拆成多个短任务,让浏览器有机会在 task 之间渲染。Worker 真正利用了多核——计算在另一个物理线程上执行,主线程不参与。如果任务是纯计算、不可拆分、时长固定,Worker 是唯一能减少 wall-clock time 的方案。
补充阅读方向
- Chromium 源码
third_party/blink/renderer/core/workers/:Worker 的创建、生命周期、消息传递的浏览器侧实现 - HTML 规范 §10.2
Dedicated workers and the Worker interface:Worker API 的标准定义 base::Thread在 Chromium 中的实现:底层线程创建的实际路径- V8 的
Isolate隔离模型:理解 Worker 与主线程的堆独立性
