Web Worker 使用指南

1,240 阅读11分钟

1. 什么是Web Worker

简单来说,Web Worker是一个 在后台线程中运行的js脚本

这里有两个关键:后台线程和js脚本。

1.1 后台线程

后台线程,顾名思义就是在web程序主执行线程之外的线程。在浏览器中,它就是浏览器主的渲染执行线程的后台线程;在服务器中,它就是服务器主的响应线程的后台线程。

一般来说,后台线程是为防止主线程阻塞而开的,所以后台线程执行的任务一般有两个特点:

  1. 计算复杂。后台进程所执行的一般是需要大量计算的场景(比如图像处理、视频解码等)。因为它们直接在主线程执行会花费很多时间,严重阻塞页面/服务器正常的工作(比如说造成页面卡死、服务器无响应),所以才会在后台线程执行。
  2. 线程沟通少。虽然后台线程有与主线程传递数据的方法,理论上后台线程可以一直和主线程保持数据交换。但是线程之间的沟通实际上开销是非常大的,同时出于线程通信安全相关的考虑,Web Worker传输信息会先用结构化克隆算法拷贝一份再传递,这也加大了沟通成本。所以后台线程与主线程的沟通频率应该是尽可能少的。

1.2 js脚本

Web Worker中能够执行所有的js标准函数集,是一个正常的js环境。但是它与主线程的js环境也有一些不同:

  1. window => WorkerGlobalScope

    • worker所处的全局上下文并不是window,而是WorkerGlobalScope,其中有很多window的方法是可用的:
名称作用
atob()解码base64
btoa()编码为base64
interval (setInterval(), clearInterval())定时器
timeout (setTimeout(), clearTimeout())定时器
structuredClone()结构化克隆(深拷贝)
queueMicrotask()添加微任务到队列(类似于setTimeout(callback, 0))
animationFrame (requestAnimationFrame,clearAnimationFrame)下次重绘前执行动画
  1. worker的全局顶层对象为self

    • var声明的全局变量可以通过self.XXX找到
    • 可以用self.addEventListener来全局监听事件
    • 实际上就是DedicatedWorkerGlobalScope
  2. worker也有自己独有的函数

    • importScripts()能在worker里面实现import
    • postMessage能让worker与主线程进行通信
  3. worker 无法进行 DOM 操作

    • 一个例外是OffscreenCanvas,它是一个可以在worker环境中生效的canvas对象,在下文会详细介绍

2. 为什么是Web Worker

实际上在worker诞生前后,已经有非常多的方法来解决计算卡顿等问题。

2.1 时间分片

最开始,我们可以直接将大任务分成小任务,用时间分片技术来减少阻塞。比如下面这个处理图片的时间分片的例子:

我们把一个image分成一个个chunk,通过不断地setTimeout调用自身,把大任务分成小任务,同时中间可以插入处理其他任务不至于阻塞。

function processImage( callback, imageFn = i => {}, imageIn = [] ) {
  const chunkSize = 1000; 
  let imageOut = [],pointer = 0;
  processChunk();
  function processChunk() {
    const pointerEnd = pointer + chunkSize;
    imageOut = imageOut.concat(imageFn( imageIn.slice( pointer, pointerEnd ) ));
    if (pointerEnd < imageIn.length) {
      pointer = pointerEnd;
      setTimeout(processChunk, 0);
    }
    else if (callback) {
      callback( null, imageOut );
    }
  }
}

但是这种方式只是避免了阻塞,实际上还是在一个CPU一个线程中进行处理,浪费了多核CPU的性能,并没有减少计算时间。

2.2 SharedArrayBuffer

上文提到了线程间通信效率的问题:数据是先拷贝一份再传输的。后续浏览器提出了相关的api来缓解这个问题:SharedArrayBuffer

SharedArrayBuffer基于ArrayBuffer的一个新的API,同样是二进制内容,可以在不同的Worker之间共享,并且不需要经过拷贝。也就是说两个进程中的两个对象实际上是一块内存。

