Web Worker 实战:RPC 封装、Transferable 零拷贝与线程池限流

举报
yd_246800664 发表于 2026/11/15 00:36:07 2026/11/15
【摘要】 用 Web Worker 把重计算搬出主线程:覆盖最小 Worker 写法、自增 id 的请求-响应 Promise 化、超时与错误回传、Transferable 移交 ArrayBuffer 实现零拷贝、按核数限流的线程池,以及内联/模块 Worker、terminate 收尾与常见坑。

主线程为什么会被拖垮

浏览器里 JS 执行、样式计算、布局、绘制和输入响应基本都在主线程排队。60fps 下一帧只有约 16.7ms 预算,一段 200ms 的 CSV 解析、图片像素处理或大数组排序,就足以让点击、滚动、动画全部卡顿,用户直接感知到「页面假死」。常见的 setTimeout 分片只是把长任务切成几段,仍然占用主线程,治标不治本。

Web Worker 提供了一条真正的并行线程:它有独立的事件循环和全局作用域,适合放纯计算任务。代价是不能碰 DOM、window、document,与主线程只能靠 postMessage 通信。本文从最小可用写法出发,一路封装到 RPC、零拷贝传输与线程池,最后把工程里最容易踩的坑集中列一下。

一、最小可用写法

主线程 new Worker(url) 起一个线程,双方各自监听 onmessage。Worker 里的全局对象是 self,不是 window。

// main.js —— 主线程
const worker = new Worker('./compute.worker.js', { type: 'module' });

worker.onmessage = (e) => {
  console.log('结果', e.data);
};
worker.postMessage({ type: 'SUM', nums: [1, 2, 3, 4, 5] });

// compute.worker.js —— Worker 线程(没有 window / document)
self.onmessage = (e) => {
  const { type, nums } = e.data;
  if (type === 'SUM') {
    const sum = nums.reduce((a, b) => a + b, 0);
    self.postMessage(sum);
  }
};

三个前提要记牢:Worker 脚本受同源策略约束,跨域要服务端配合或走 Blob;type: 'module' 才能在 Worker 里用 import,但老浏览器支持不一;传递的数据走结构化克隆,函数、DOM 节点、class 实例的方法都传不过去。

二、把消息封装成 RPC

直接在业务代码里写 onmessage 分支,很快就会变成一堆全局变量和回调。更实用的模型是「一次调用 = 一个 Promise」:给每个请求分配自增 id,用 Map 存下 resolve/reject,收到回包按 id 取出来结算。

// 主线程:把「发消息 + 等回包」封装成一次 Promise 调用
function createWorkerRPC(url) {
  const worker = new Worker(url, { type: 'module' });
  const pending = new Map();
  let seq = 0;

  worker.onmessage = (e) => {
    const { id, ok, result, error } = e.data;
    const task = pending.get(id);
    if (!task) return;                 // 迟到的回包,忽略
    pending.delete(id);
    clearTimeout(task.timer);
    ok ? task.resolve(result) : task.reject(new Error(error));
  };

  function call(type, payload, timeout = 10000) {
    const id = ++seq;
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        pending.delete(id);
        reject(new Error('Worker 调用超时: ' + type));
      }, timeout);
      pending.set(id, { resolve, reject, timer });
      worker.postMessage({ id, type, payload });
    });
  }

  return { call, terminate: () => worker.terminate() };
}

const rpc = createWorkerRPC('./compute.worker.js');
const sum = await rpc.call('SUM', { nums: [1, 2, 3] });
// compute.worker.js —— 与主线程约定统一的 { id, ok, result, error } 信封
self.onmessage = async (e) => {
  const { id, type, payload } = e.data;
  try {
    let result;
    switch (type) {
      case 'SUM':
        result = payload.nums.reduce((a, b) => a + b, 0);
        break;
      case 'PARSE_CSV':
        result = parseCSV(payload.text);
        break;
      default:
        throw new Error('未知任务类型: ' + type);
    }
    self.postMessage({ id, ok: true, result });
  } catch (err) {
    // Error 对象不能被结构化克隆完整传递,手动转成字符串
    self.postMessage({ id, ok: false, error: err.message });
  }
};

这个封装顺手解决了三个问题:并发请求不会串包(靠 id 对应)、调用方能 await(不用在业务里注册回调)、异常会 reject(Worker 捕获后以 ok:false 回传)。注意 Error 对象不能被结构化克隆完整传递,要手动转成 message 字符串;回包迟到时要检查 pending 里还有没有这个 id,否则超时后重复结算。

三、超时、取消与错误边界

Worker 一旦进入死循环或抛了未捕获异常,主线程会永远等下去,所以每个调用都必须有超时。上面 call 里的 timer 就是兜底:超时后从 pending 删除并 reject,但此时 Worker 线程还在跑,必要时要 terminate() 重建。另外建议在主线程监听 worker.onerror 与 onmessageerror,前者捕获 Worker 内未处理的异常,后者在「数据无法被结构化克隆」时触发——后者非常隐蔽,比如不小心把函数塞进了 payload,消息会静默发不出去。

四、Transferable:避免大内存复制

默认情况下 postMessage 会克隆整份数据,一个 64MB 的 ArrayBuffer 传进 Worker,主线程和 Worker 就各占 64MB。对图像、音频、大表格这类场景,应该用 Transferable 直接移交所有权,实现零拷贝。

