WebGL Tutorial
and more

Web Workers

撰写时间:2023-12-13

修订时间:2024-04-09

概述

Web Workers可在后台以独立的线程运行特定的任务,这样,主线程(通常是UI)就不会因此而阻塞或变慢。

初识Web Worker

主线程的代码:

if (!window.Worker) { throw new Error('Your browser does not support Web worker!'); } const worker = new Worker('worker.js'); let arr = [5, 2, 3, 8, 7, 10, 2]; worker.postMessage(arr); worker.onmessage = (e) => { console.log('Result from worker:'); console.log(e.data); };

上面的代码,主线程孵化了一个Worker线程,用以执行worker.js文件的内容。

线程之间通过postMessage传递数据,并响应onmessage事件以接收数据。

worker.js的代码:

onmessage = (e) => { let arr = e.data; const maxValue = arr.reduce((a, b) => Math.max(a, b), -Infinity); postMessage(maxValue); };

这种只能通过调用它的脚本来访问的Worker类,称为dedicated worker.

Web Workers的应用

多个线程同时工作,可在一定程度上提高应用效率。

下面,我们准备使用Web worker来计算从1加到80亿的和。

import {Performance} from '/tutorials/webgl/textures/examples/js/esm/CommonUtils.js'; function init() { const worker = new Worker('taskDispatcher.js'); let jobSpec = { nums: { from: 1, to: 80_0000_0000 }, workers: 6 }; Performance.Start(); worker.postMessage(jobSpec); worker.onmessage = (e) => { Performance.Stop(); console.log(e.data); }; } init();

taskDispatcher.js的内容:

onmessage = (e) => { splitJobs(e.data); }; function splitJobs(spec) { const {nums, workers} = spec; let totals = nums.to - nums.from + 1; let unitAmount = Math.floor(totals / workers); let left = totals % workers; let workersFinished = 0; let sum = 0; for (let currWorker = 1; currWorker <= workers; currWorker++) { let from = nums.from + unitAmount * (currWorker - 1); let to = from + unitAmount - 1; if (currWorker === workers) { to += left; } const worker = new Worker('sum.js'); worker.onmessage = (e) => { workersFinished++; sum += e.data; if (workersFinished === workers) { postMessage(sum); } }; worker.postMessage([from, to]); } }

根据要求的线程数量,为它们平均分配计算量。sum.js文件的内容:

onmessage = (e) => { let arr = e.data; let sum = 0; let start = arr[0]; let end = arr.at(-1); for (let i = start; i <= end; i++) { sum += i; } postMessage(sum); };

运行程序,当只有一个线程时,所需时间为9669.00毫秒,即9.7秒。将线程数量改为4,所需时间为2342.00毫秒。将线程数量改为10,所需时间为1704.00毫秒。如果再继续增大线程数量,则可能还可以再提升一点性能。例如,线程数为50时,时间为1666.00毫秒。但太多线程,可能适得其反,速度反倒变慢了。因此,需谨慎选取最合适的线程数量。

多少个Worker才最好

主线程:

const worker = new Worker('taskDispatcher.js'); [1, 2, 3, 6, 8, 15, 30, 50, 100].forEach(workers => { let jobSpec = { nums: { from: 1, to: 50_0000_0000 }, workers: workers }; worker.postMessage(jobSpec); worker.onmessage = (e) => { let obj = e.data; console.log('workers: ' + obj.workers); console.log(' elapsedTime: ' + obj.elapsedTime + ' ms'); }; });

任务分派:

onmessage = (e) => { splitJobs(e.data); }; function splitJobs(spec) { const { nums, workers = navigator.hardwareConcurrency } = spec; let totals = nums.to - nums.from + 1; let unitAmount = Math.floor(totals / workers); let left = totals % workers; let workersFinished = 0; let sum = 0; for (let currWorker = 1; currWorker <= workers; currWorker++) { let from = nums.from + unitAmount * (currWorker - 1); let to = from + unitAmount - 1; if (currWorker === workers) { to += left; } let startTimeStamp = performance.now(); const worker = new Worker('sum.js'); worker.onmessage = (e) => { workersFinished++; sum += e.data; if (workersFinished === workers) { let stopTimeStamp = performance.now(); postMessage({ workers: workers, elapsedTime: Math.floor(stopTimeStamp - startTimeStamp) }); } }; worker.postMessage([from, to]); } }

可能的结果:

workers: 30 elapsedTime: 8030 ms workers: 50 elapsedTime: 10159 ms workers: 100 elapsedTime: 10263 ms workers: 15 elapsedTime: 10715 ms workers: 8 elapsedTime: 12195 ms workers: 6 elapsedTime: 12664 ms workers: 3 elapsedTime: 14899 ms workers: 2 elapsedTime: 16658 ms workers: 1 elapsedTime: 22052 ms

排在最上面的是最快的。调节要计数的范围,排序结果也不一样。因此,可根据具体任务量的多少,修改运行此程序来确定最合适的Web workers的数量。

OffscreenCanvas

OffscreenCanvas,也即离屏Cavnas,可无须事先创建任何一个Canvas就可在后台进行渲染。渲染完成后,可将渲染结果快速地绘制至任意一个前台的Canvas之上。

初识OffscreenCanvas

在下面的例子中,网页上有2个Canvas对象。

它们的大小分别为:

#canvas1 { width: 200px; height: 200px; } #canvas2 { width: 150px; height: 150px; }

我们要使用OffscreenCanvas进行2次离屏渲染,然后,将这些渲染结果分别绘制到上述两个Canvas上面。

为使代码更加灵活,我们使用了回调机制。首先,在OffscreenCanvas的原型上添加一个renderInto方法:

OffscreenCanvas.prototype.renderInto = function (targetCanvas, callback) { this.width = targetCanvas.clientWidth; this.height = targetCanvas.clientHeight; callback(this.getContext('2d')); let imageBitmap = this.transferToImageBitmap(); let targetCtx = targetCanvas.getContext('bitmaprenderer'); targetCtx.transferFromImageBitmap(imageBitmap); };

该方法先使离屏Canvas的宽度与高度分别与参数targetCanvas保持一致,然后,将一个CanvasRenderingContext2D对象注入到参数callback回调函数中,这样,调用者可利用该CanvasRenderingContext2D对象进行渲染。

之后,调用OffscreenCanvastransferToImageBitmap方法将渲染结果存储为一个ImageBitmap的实例变量imageBitmap

最后,调用targetCanvasgetContext('bitmaprenderer')方法,返回一个ImageBitmapRenderingContext实例,该实例只有一个方法transferFromImageBitmap,用以接收所渲染完毕的结果。

可以多次调用transferToImageBitmap,并向多个canvas传递。

客户端代码如下:

const offscreenCanvas = new OffscreenCanvas(300, 300); let canvas1 = document.getElementById('canvas1'); offscreenCanvas.renderInto(canvas1, ctx => { ctx.fillStyle = '#336699'; ctx.fillRect(0, 0, 100, 100); }); let canvas2 = document.getElementById('canvas2'); offscreenCanvas.renderInto(canvas2, ctx => { ctx.fillStyle = '#990099'; ctx.fillRect(0, 0, 100, 100); });

运行应用

从代码运行效果来看,即先离屏渲染两个图像,然后再分别显示到两个canvas上面。

transferControlToOffscreen

OffscreenCanvas还可以通过CanvastransferControlToOffscreen方法来获取。

let canvas = document.getElementById('canvas'); canvas.width = 500; canvas.height = 500; const offscreenCanvas = canvas.transferControlToOffscreen(); let ctx = offscreenCanvas.getContext('2d'); ctx.fillStyle = '#336699'; ctx.fillRect(0, 0, 100, 100);

