Comlink 简化了 Web Worker 的应用,使它们用起来更加安全,但是也要注意它背后的成本。

我写这篇文章的同时还建了一个 演示网站,网站使用了复杂的物理效果和 SVG 滤镜。它在移动设备上的手感很好,所以需要很流畅地运行才能出效果。

在同一个线程中运行物理效果和 SVG 滤镜开销太大了,所以我把物理效果部分移动到了 Web Worker 中来充分利用资源。

如果你不熟悉并行编程的话,Web Worker 用起来也会很困难。Comlink 这个库可以帮助开发者简化 Worker 的应用过程。本文将讨论与使用 Web Worker 的好处和缺陷,以及优化它们来提升性能的策略。

# JavaScript 中异步脚本的历史回顾

传统的 Web 是单线程的。一条条命令会按顺序执行,完成一条再开始下一条。早年间,就连 XMLHttpRequest 这样长时间运行的命令也可能阻塞主线程,完成后主线程才能解放出来:

var request = new XMLHttpRequest();
request.open('GET', '/bar/foo.txt', false);
request.send(null); // Can take several seconds

由于用户体验不佳,同步的 XMLHttpRequest 已被弃用;但一些较新的 API,比如说访问磁盘存储的 localstorage 也是同步的。它在传统机械硬盘上的延迟可能达到 10 毫秒之多,耗尽我们大部分的帧预算。

同步 API 简化了我们的脚本编写工作,因为程序的状态会随命令编写的顺序改变,在上一条命令完成之前不会发生任何事情。

Web 中的异步 API 是用来访问某些速度较慢的计算机资源的,比如说从磁盘读取、访问网络或周边设备(如网络摄像头或麦克风等)。这些 API 经常依赖事件或回调来处理这些资源。

// The deprecated way of using getUserMedia with callbacks:
function successCallback() {}
navigator.getUserMedia(constraints, successCallback, errorCallback);
// Using events for XMLHttpRequest
// via MDN WebDocs
function reqListener() {}
var oReq = new XMLHttpRequest();
oReq.addEventListener('load', reqListener);
oReq.open('GET', 'http://www.example.org/example.txt');
oReq.send();

Node.js 是服务端 JavaScript 环境,使用了大量异步代码,因为 Node 需要在服务器上高效运行;它不会浪费数百万个 CPU 周期专门等待 IO 操作同步完成。Node 通常使用回调模式进行异步操作。

fs.readFile('/etc/passwd', (error, data) => {
  if (error) throw error;
  console.log(data);
});

虽然回调非常有用,但遗憾的是它们会依赖于先前异步函数的结果,从而散发一些嵌套异步函数的代码味道,导致代码大幅缩进;这被称为“回调金字塔的噩梦”。

为了解决这个问题,比较新的 API 往往既不使用回调也不使用事件,而是使用 Promise。Promise 使用.then 语法使回调看起来更具可读性:

fetch('/data.json')
  .then(response => response.json())
  .then(data => {
    console.log(data);
  });

Promise 的功能和回调是一样的,但前者更具可读性。特别是与 ES2015 的箭头函数结合使用时,我们可以清楚地表达 Promise 中的每一步是怎样转换上一步的输出的。

Promise 的真正优势在于,它们是 EcmaScript 2017 中引入的新 JavaScript 语法——async/await 语法的基础之一。

在 async 函数中,await 语句将暂停函数的执行,直到它们等待的 promise 完成或拒绝。结果代码看起来还是同步的,还可以使用 try/catch 和 for 循环之类的同步构造,但行为却是异步的,不会阻塞主线程!

async function getData() {
  const response = await fetch('data.json');
  const data = await response.json();
  console.log(data);
}
getData();

async 函数将返回一个 promise,它本身可以在其他 async 函数中与 await 并用,我觉得这种设计非常优雅。

# 来谈谈 Web Workers

目前为止我们谈的都是单线程编程。虽然异步代码看起来像是同步运行的,它也实际上在阻止网站其他部分的运行。

通常来说每个网站都运行在一个 CPU 线程上,这个线程负责运行 JavaScript 代码、解析 CSS、处理用户看到的网站布局和绘图。需要运行很长时间的 JavaScript 将阻止线程中的其他所有内容继续工作。如果你的网站过了好久还没开始绘制,这将给用户带来非常糟糕的体验。在过去这甚至可能导致浏览器崩溃,但现代浏览器在这方面的表现要好得多。

