Web Workers 与并行计算

Web Workers 与并行计算——怎样把重计算从主线程移出去,理解消息传递、structured clone、transferable、OffscreenCanvas、SharedArrayBuffer,以及它们在真实前端工程里的边界。

Web Workers 与并行计算

浏览器里的“卡”,很多时候不是慢,而是堵

前端性能问题常常被笼统地说成“页面慢”。

但从用户感受上看,很多更痛的问题其实是:

  • 输入不跟手
  • 滚动发黏
  • 点击没反应
  • 动画掉帧
  • 页面像被卡住了一样

这类问题经常和主线程被占住有关。

因为浏览器里的 UI 渲染、事件处理、很多脚本执行,都要在主线程上争时间。

一旦某段同步任务太重,用户就会直接感知到阻塞。

这也是 Web Workers 最核心的价值:

不是让 JavaScript 变神奇。

而是把某些工作从主线程移开,让界面保持可交互。

先把 Worker 讲简单

Worker 可以理解成:

浏览器里一个独立于页面主线程运行的 JavaScript 执行环境。

它有自己的全局作用域、自己的事件循环,能和主线程通过消息通信。

这句话里最重要的是“独立”。

也正因为独立,Worker 不是免费共享一切。

它和主线程之间有清晰边界。

Worker 不是什么

这点也要先说清楚。

Worker 不是:

  • 任意共享 DOM 的副线程
  • 想调用什么页面对象就能调用的环境
  • 自动把代码并行化的魔法

大多数 Worker 里不能直接操作 DOM。

这不是缺点。

这是它的边界。

也正因为它不直接碰 DOM,才更适合承载计算、解析、转换这类工作。

常见 Worker 类型

Dedicated Worker

最常见。

通常被单个页面或单个脚本拥有。

适合:

  • 图像处理
  • 大数据计算
  • 文本解析
  • 编辑器里的后台任务

SharedWorker

可以被多个页面或 tab 共享。

它的价值在于共享连接和状态。

但工程复杂度也更高,所以日常项目里不如 Dedicated Worker 常见。

Service Worker

Service Worker 也叫 worker,但它的职责和前两者不同。

它更偏网络代理、缓存、离线、推送。

不要把它和“拿去做重计算的 Worker”混在一起。

Web Workers 最适合处理什么

比较典型的场景有:

  • 大数组排序、过滤、聚合
  • Markdown / 富文本解析
  • 代码高亮、lint、格式化
  • 图像编解码和处理
  • 音视频波形或特征计算
  • 密码学、压缩、哈希
  • Canvas 离屏绘制

一个很实用的判断标准是:

如果某段工作明显会抢主线程时间,而且又不需要直接操作 DOM,就值得考虑 Worker。

什么时候不值得上 Worker

也不是所有计算都值得搬。

因为 Worker 也有成本:

  • 初始化成本
  • 通信成本
  • 构建和调试成本
  • 状态同步复杂度

如果任务很轻、很短、很少执行,直接放主线程可能更简单。

成熟判断不是“凡是性能问题都上 Worker”。

而是评估:

  • 重不重
  • 频不频
  • 搬过去之后值不值

消息通信为什么是 Worker 的第一道门槛

主线程和 Worker 默认通过 postMessage 通信。

这意味着你需要把任务写成一种消息协议:

  • 发什么数据过去
  • 期望收到什么结果
  • 失败时怎么回传
  • 如何取消或忽略过期任务

这也是为什么 Worker 设计往往不仅是“把函数搬过去”。

而是把一段逻辑重新组织成异步消息系统。

Structured clone 到底在做什么

很多数据在主线程和 Worker 之间传递时,会走 structured clone。

简单说,就是浏览器用一套规定好的算法,把可复制的数据结构拷过去。

MDN 对这一点讲得很清楚:

不是所有对象都能这样传。

像函数、DOM 节点这类就不行。

这会直接影响 Worker API 设计。

如果你的主线程逻辑严重依赖闭包、函数回调和 DOM 对象,搬进 Worker 往往就没那么顺。

Transferable 为什么重要

如果所有数据都一律复制,通信成本会很高。

这时 transferable object 就很关键了。

ArrayBuffer 这类对象,可以把底层资源直接“转移”过去,而不是复制一份。

转移后,原线程上的那个对象会被 detach。

这非常适合:

  • 大块二进制数据
  • 图像、音频 buffer
  • 计算密集场景里的零拷贝传递

所以 Worker 不是只会“消息很慢”。

关键是你有没有用对传输方式。

SharedArrayBuffer 为什么更进一步

ArrayBuffer 是转移。

SharedArrayBuffer 则是共享。

它允许不同执行上下文看到同一块共享内存。

这会打开更底层的并行编程能力。

但也会引入同步和安全问题。

所以它不是日常 Worker 编程的默认选项。

你通常只会在这些场景认真考虑它:

  • 高性能计算
  • Wasm 线程
  • 需要显式共享内存的复杂场景

另外,浏览器对 SharedArrayBuffer 有明确安全要求。

MDN 也强调了 cross-origin isolation 条件。

这点不能忽略。

OffscreenCanvas 为什么很实用

