Skip to content

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 的所有权已转移到 worker

transfer 的真正含义是所有权转移,不是复制。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~8msWorker 更慢(通信开销 > 计算收益)
10M 次 Math.sqrt~18ms~10msWorker 快 1.8x
字符串 hash(1MB 文本)~35ms~20msWorker 快 1.75x
JSON 解析(5MB)~45ms~28msWorker 快 1.6x
图像卷积(1024x1024)~120ms~25msWorker 快 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) 的实际开销包括:

  1. 启动新 OS 线程(~5-15ms,取决于系统负载)
  2. 初始化 V8 isolate + context(~10-30ms,取决于 worker 脚本大小)
  3. 加载并编译 worker 脚本(与脚本大小成正比)
  4. 首次执行 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.onerrorunhandledrejection。必须在 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 + WorkerWorker 线程数值密集(音视频/游戏)
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 与主线程的堆独立性