为了绕过在单个线程中运行内容的限制,Web 可以通过 Web Worker 来利用多个线程。有几种 Worker 是针对特定应用的(如服务 Worker 和 Worklet),但我们只讨论通用的 Web Worker。

运行下面的代码可以启动一个新的 Web Worker:

const worker = new Worker('/my-worker.js');

它将下载 JavaScript 文件并运行在不同的线程中,使你在不阻塞主线程的前提下运行复杂的 JavaScript 程序。在下面的例子中,我们可以对比分别在主线程和 Worker 中计算 3 万位圆周率的结果。

当它在主线程中计算时,页面的其余部分会停止工作;在 Worker 中计算时页面可以在后台继续运行,直到计算完成。

示例:https://a-slice-of-pi.glitch.me/ (opens new window)

要显示 Worker 的计算结果,必须把结果用一条消息发送给主线程。然后主线程负责显示数字。Worker 本身是无法显示数字的,因为它无法访问主脚本的变量或文档本身,它所能做的只有传回计算的最终结果。

这是线程的性质决定的。你只能访问同一线程内存中的内容。Document 是位于主线程中的,因此 Worker 线程无法对其执行任何操作。

# 究竟线程是什么东西?

当初人们发明了计算机。很多人对此十分不满,认为这是人类迈出的错误一步。
—— Douglas Adams(《银河系漫游指南》作者)

下面来简单介绍一下计算机是如何管理线程和内存的。

早年间的计算机可以一次运行一个进程。每个程序都可以访问用来执行计算的 CPU 资源和用来存储信息的内存资源。

在现代计算模型中,虽然很多程序可以同时并行运行,程序的行为依旧是原来这个样子。每个进程仍然可以使用一个 CPU 并可以访问内存。这也可以防止进程写入其他进程的内存。

计算机的线程数量等于其计算内核的数量,一些英特尔处理器可以在每个内核中运行两个线程。

可以同时存在的线程数与 CPU 和内存的物理现实是分离的,因为计算机可以在内存中存储多个线程,然后在它们之间切换。这称为上下文切换,是一项昂贵的操作;因为它需要清除 CPU 的 L1 到 L3 高速缓存并从内存重新填充它们。这可能需要花费 100ns 左右!看起来好像很快,但这已经相当于 100 个 CPU 时钟周期了,因此应尽可能避免。

此外,程序可以使用的内存数量并不等同于机器中物理存在的内存容量,因为操作系统可以使用硬盘交换空间来假装有几乎无限的内存,只是交换内存的部分速度很慢。

对现代硬件来说程序尽可能使用多个线程是很有意义的,因为单个 CPU 核心的速度很难继续增长了,取而代之的是单个芯片上的 CPU 内核数量不断增加。

虽然在传统的台式 / 服务器计算机中各个处理核心几乎没有区别,但现代移动芯片通常包含功率有高有低的多个处理器核心以增加电池寿命并加强散热能力。即使你的手机上有一颗非常强大的 CPU 核心,但它持续全速工作的时间可能会很短,以避免芯片过热。

我手机中的 Exynos 9820 芯片的架构如下图所示,其 CPU 部分有两个大核心、两个中核心和四个小核心。

https://www.samsung.com/semiconductor/minisite/exynos/products/mobileprocessor/exynos-9-series-9820/ (opens new window)

# 解决线程的局限性

虽然不同的线程不能共享内存,但它们仍然可以相互通信以交换信息。这个 API 是基于事件的,每个线程都会侦听 message 事件,并可以使用 postMessage API 发送消息。

除了字符串之外,还可以使用 postMessage 共享许多类型的数据结构,例如数组和对象等。发送这些数据时,浏览器以特殊的序列化格式制作数据结构的副本,然后在另一个线程中重建:

// In the worker:
self.postMessage(someObject);
// In the main thread:
worker.addEventListener('message', msg => console.log(msg.data));

在上面的示例中,对象 someObject 被克隆并变成可传递的形式,这个过程称为序列化。然后主线程会接收它并转换成原始对象的副本。这可能是一项开销巨大的操作,但没有它就没法维持复杂的数据结构了。