// 默认 postMessage 会结构化克隆,大数组要复制一份,主线程和 Worker 各占一份内存
const buffer = new ArrayBuffer(64 * 1024 * 1024);   // 64MB
const view = new Uint8Array(buffer);

// 移交(transfer)所有权:零拷贝,但主线程这边的 buffer 立即失效
worker.postMessage({ type: 'HASH', buffer }, [buffer]);
console.log(view.byteLength);          // 0 —— 已被 neutered,别再读

// Worker 处理完再转移回来,形成「乒乓」复用同一块内存
self.postMessage({ type: 'HASH_DONE', buffer }, [buffer]);

postMessage 的第二个参数是转移列表,只能是 ArrayBuffer、MessagePort、ImageBitmap 等可转移对象。转移后原线程的那块内存立即失效(byteLength 变 0),再读写会报错,这是零拷贝的代价。工程上常用「乒乓」模式:Worker 算完再把同一块 buffer 转移回来,全程不复制。普通对象、字符串无法转移,仍走克隆。

五、线程池与并发控制

一个任务起一个 Worker 是最常见的误区:Worker 本身有启动开销和内存占用,起几十个反而更慢,而且 navigator.hardwareConcurrency 通常只有 4~16。正确做法是固定大小的线程池 + 任务队列,让并发数贴近 CPU 核数。

function createPool(url, size = navigator.hardwareConcurrency || 4) {
  const workers = Array.from({ length: size },
    () => createWorkerRPC(url));
  const queue = [];
  let cursor = 0;

  function run(type, payload) {
    return new Promise((resolve, reject) => {
      queue.push({ type, payload, resolve, reject });
      pump();
    });
  }

  function pump() {
    // 简单轮询分发;也可改成「谁空闲谁领任务」的 ack 机制
    while (queue.length) {
      const idle = workers.find((w, i) => !w.busy);
      if (!idle) break;
      const task = queue.shift();
      idle.busy = true;
      idle.call(task.type, task.payload)
        .then(task.resolve, task.reject)
        .finally(() => { idle.busy = false; pump(); });
    }
  }

  return {
    run,
    terminate: () => workers.forEach(w => w.terminate())
  };
}

const pool = createPool('./compute.worker.js', 4);
const results = await Promise.all(
  chunks.map(c => pool.run('PARSE_CSV', { text: c }))
);

示例用 busy 标记做简单分发,实际项目可以改成 Worker 主动回报空闲再领任务,负载更均匀。还要留意:CPU 密集型任务按核数限流,但 IO 密集型可以适当放大;同一个大 ArrayBuffer 不能同时派给两个 Worker,转移后要等它回来才能复用;页面隐藏或组件卸载时要 terminate() 整池,否则后台线程会一直吃 CPU。

六、内联 Worker、模块 Worker 与生命周期

不想为一个几十行的小函数单独建文件,可以用 Blob + URL.createObjectURL 内联;用完后要 revokeObjectURL 释放。需要复用工具库时用 { type: 'module' } 加载模块 Worker。

// 不想多建一个文件:用 Blob 内联 Worker
function inlineWorker(fn) {
  const src = '(' + fn.toString() + ')()';
  const blob = new Blob([src], { type: 'text/javascript' });
  const url = URL.createObjectURL(blob);
  const worker = new Worker(url);
  // 用完后记得释放 objectURL,否则一直占着内存
  worker.addEventListener('message', () => {
    // ...
  });
  return { worker, revoke: () => URL.revokeObjectURL(url) };
}

inlineWorker(() => {
  self.onmessage = (e) => self.postMessage(e.data * 2);
});

// 用不上时主动 terminate,Worker 里的定时器和循环才会停
// worker.terminate();

其余常见坑集中列一下:

  • Worker 里没有 DOM:不能 document.querySelector,也拿不到 localStorage(indexedDB、fetch、WebSocket 可用);
  • 别传不可克隆的值:函数、Symbol、DOM 节点、含方法的类实例都会失败,且错误发生在 onmessageerror,很容易漏掉监听;
  • 转移后原 buffer 失效,读写前要确认所有权,别在同一线程里继续用;
  • 线程数不是越多越好,按 hardwareConcurrency 限流,避免起一堆 Worker 互相抢 CPU;
  • 必须有超时和 terminate,否则死循环会永久挂起 Promise 且后台持续占 CPU;
  • 调试方式不同:Worker 的 console 在浏览器开发者工具的独立线程上下文里,断点也要切过去;
  • 构建工具要单独配置:new Worker(new URL('./x.worker.js', import.meta.url)) 是 Vite/webpack 5 推荐的写法,直接写字符串路径容易被打包漏掉;
  • 小任务别用 Worker:消息往返本身有序列化与调度开销,几毫秒的计算搬过去反而更慢。

小结

Web Worker 的价值不是「能开线程」,而是给主线程减负:把纯计算搬走,让 16.7ms 的帧预算留给渲染和交互。落地时真正花功夫的是通信层——用自增 id 把消息封装成 RPC 让调用方可 await,给每个调用加超时与错误回传,大内存用 Transferable 零拷贝,再用线程池把并发压到核数附近。边界同样重要:不能碰 DOM、值必须可克隆、Worker 用完要 terminate。把这套封装沉淀成几十行工具函数,之后处理大文件解析、图像运算、批量排序就能直接复用,不必每次重搭。

本文为原创实践记录,示例代码均可直接运行,欢迎交流更稳妥的多线程任务调度与内存复用方案。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。