渲染完成后,offscreenCanvas自动将渲染结果立即绘制到canvas上。

运行应用

这种方式,与普通的Canvas渲染看似相似。但我们还可以更进一步,将渲染环节交由Web worker来完成。

Offscreen Canvas Web worker

先编写下面代码:

let canvas = document.getElementById('canvas'); const worker = new Worker('canvas-worker.js'); worker.postMessage(canvas);

当我们运行上面的代码,就会出现错误:

DataCloneError: The object can not be cloned.

这是因为WorkerpostMessage方法,只接受能处理structured clone算法的JavaScript对象。Transferable objects支持这种算法。Canvas不属于Transferable objects,而OffscreenCanvas属于Transferable objects。因此,我们需要将代码改为:

let canvas = document.getElementById('canvas'); const offscreenCanvas = canvas.transferControlToOffscreen(); const worker = new Worker('canvas-worker.js'); worker.postMessage(offscreenCanvas, [offscreenCanvas]);

postMessage方法的第2个可选参数是一个数组,里面列出所有需要转移支配权(ownership)的Transferable objects。只有显式地列出这些可转移支配权的对象,才能在不同的线程中安全地交换数据。

在不同线程中转移支配权,意味着在任一时刻,只有在拥有支配权的线程中,我们才能安全地访问该对象。看下面的代码:

let canvas = document.getElementById('canvas'); const offscreenCanvas = canvas.transferControlToOffscreen(); console.log(offscreenCanvas.width); // 300 const worker = new Worker('canvas-worker.js'); worker.postMessage(offscreenCanvas, [offscreenCanvas]); console.log(canvas.width); // 300 console.log(offscreenCanvas.width); // 0

offscreenCanvas转移支配权之前,我们可以访问其width属性值。

而在将offscreenCanvas的主线程的支配权转移至Worker线程后,我们在主线程中若访问其width属性值,将得到错误的数值。除非分支线程重新转移回其支配权。

而由于canvas并非转移其支配权,因此在主线程中我们仍可安全地访问其各个属性值。

下面是canavs-worker.js的内容:

onmessage = (e) => { const offscreenCanvas = e.data; const ctx = offscreenCanvas.getContext('2d'); ctx.fillStyle = '#336699'; ctx.fillRect(0, 0, 100, 100); };

由于我们不需要在主线程中对offscreenCanvas进行进一步的操作,因此分支线程无需返回offscreenCanvas及其支配权。

运行应用

在分支线程中实现动画

主线程代码:

let canvas = document.getElementById('canvas'); const offscreenCanvas = canvas.transferControlToOffscreen(); const worker = new Worker('canvas-animation.js'); worker.postMessage(offscreenCanvas, [offscreenCanvas]);

分支线程代码:

onmessage = (e) => { const offscreenCanvas = e.data; const ctx = offscreenCanvas.getContext('2d'); ctx.fillStyle = 'white'; ctx.font = 'bold 64px Verdana'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; let centerX = offscreenCanvas.width / 2; let centerY = offscreenCanvas.height / 2; let count = 1; let prevTimeStamp = 0; requestAnimationFrame(initFirstFrame); function initFirstFrame(timeStamp) { ctx.fillText(count, centerX, centerY); count++; prevTimeStamp = timeStamp; requestAnimationFrame(render); } function render(timeStamp) { let elapsedTime = timeStamp - prevTimeStamp; if (elapsedTime / 1000 >= 1.0) { ctx.clearRect(0, 0, offscreenCanvas.width, offscreenCanvas.height); ctx.fillText(count, centerX, centerY); count++; prevTimeStamp = timeStamp; } requestAnimationFrame(render); } };

运行应用

参考资源

  1. MDN: Web Workers API
  2. MDN: Using Web Workers
  3. MDN: Navigator: hardwareConcurrency property
  4. MDN: OffscreenCanvas
  5. Github: dom-examples/Web-Worker
  6. WebGL Off the Main Thread