首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

Web单线程的终结者:Web Workers

作者 | Ada Rose Cannon

译者 | 王强

编辑 | Yonie

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

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

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

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

JavaScript 中异步脚本的历史回顾

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

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

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

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

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

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

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

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

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

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

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

来谈谈 Web Workers

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

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

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

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

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

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

示例:https://a-slice-of-pi.glitch.me/

要显示 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/

解决线程的局限性

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

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

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

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

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

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

使用 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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

转换现有代码库以利用 Worker

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

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

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

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

例子

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20190827A0OEFB00?refer=cp_1026
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券