这就需要考虑到进程间竞争的问题,为此,浏览器又实现了一套完整的原子操作API

这里出于篇幅考虑不对它们进行详细介绍,我们只需要了解到:这两者提供了一套完整的线程通信方案,这让WebAssembly更好的具有了多线程能力。

但是sharedArrayBuffer并不能看作是Web Worker的替代品,而是一种补充。WebAssembly在使用时同样是使用worker来实现多线程

/// main.js ///
let moduleBytes = ...;  // An ArrayBuffer containing the WebAssembly module above.
let memory = new WebAssembly.Memory({initial: 1, maximum: 1, shared: true});
let worker = new Worker('worker.js');
const mutexAddr = 0;
​
// Send the shared memory to the worker.
worker.postMessage(memory);
​
let imports = {env: {memory: memory}};
let module = WebAssembly.instantiate(moduleBytes, imports).then(
    ({instance}) => {
        // Blocking on the main thread is not allowed, so we can't
        // call lockMutex.
        if (instance.exports.tryLockMutex(mutexAddr)) {
            ...
            instance.exports.unlockMutex(mutexAddr);
        }
    });
​
​
/// worker.js ///
let moduleBytes = ...;  // An ArrayBuffer containing the WebAssembly module above.
const mutexAddr = 0;
​
// Listen for messages from the main thread.
onmessage = function(e) {
    let memory = e.data;
    let imports = {env: {memory: memory}};
    let module = WebAssembly.instantiate(moduleBytes, imports).then(
        ({instance}) => {
            // Blocking on a Worker thread is allowed.
            instance.exports.lockMutex(mutexAddr);
            ...
            instance.exports.unlockMutex(mutexAddr);
        });
};

2.3 GPGPU

WebGPU API 使 web 开发人员能够使用底层系统的 GPU(图形处理器)进行高性能计算并绘制可在浏览器中渲染的复杂图形。其中使用GPU进行计算的能力被称为GPGPU(General-Purpose computation on Graphics Processing Units)

WebGPU让我们可以利用GPU进行大规模的计算,当然这同样是在另外的线程中处理的,避免堵塞主流程。同时考虑到GPU在计算能力上大部分时候优于CPU,WebGPU有着比worker更好的性能。目前Three.js(3D绘图)和tfjs(机器学习)都已经支持WebGPU了。

可以说WebGPU绝对是计算密集任务未来的最佳选择,但是目前还处在实验性阶段,兼容性极低,基本只有谷歌浏览器能够使用

image-20231110140853699.png

2.4 Web Worker 好在哪里?

相比于时间分片等技术,worker能够利用更多的CPU资源;

在SharedArrayBuffer等技术的支持下,我们可以在worker里使用多线程的wasm功能,加快计算

相比于WebGPU, worker在低端设备上能使用的资源更多(CPU > GPU),同时兼容性更好

image-20231110152304413.png

3. Web Worker基本使用

3.1 创建

const worker = new Worker(path, options);
参数说明
path有效的js脚本的地址,必须遵守同源策略。无效的js地址或者违反同源策略,会抛出SECURITY_ERR类型错误
options.type可选,用以指定 worker 类型。该值可以是 classicmodule。 如未指定,将使用默认值 classic
options.credentials可选,用以指定 worker 凭证。该值可以是 omit, same-origin,或 include。如果未指定,或者 type 是 classic,将使用默认值 omit (不要求凭证)
options.name可选,在 DedicatedWorkerGlobalScope 的情况下,用来表示 worker 的 scope 的一个 DOMString 值,主要用于调试目的。

创建成功返回worker,失败报错,有以下三种类型

名称说明
SecurityError文档不允许启动 worker
NetworkError脚本的MIME类型不为text/javascript
SyntaxErrorpath 无法被解析(格式错误)

3.2 关闭

/// main.js ///
worker.terminate();
/// worker.js ///
self.close();
close(); // 直接使用close()其实一样

在主线程或者worker线程都可以关闭worker

3.3 事件

可以在主进程和worker进程中使用addEventListener()进行事件监听,目前有以下几种事件:

名称触发在获取event类型说明
error主线程Event在worker内部发生错误时触发
message主线程,worker线程MessageEvent在接受到 worker内部 / 主线程 传来的消息时
messageerror主线程,worker线程MessageEvent在worker收到一条无法被 反序列化 / 序列化 的消息是触发
rtctransformworker线程RTCTransformEvent当编码的视频或音频帧已排队等待 WebRTC 编码转换处理时

addEventListener('message') 可以被简写为 onmessage

序列化和反序列化是结构化克隆算法的一步,JSON.stringify()就是一种序列化过程,简单的说就是将js对象转为可存储的一种形式,可以是JSON,也可以是其他类型。而反序列化,类似JSON.parse()

我们都知道如果传入值不符合JSON规范的话,JSON.parse()是会失败的,messageerror的形成原理也一样,就是格式错误。

3.4 数据传递

进程信息使用postMessage(),在主进程和worker进程都是一样的方法和参数要求。

postMessage(message, transfer)

message可以是任何值,如果可以被结构化克隆算法处理则正常传递,如果不可以则会导致messageerror;transfer则是一个可选的、会被转移所有权的可转移对象。转移之后当前传递进程无法访问操作该数据,接收到的进程则可以。

3.4.1 结构化克隆

结构化克隆算法通过递归对象构建克隆,类似于lodash.cloneDeep,他可以用来处理包括嵌套对象在内的绝大部分数据。但是也有一些缺陷:

  1. 不能拷贝函数

  2. 不能拷贝DOM节点

  3. 不会保留一些特定参数

    • RegExp 对象的 lastIndex 字段不会被保留
    • 属性描述符,setters 以及 getters(以及其他类似元数据的功能)同样不会被复制。例如,如果一个对象用属性描述符标记为 read-only,它将会被复制为 read-write,因为这是默认的情况下。
    • 原形链上的属性也不会被追踪以及复制。

3.4.2 对象转移

对象转移从根本上来说,是将一块内存的读写权限转移到另一个上下文。一般情况下,这个上下文就是不同的线程,而对象转移实际上就是对象共享。

只有这几种对象能够被转移:ArrayBuffer, MessagePort, ReadableStream, WritableStream, TransformStream, AudioData (en-US), ImageBitmap VideoFrame (en-US), OffscreenCanvas ,RTCDataChannel

如果试图读写被转移的对象,就会报错:TypeError: Cannot perform XXX on detached XXX. detached表明了读写权限的转移

那么转移对象的意义是什么呢?就是减少结构化克隆所需的时间。直接将内存交给另一个线程,实际上就是简单的线程通信安全策略的两个极端(极端共有,极端独占)

3.4.3 SharedArrayBuffer

其实在绝大部分情况下,使用SharedArrayBuffer的成本大于收益。参考这篇文章,实际上绝大部分情况下传输用时都是很小的,至少也要到百MB的数据大小级别,传输时间才是需要被考虑的性能问题。而出现这种情况,在绝大多数情况下都是携带数据太多了,没有进行压缩编码。而如果使用sharedArrayBuffer,就意味着所有的读写操作都要用Atomics重写。

3.5 不同类型的worker

除了上文提到的最基本的worker,还有两种worker类型:SharedWorker 和 Service Worker

  • SharedWorker 相比 Worker,主要的变化就是可以在多个浏览上下文(窗口、iframe、其他worker)中访问;同时它的全局作用域为SharedWorkerGlobalScope
  • Service Worker本质上是一个代理服务器,主要用来创建离线体验(比如谷歌浏览器经典的离线恐龙跑酷小游戏)。这与本文提到的worker使用场景相去甚远,本文就不详细介绍了

SharedWorker的api与worker有一定区别,但是整体使用功能相似:

workerSharedWorker
创建new Workernew SharedWorker
启动无需port.start()
发送消息postMessage()port.postMessage()
连接无需onconnect()

一个基本的SharedWorker使用方式如下:

/// main.js ///
const first = document.querySelector("#number1");
const result1 = document.querySelector(".result1");
if (!!window.SharedWorker) {
  const myWorker = new SharedWorker("worker.js");
  first.onchange = function () {
    myWorker.port.postMessage([first.value]);
    console.log("Message posted to worker");
  };
  myWorker.port.onmessage = function (e) {
    result1.textContent = e.data;
    console.log("Message received from worker");
    console.log(e.lastEventId);
  };
}
/// worker.js ///
onconnect = function (event) {
  const port = event.ports[0];
  port.onmessage = function (e) {
    const workerResult = `Result: ${e.data[0] * e.data[0]}`;
    port.postMessage(workerResult);
  };
};

4. 使用场景①: 离屏渲染

关于离屏canvas,一个非常经典的demo是avoid jank

在主线程有canvas执行动画,同时执行斐波那契数列计算这样的计算密集任务,进而阻塞canvas执行

这时可以使用transferControlToOffscreen(),将canvas的控制权转交给worker,让空闲的它来执行动画避免卡顿。

其中的关键在于:

/// main.js ///
const offscreen = document.querySelector('#canvas-worker').transferControlToOffscreen(); // 生成可转移对象
worker.postMessage({ msg: 'start', origin: urlParts.join('/'), canvas: offscreen }, [offscreen]); // transfer转移控制权
/// worker.js ///
self.onmessage = function(e) {
    //...
    animationWorker = new Animation(e.data.canvas.getContext('2d')); // 开启动画
    animationWorker.start();
    //...
}

但是这个demo有一个问题,其实它更应该把斐波那契拿去worker里面算,主线程不要阻塞。因为:

  1. 这里的计算密集任务是斐波那契数列计算。虽然在这里总共就两个任务两个线程,你分一下哪个都不会卡。但是如果除了canvas动画,主线程还有其他任务,那么它们同样会被阻塞。

  2. 从直觉上来讲,渲染这类与DOM操作相关的我们更认为应该放在主线程,而不是worker进程计算。虽然offscreen这个功能本身提供了这样的能力,但是从整体上来看worker还是主要处理计算相关。

    可能是考虑到了这一点,为了避免注意分散,找不到位置,作者将控制OffscreenCanvas的worker独立出来并给了id

那什么情况下,才会让OffscreenCanvas和渲染逻辑分离,和计算逻辑更紧密呢?我想到了一种方式:worker绘制图表,再传回主线程

4.1 Sparkline

sparkline,即迷你图,一般用于在表格中简单的展示趋势等信息。由于占地较小,一般没有坐标轴、交互操作等。

Sparkline Control for WinForms Apps | ComponentOne

sparkline的实现有一个非常严重的问题:图表绘制工具一般只支持 canvas / svg 绘制图表,但是很明显页面中又不应该以这种形式呈现,因为:

  1. 在表格很大的情况下,过多的canvas / svg 会造成严重的性能问题。
  2. 在表格支持懒加载的情况下,滑动到表格底部才会开始请求数据,请求完数据才能异步开始绘制图表(比如使用 Promise.then()),不仅图表会在表格显示之后一会才延迟显示,同时也会使代码难以阅读和维护。

这时我们可以考虑用Web Worker的OffscreenCanvas先提前从主线程获取数据,然后在其中绘制图表,将结果用toDataURL()转为data64字符串,主线程获取这个字符串将图表展示为图片,避免在主线程出现过多的canvas

流程图如下所述,我们在worker中获取数据并通过OffscreenCanvas反复绘制图表,生成DataURL返回给主线程,主线程直接使用图片

image-20231113100031356.png

从主线程的角度,这不过是从两个不同的接口获取数据(从数据库接口获取绝大部分原始数据,从worker接口获取DataURL数据),保证了主线程的低耦合性,后续考虑从后端直接获取dataURL需要更改的代码页不多。

最关键的是:主线程没有出现过哪怕一次的canvas绘制,最大程度的减少了主线程的渲染压力。同时也和前面的离屏渲染不同,并没有造成逻辑分离,因为我们本来就是要的图片而不是canvas。