需要传输大量数据时你可以传输一块内存,可以通过这种方式传输的对象称为 可传递对象。最常见的为共享数据而传递的对象类型是 ArrayBuffer。

ArrayBuffer 是类型化数组 API 的一部分。你不能直接写入 ArrayBuffer,而需要使用类型化的数组来读取和写入。类型化数组将 JavaScript 数字转换为存储在数组缓冲区中的原始数据。

你还可以创建具有已定义大小的新类型化数组,它将分配一块新内存以适应这个大小值。这块内存由底层的 ArrayBuffer 表示,并暴露为.buffer,这个 ArrayBuffer 实例可以在线程之间传输以共享内容。

// In the worker:
const buffer = new ArrayBuffer(32); // 32 Bytes
>> ArrayBuffer { byteLength: 32 }
const array = new Float32Array(buffer);
>> Float32Array [ 0, 0, 0, 0, 0, 0, 0, 0 ]; // 4 Bytes per element, so 8 elements long.
array[0] = 1;
array[1] = 2;
array[2] = 3;
self.postMessage(array.buffer, [array.buffer]);

使用 postMessage 传输 ArrayBuffer 时要小心。一旦它被传输后,它在原始线程中就不能再读取或写入了,并且如果你尝试使用它将抛出错误。

ArrayBuffer 与数据无关,它们只是内存块。他们不关心自己存储的是什么样的数据。因此你可以使用单个 ArrayBuffer 来存储大量不同类型的较小数据块。

所以如果你需要 ArrayBuffer 的效率,同时也需要处理复杂的数据结构,那么你就可以小心地使用单个 ArrayBuffer。我写了一篇 在 ArrayBuffer 中存储稍复杂结构的文章,详细介绍了如何在单个 ArrayBuffer 中存储不同类型的数字: https://medium.com/samsung-internet-dev/being-fast-and-light-using-binary-data-to-optimise-libraries-on-the-client-and-the-server-5709f06ef105 (opens new window)

你可以使用 postMessage 来回发送消息并使用事件来响应。不幸的是,在现实世界中这种方法用起来很麻烦,因为想要跟踪哪个响应对应于哪些消息,对于不常见的用例是很难做到的。

使用 Worker 在理想情况下可以给我们带来很大的性能提升,所谓理想情况是指在不同处理器上运行的线程之间可以高效通信。

我们无法控制操作系统选择在哪个物理处理器上运行进程,也无法控制用户可能正在运行的其他应用程序。因此可能存在这样的情况:Worker 和主线程都在同一物理处理器上运行,这就意味着 Worker 需要上下文切换才能开始执行。可能还存在这样的情况:Worker 不是该 CPU 核心上的最高优先级进程,因此 Worker 线程可能会在内存中等待,而其他任务继续工作。

# 让开发人员更容易地使用多线程技术

所幸谷歌的 Surma 开发了一个令人赞叹的 JS 库,将这种消息来往转换成了基于 Promise 的异步 API!这个库名为 Comlink,体积非常小,但大大简化了 Worker 的消息循环处理工作,

在下面的示例中,我们把从 Worker 中暴露的类实例化为新对象,然后从中调用一些方法。在原始类中这些方法完全是同步的,但因为向 Worker 发送并接收消息需要时间,所以 Comlink 返回一个 Promise 取而代之。

还好我们可以用 async/await 语法编写看起来像是同步的异步代码,因此代码看起来仍然非常整洁和同步。

import { wrap } from '/comlink/comlink.js';
// This web worker uses Comlink's expose to expose a function
const MyMathLibrary = wrap(new Worker('/mymath.js'));
async function main() {
  const myMath = await new MyMathLibrary();
  const result1 = await myMath.add(2, 2);
  const result2 = await myMath.add(3, 7);
  return await myMath.multiply(result1, result2);
}

注意

Comlink 简化了使用 Worker 的过程,但它也隐藏了来回发送数据的成本!在 main 中的这几行代码包括了 Worker 之间前后发送的 6 条消息,每条消息都要等上一条完成后才会发送。每次发送消息时都必须对数据进行序列化和重构,并且可能需要进行上下文切换才能完成响应。