很多图形问题的痛点,不只是计算。

还包括绘制本身抢了主线程时间。

OffscreenCanvas 的价值就在于:

把 canvas 绘制能力带到 Worker 里。

这对下面这些场景非常有帮助:

  • 图表渲染
  • 粒子系统
  • 图像编辑器
  • 视频帧处理

它让“计算在 Worker,绘制还在主线程”的割裂感减少很多。

requestAnimationFrame 和 Worker 是什么关系

很多人会把这两个概念混在一起。

其实它们关注点不同。

  • requestAnimationFrame 更偏主线程上的逐帧调度
  • Worker 更偏把某些任务并行或异步地移出去

两者可以配合,但不是替代关系。

例如:

  • 主线程保留输入和可见层控制
  • Worker 负责计算下一帧需要的数据

这类分工在复杂图形里很常见。

Comlink 为什么值得知道

原生 postMessage 很基础,也很真实。

但写多了会觉得样板多、协议代码多。

Comlink 这类库的价值就在于:

把 Worker 调用包装得更像调用一个远端对象。

它不会消除边界。

但能降低很多消息封装负担。

在团队里,这类抽象常常会明显改善可维护性。

Worker 真正难的地方,不是创建,而是任务模型

写一个 Worker demo 很简单。

真正进入生产后,难点通常是:

  • 任务怎么分块
  • 结果如何回传
  • 如何取消过期任务
  • 如何限制并发
  • 一个 Worker 够不够,还是需要 worker pool

例如搜索输入时,如果用户连续输入 10 次,你不希望前 9 次的旧结果晚到后覆盖新结果。

所以 Worker 设计最终会回到系统问题:

任务编排和状态一致性。

前端里典型的 Worker 模式

单任务型

一个任务发进去,一个结果回来。

适合简单计算。

长驻服务型

Worker 长期存在,持续接任务。

适合编辑器、解析器、复杂图形。

池化型

多个 Worker 分担任务。

适合更强的并行场景,但复杂度也更高。

调试和构建为什么会增加心智成本

Worker 一旦进入真实项目,就要考虑:

  • 打包方式
  • 路径和模块加载
  • sourcemap
  • 错误上报
  • 本地与生产环境一致性

这也是为什么 Worker 虽然很强,但并不适合作为“默认架构”。

只有当主线程压力真的成问题,它的收益才会显得足够大。

Worker 和 Wasm 为什么经常一起出现

因为两者都在处理“把重活从主线程挪开”的问题。

Wasm 带来的是更接近底层的执行能力。

Worker 带来的是更合理的线程边界。

二者结合后,适合:

  • 编码器
  • 图像处理
  • 大规模文本处理
  • 音视频算法

但不要把“上 Wasm + Worker”当成银弹。

工程成本和调试成本都不低。

中国互联网语境里的常见场景

在国内业务里,Worker 很多时候会出现在这些地方:

  • 富文本编辑器
  • 图片压缩与上传前处理
  • 大表格导出
  • 活动页复杂动画与粒子
  • 音视频或实时互动页面

这些场景有一个共同点:

用户感知非常直接,主线程一堵就很明显。

海外产品语境里,为什么 Worker 话题常和平台能力一起讨论

很多海外前端语境里,会更明确地把 Worker 放进:

  • off-main-thread
  • Baseline
  • performance budget
  • collaborative apps

也就是把它视为平台能力的一部分,而不是冷门技巧。

这很值得借鉴。

因为 Worker 的价值,本来就不是“会不会这个 API”。

而是“你能不能合理利用浏览器的并行边界”。

这类主题为什么特别适合做表达训练

因为它容易被说成:

  • JS 是单线程,但 Worker 可以多线程

这句不算错。

但它太粗。

更成熟的表达应该继续说明:

  • Worker 为什么能改善交互卡顿
  • 它为什么不能直接操作 DOM
  • structured clone、transferable、SharedArrayBuffer 分别意味着什么
  • OffscreenCanvas 在什么情况下改变了方案选择

建议实践

实践 1:把一个重计算任务搬进 Dedicated Worker

练什么:

感受主线程阻塞和 Worker 分流的差异。

最小交付物:

一个大数组排序或 Markdown 解析 demo。

验收标准:

  • 主线程交互明显更顺
  • 能解释通信与初始化成本

常见误区:

  • 只看结果时间,不看主线程是否真的解堵

实践 2:做一次 transferable 对照实验

练什么:

理解复制与转移的差别。

最小交付物:

一个 ArrayBuffer 在 clone 与 transfer 两种方式下的对照 demo。

验收标准:

  • 能观察到原对象被 detach
  • 能解释为什么大数据场景适合 transfer

常见误区:

  • 把 transfer 误解成“复制得更快”

实践 3:用 OffscreenCanvas 做一个离屏绘制 demo

练什么:

理解计算和绘制一起移出主线程后的价值。

最小交付物:

一个简单图表或粒子动画 demo。

验收标准:

  • 绘制逻辑在 Worker 内运行
  • 主线程仍保持良好交互

常见误区:

  • 只把计算放进 Worker,绘制仍在主线程抢资源

延展阅读