5. 使用场景②:生成Excel文件

很多时候我们有需要让用户把页面数据下载为表格文件的需求。很多时候这是完全通过后端实现的,前端只负责发送请求。但是这个需求实际上也可以用xlsx.js实现。

如果想要在前端实现,就必须考虑到数据量较大的情况下表格文件生成是否会阻塞主线程,这时候我们就可以使用Web Worker来实现。

/// main.js ///
const worker = new Worker(new URL('./utils/uploadExcel.js?raw', import.meta.url))
// 发送数据
worker.postMessage(list)
// 监听消息
worker.onmessage = ({ data }) => {
  const a = document.createElement("a");
  a.download = `${new Date().toLocaleTimeString()}.xlsx`;
  a.href = URL.createObjectURL(new Blob([data], { type: "application/octet-stream" }));
  a.click();
}
// 点击导出按钮触发getExcel
const getExcel = () => {
  worker.postMessage("start")
}
/// worker.js ///
self.importScripts('https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.15.3/xlsx.core.min.js');
onmessage = function (event) {
  const { data } = event
  if (data === "start") {
    let startTime = new Date();
    slef.onmessage = ({data}) => {
      console.log("开始整理数据-"+startTime);
      const ws = XLSX.utils.aoa_to_sheet(data, { dense: true });
      const wb = XLSX.utils.book_new();
      XLSX.utils.book_append_sheet(wb, ws);
      postMessage(XLSX.write(wb, {type: 'array', bookType: 'xlsx', bookSST: true, compression: true}));
      let endTime = new Date()
      console.log("结束正在将数据发送给主线程-"+endTime)
      console.log(`传递总耗时间${Math.floor((endTime - startTime )/ 1000)}`);
    });
  }
}

其实这部分也可以与前文提到的表格sparkline配合:先用worker生成img插入表格,然后再用worker调用xlsx.js生成xlsx文件。这里的关键就是实现worker的嵌套以及相互的通信。

6. 使用前景:Partytown

由于Partytown还在beta版本不稳定,所以这里只简单介绍其使用前景。

Partytown是一个轻量的库,可以将第三方脚本移到Web Worker中,从而减少加载时间。同时不需要担心第三方脚本不可用,因为Partytown会同步读写主线程DOM操作,保证worker中的第三方脚本正常执行

image-partytown

在vue中使用Partytown实际上进行的操作有两步:

  1. 在构建的时候获取所有第三方脚本,将他们都打包到public/~partytown/partytown.js
  2. index.html中引入

具体的步骤有三步:

  1. npm install @builder.io/partytown
  2. /// vite.config.js ///
    import path from "path";
    import { partytownVite } from "@builder.io/partytown/utils";
    
    export default ({ command }) => ({
      build: {
        plugins: [
          partytownVite({
            dest: path.join(__dirname, "dist", "~partytown"),
          }),
        ],
      },
    });
    
  3. /// In package.json ///
    "scripts": {
      // ...
      "dev": "partytown && vite",
      "build": "partytown && vite build",
      "preview": "partytown && vite preview --port 4173",
      "partytown": "partytown copylib public/~partytown"
    }
    

详细的快了多少的数据并没有具体测试,因为目前还存在着许多问题。后续在考虑性能优化的时候可以尝试这个方向。

7. 总结

本文介绍了 web worker 的作用和基本使用,给出了两个使用场景(表格迷你图表、Excel文件生成)。最后还介绍了Partytown这个利用 web worker 进行性能优化的新工具。在后续会详细调研Partytown的能力,看看能不能帮助我们进行性能优化。

8. 参考资料

  1. 两万字Web Workers终极指南
  2. mdn: Web Worker API
  3. JavaScript多线程编程
  4. mdn: WebGPU API
  5. WebAssembly多线程支持的内部原理
  6. WebAssembly threads
  7. OffscreenCanvas 批量离屏绘制迷你图表
  8. using-partytown-to-improve-the-performance-of-vuejs-applications