在理想情况下,另一个线程会运行在另一个 CPU 内核上等待一些输入,一切都有条不紊地推进。但如果线程没有主动工作,那么 CPU 可能必须从内存中恢复它,速度可能会很慢。我们无法控制操作系统何时切换线程,但如果阻止代码执行,直到另一个线程中的代码执行完毕后才继续,那么就可能要等待 100 纳秒的时间。

写出清晰易读和代码总归是好事情,但我们必须警惕性能的负面影响。我们能做的一项改进是并行计算 result1 和 result2 来提升性能,但代码就不会那么简洁了。

// This web worker uses Comlink's expose to expose a function
const MyMathLibrary = proxy(new Worker('/mymath.js'));
async function main() {
  const myMath = await new MyMathLibrary();
  const [result1, result2] = await Promise.all([myMath.add(2, 2), myMath.add(3, 7)]);
  return await myMath.multiply(result1, result2);
}

使用 Comlink 可以带来的另一大性能提升是利用 ArrayBuffer 之类的可传递对象,不用再复制它们。这会显著提升性能,但用的时候也要小心,因为一旦它们被传递后就不能在原始线程中使用了。

如果你正在程序中使用可传递对象,那么传递后就把它们移出范围,以免不小心再去读取它们的数据。

const data = [1, 2, 3, 4];
await (function() {
  const toSend = Int16Array.from(data);
  return myMath.addArray(Comlink.transfer(toSend.buffer, [toSend.buffer]));
})();

传递函数是用来包装你发送的内容的,同时标记在第二个参数数组中可传输的数据。上面的示例中我发送 toSend.buffer 并告诉 Comlink 它可以传递而非复制。

记得在你的 Worker 中处理缓冲区的问题:

addArray(array) {
array = array.constructor === ArrayBuffer ?
new Int16Array(array) :
array;

优化 Comlink 代码时,请注意平衡性能和代码易读性。这些优化可以为你提供 10 纳秒或 100 纳秒的性能改进,对用户来说没那么明显,除非很多优化同时使用。优化太多的代码也更难阅读,可能会让你更难诊断错误。

# 转换现有代码库以利用 Worker

Comlink 的一大好处是它让开发人员可以方便地把一部分应用放到 Worker 中,而无需对代码库做大幅度改动。

你要做的工作主要是把同步函数转换为异步函数,后者 await 从 Worker 暴露的 api。

但是简单地把代码都移到 Worker 里并不是什么银弹。

你的帧速率可能会略有提高,因为主线程的负担减轻了不少;但如果有大量的消息来回传递,你可能会发现实际工作消耗的时间反而更久了。

# 例子

我写了一个演示,结合了 Verlet 集成与 SVG 创建出晃来晃去的界面。相关链接: https://mind-map.glitch.me/。 (opens new window)

Verlet 集成是一个基于一些点和约束条件的简单物理模型。每一帧都需要为运动部件做一次新的物理计算。

我的演示还使用了一个复杂的 SVG 滤镜来为 DOM 元素生成一个好看的特效。这个滤镜在主线程上消耗了很多 CPU 计算资源。

它一开始运行得很顺利,但后来应用程序的 Verlet 集成需要计算很多点,此时执行 Verlet 集成物理运算和渲染 SVG 所花费的时间就要比每帧的显示时间(16ms)更长了。

我以为把 Verlet 集成的代码移动到 Web Worker 中就行,这部分代码会 await 每个 API 调用。

然后我测试应用程序时发现卡顿消失也不跳帧了,但是每次物理计算花费的时间变长了很多,显示出来的效果也不对劲了。

我使用 Chrome 的性能选项卡来测量 CPU 的占用率,令我惊讶的是 CPU 大部分时间处于空闲状态!发生了什么事?!

问题在于必须在 for 循环中多次切换线程。在线程之间切换时,计算机可能需要从内存中获取信息以填充缓存,这一过程就比较慢了。

// slow
for (let i = 0; i < 100; i++) await point.doPhysics(i);

我没那么多时间来优化代码,而且优化过的代码往往没那么容易看懂。我得把重点放在运行最频繁且对用户体验影响最大的代码上。

下面是我优化的顺序:

  1. PointerMove 事件中的循环(运行速度超过 60fps)。
  2. 请求动画帧中的循环(60fps)。
  3. 阻止应用启动的循环,优化它来改善用户体验。
  4. PointerMove 事件。
  5. 请求动画帧。

最重要的是要测量每次改动的结果,否则你没法知道是不是修复了问题,修复了多少,甚至可能在不知不觉中做出更糟糕的事

可以用性能 API 来提供准确的计时数据,从而查看某些代码运行所需的时间。

performance.clearMarks('start-doing-thing');
performance.clearMarks('end-doing-thing');
performance.clearMeasures('Time to do things');
performance.mark('start-doing-thing');
for (let i = 0; i < 100; i++) await myMath.doThing(i);
performance.mark('end-doing-thing');
performance.measure('Time to do things', 'start-doing-thing', 'end-doing-thing');

一旦你测出了要优化的代码并确认它确实是性能问题的根源,就可以开始优化它了。优化一段代码可以有这样几种方法:

  • 删除它,你真的需要它吗?

  • 缓存它,旧数据有什么用? (可能会引入意外错误。)

  • 使计算并行运行(这没关系):

arrayOfPromises = [];
for (let i = 0; i < 100; i++) arrayOfPromises[i] = myMath.doThing(i);
const results = await Promise.all(arrayOfPromises);
  • 更改你的 API 以批量接收输入(这样更好):
arrayOfArguments = [];
for (let i = 0; i < 100; i++) arrayOfArguments[i] = i;
const results = await myMath.doManyThings(arrayOfArguments);

如果你真的更改了 API 以处理批量输入,那么发送和返回 ArrayBuffer 就能进一步提升效率,因为结果或参数本身可能是一个非常大的数值数组。

谨记!在线程之间传输可传递对象时要非常小心,因为当它们跑到另一个线程中后就不再可用了。

# 最重要的事情

编写跨两个线程异步运行的代码是一个难题。很容易出现一些意外错误,因为你的代码是并行运行的,可事情不会按照你期望的顺序发生。

在开发人员的开发体验和应用程序的性能之间做好取舍是非常重要的。不要过度优化对最终用户没什么影响的代码。

在下面的示例中,第一个版本更加简洁,开发人员更容易理解,也没比第二个版本慢很多。

const handle = await teapot.handle();
const spout = await teapot.spout();
const lid = await teapot.lid();
vs;
const [handle, spout, lid] = await Promise.all([teapot.handle(), teapot.spout(), teapot.lid()]);

应该只在性能表现很重要时做优化,例如循环、快速触发事件(如滚动或鼠标移动)或请求动画帧这些情况。

在优化之前先做测量,确保你没有浪费时间优化用户根本注意不到的事情。应用程序启动时多 200 毫秒可能没人会注意,但多出来 2000 毫秒就是另一回事了。

过早优化可能会引入意外的错误,因为代码很难阅读也很难查错。

尽管异步代码很难编写,但它也有自己的价值,有时可以用来为你的应用程序提供令人难以置信的流畅体验。它不是必不可少的功能,而是 Web 性能工具箱中的一项工具。

# PostScript:关于性能的有趣注释

在考虑性能问题时,最重要的一步是找出瓶颈所在并测量性能。下表是一个方便的指南,帮助你找出性能问题的根源所在。

这个不同类型的 IO 操作时间指南比较粗略,但也足够用了;我们的目标是提供每秒 60 帧的流畅体验,在这样的预算限制下做优化。

+---------------------------------------+-----------+
| Type | Time/ns |
+---------------------------------------+-----------+
| One frame at 60fps (16ms) | 16000000 |
| Accessing the disk (spinning platter) | 10000000 |
| Accessing the disk (solid state) | 150000 |
| Accessing memory | 100 |
| Accessing L3 cpu cache | 28 |
| Accessing L2 cpu cache | 3 |
| Accessing L1 cpu cache | 1 |
| 1 CPU cycle | 0.5 |
+---------------------------------------+-----------+

https://www.prowesscorp.com/computer-latency-at-a-human-scale/ (opens new window)

有趣的是,光在 1 纳秒时间内可以移动 30 厘米左右,因此在大约 0.5 个 CPU 周期内信号只能行进大约 15 厘米,和常见的手机一样长。

Web 单线程的终结者:Web Workers (opens new window)