2021 Web Worker 需要了解的现状

1,050 阅读23分钟

前言

导读: Web 是单线程的。这让编写流畅又灵敏的应用程序变得越来越困难。Web Worker 的名声很臭,但对 Web 开发者来说,它是解决流畅度问题的 一个非常重要的工具。让我们来了解一下 Web Worker 吧。

我们总是把 Web 和 所谓的 “Native” 平台(比如 Android 和 iOS)放在一起比较。Web 是流式的,当你第一次打开一个应用程序时,本地是不存在任何可用资源的。这是一个根本的区别,这使得很多在 Native 上可用的架构 无法简单应用到 Web 上。

不过,不管你在关注在什么领域,都一定使用过或了解过 多线程技术。iOS 允许开发者 使用 Grand Central Dispatch 简单的并行化代码,而 Android 通过 新的统一任务调度器 WorkManager 实现同样的事情,游戏引擎 Unity 则会使用 job systems 。我上面列举的这些平台不仅支持了多线程,还让多线程编程变得尽可能简单。

在这篇文章,我将概述为什么我认为多线程在 Web 领域很重要,然后介绍作为开发者的我们能够使用的多线程原语。除此之外,我还会谈论一些有关架构的话题,以此帮助你更轻松的实现多线程编程(甚至可以渐进实现)。

无法预测的性能问题

我们的目标是保持应用程序的 流畅(smooth) 和 灵敏(responsive)。流畅 意味着 稳定 且 足够高 的 帧率。灵敏 意味着 UI 以最低的延迟 响应 用户交互。两者是保持应用程序 优雅 和 高质量 的 关键因素。

按照 RAIL 模型,灵敏 意味着响应用户行为的时间控制在 100ms 内,而 流畅 意味着屏幕上任何元素移动时 稳定在 60 fps。所以,我们作为开发者 拥有 1000ms/60 = 16.6ms 的时间 来生成每一帧,这也被称作 “帧预算”(frame budget)。

我刚刚提到了 “我们”,但实际上是 “浏览器” 需要 16.6ms 的时间 去完成 渲染一帧背后的所有工作。我们开发者仅仅直接负责 浏览器实际工作 的一部分。浏览器的工作包括(但不限于):

  • 检测 用户操作的 元素(element)
  • 发出 对应的事件
  • 运行相关的 JavaScript 时间处理程序
  • 计算 样式
  • 进行 布局(layout)
  • 绘制(paint)图层
  • 将这些图层合并成一张 最终用户在屏幕上看到的 图片
  • (以及更多…)

好大的工作量啊。

另一方面,“性能差距” 在不断扩大。旗舰手机的性能随着 手机产品的更新换代 变得越来越高。而低端机型正在变得越来越便宜,这使得之前买不起手机的人能够接触到移动互联网了。就性能而言,这些低端手机的性能相当于 2012 年的 iPhone。

为 Web 构建的应用程序会广泛运行在性能差异很大的不同设备上。JavaScript 执行完成的时间取决于 运行代码设备有多快。不光是 JavaScript,浏览器执行的其他任务(如 layout 和 paint)也受制于设备的性能。在一台现代的 iPhone 上运行只需要 0.5ms 的任务 可能 到了 Nokia 2 上需要 10ms。用户设备的性能是完全无法预测的。

注:RAIL 作为一个指导框架至今已经 6 年了。你需要注意一点,实际上 60fps 只是一个占位值,它表示的是用户的显示设备原生刷新率。例如,新的 Pixel 手机 有 90Hz 的屏幕 而 iPad Pro 的屏幕是 120Hz 的,这会让 帧预算 分别减少到 11.1ms 和 8.3ms。

更复杂的是,除了测算 requestAnimationFrame() 回调之间的时间,没有更好的方法来确定运行 app 设备的刷新率

JavaScript

JavaScript 被设计成 与浏览器的主渲染循环同步运行。几乎所有的 Web 应用程序都会遵循这种模式。这种设计的缺点是:执行缓慢的 JavaScript 代码会阻塞浏览器渲染循环。JavaScript 与浏览器的主渲染循环 同步运行可以理解为:如果其中一个没有完成,另一个就不能继续。为了让长时间的任务能在 JavaScript中 协调运行,一种基于 回调 以及 后来的 Promise 的 异步模型被建立起来

为了保持应用程序的 流畅,你需要保证你的 JavaScript 代码运行 连同 浏览器做的其他任务(样式、布局、绘制…)的时间加起来不超出设备的帧预算。为了保持应用程序的 灵敏,你需要确保任何给定的事件处理程序不会花费超过 100ms 的时间,这样才能及时在设备屏幕上展示变化。在开发中,即使用自己的设备实现上面这些已经很困难了,想要在所有的设备都上实现这些几乎是不可能的。

通常的建议是 “做代码分割(chunk your code)”,这种方式也可以被称作 “出让控制权(yield)给浏览器”。其根本的原理是一样的:为了给浏览器一个时机来进入下一帧,你需要将代码分割成大小相似的块(chunk),这样一来,在代码块间切换时 就能将控制权交还给 浏览器 来做渲染。

有很多种“出让控制权(yield)给浏览器” 的方法,但是没有那种特别优雅的。最近提出的 任务调度 API 旨在直接暴露这种能力。然而,就算我们能够使用 await yieldToBrowser() (或者类似的其他东西) 这样的 API 来 出让控制权,这种技术本身还是会存在缺陷:为了保证不超出帧预算,你需要在足够小的块(chunk)中完成业务,而且,你的代码每一帧至少要 出让一次控制权。

过于频繁的出让控制权 的 代码 会导致 调度任务的开销过重,以至于对应用程序整体性能产生负面影响。再综合一下我之前提到的 “无法预测的设备性能”,我们就能得出结论 — 没有适合所有设备的块(chunk)大小。当尝试对 UI 业务进行 “代码分割” 时,你就会发现这种方式很成问题,因为通过出让控制权给浏览器来分步渲染完整的 UI 会增加 布局 和 绘制 的总成本。

Web Workers

有一种方法可以打破 与浏览器渲染线程同步的 代码执行。我们可以将一些代码挪到另一个不同的线程。一旦进入不同的线程,我们就可以任由 持续运行的 JavaScript 代码 阻塞,而不需要接受 代码分割出让控制权 所带来的 复杂度 和 成本。使用这种方法,渲染进程甚至都不会注意到另一个线程在执行阻塞任务。在 Web 上实现这一点的 API就是 Web Worker。通过传入一个独立的 JavaScript 文件路径 就可以 创建一个 Web Worker,而这个文件将在新创建的线程里加载和运行。

const worker = new Worker("./worker.js");

在我们深入讨论之前,有一点很重要,虽然 Web Workers, Service Worker 和 Worklet 很相似,但是它们完全不是一回事,它们的目的是不同的:

  • 在这篇文章中,我只讨论 Web Workers (经常简称为 “Worker”)。Worker 就是一个运行在 独立线程里的 JavaScript 作用域。Worker 由一个页面生成(并所有)。
  • ServiceWorker 是一个 短期的 ,运行在 独立线程里的 JavaScript 作用域,作为一个 代理(proxy)处理 同源页面中发出的所有网络请求。最重要的一点,你能通过使用 Service Worker 来实现任意的复杂缓存逻辑。除此之外,你也可以利用 Service Worker 进一步实现 后台长请求,消息推送 和 其他那些无需关联特定页面的功能。它挺像 Web Worker 的,但是不同点在于 Service Worker 有一个特定的目的 和 额外的约束。
  • Worklet 是一个 API 收到严格限制的 独立 JavaScript 作用域,它可以选择是否运行在独立的线程上。Worklet 的重点在于,浏览器可以在线程间移动 Worklet。AudioWorkletCSS Painting APIAnimation Worklet 都是 Worklet 应用的例子。
  • SharedWorker 是特殊的 Web Worker,同源的多个 Tab 和 窗口可以引用同一个 SharedWorker。这个 API 几乎不可能通过 polyfill 的方式使用,而且目前只有 Blink 实现过。所以,我不会在本文中深入介绍。

JavaScript 被设计为和浏览器同步运行,也就是说没有并发需要处理,这导致很多暴露给 JavaScript 的 API 都不是 线程安全 的。对于一个数据结构来说,线程安全意味着它可以被多个线程并行访问和操作,而它的 状态(state)不会 被破坏(corrupted)。

这一般通过 互斥锁(mutexes) 实现。当一个线程执行操作时,互斥锁会锁定其他线程。浏览器 和 JavaScript 引擎 因为不处理锁定相关的逻辑,所以能够做更多优化来让代码执行更快。另一方面,没有锁机制 导致 Worker 需要运行在一个完全隔离的 JavaScript 作用域,因为任何形式的数据共享都会 因缺乏线程安全 而产生问题。

虽然 Worker 是 Web 的 “线程”原语 ,但这里的 “线程” 和在 C++,Java 及其他语言中的非常不同。最大的区别在于,依赖于隔离环境 意味着 Worker 没有权限 访问其创建页面中其他变量和代码,反之,后者也无法访问 Worker 中的变量。数据通信的唯一方式就是调用 API postMessage,它会将传递信息复制一份,并在接收端 触发 message 事件。隔离环境也意味着 Worker 无法访问 DOM,在Worker 中也就无法更新 UI — 至少在没有付出巨大努力的情况下(比如 AMP 的 worker-dom)。

浏览器对 Web Worker 的支持可以说是普遍的,即使是 IE10 也支持。但是,Web Worker 的使用率依旧偏低,我认为这很大程度上是由于 Worker API 特殊的设计。

JavaScript 的并发模型

想要应用 Worker ,那么就需要对应用程序的架构进行调整。JavaScript 实际上支持两种不同的并发模型,这两种模型通常被归类为 “Off-Main-Thread 架构”(脱离主线程架构)。这两种模型都会使用 Worker,但是有非常不同的使用方式,每种方式都有自己的权衡策略。这两种模型了代表解决问题的两个方向,而任何应用程序都能在两者之间找到一个更合适的。

并发模型 #1:Actor

我个人倾向于将 Worker 理解为 Actor 模型 中的 Actor。编程语言 Erlang 中对于 Actor 模型 的实现可以说是最受欢迎的版本。每个 Actor 都可以选择是否运行在独立的线程上,而且完全保有自己操作的数据。没有其他的线程可以访问它,这使得像 互斥锁 这样的渲染同步机制就变得没有必要了。Actor 只会将信息传播给其他 Actor 并 响应它们接收到的信息。

例如,我会把 主线程 想象成 拥有并管理 DOM 或者说是 全部 UI 的 Actor。它负责更新 UI 和 捕获外界输入的事件。还会有一个 Actor 负责管理应用程序的状态。DOM Actor 将低级的输入事件 转换成 应用级的语义化的事件,并将这些事件传递给 状态 Actor 。状态 Actor 按照接收到的事件 修改 状态对象,可能会使用一个状态机 甚至涉及其他 Actor。一旦状态对象被更新,状态 Actor 就会发送一个 更新后状态对象的拷贝 到 DOM Actor。DOM Actor 就会按照新的状态对象更新 DOM 了。Paul Lewis 和 我 曾经在 2018 年的 Chrome 开发峰会上探索过以 Actor 为中心的应用架构

当然,这种模式也不是没有问题的。例如,你发送的每一条消息都需要被拷贝。拷贝所花的时间不仅取决于 消息的大小,还取决于当前应用程序的运行情况。根据我的经验,postMessage 通常 “足够快”,但在某些场景确实不太行。另一个问题是,将代码迁移到 Worker 中可以解放 主线程,但同时不得不支付通信的开销,而且 Worker 可能会在响应你的消息之前忙于执行其他代码,我们需要考虑这些问题来做一个平衡。一不小心,Worker 可能会给 UI 响应带来负面影响。

通过 postMessage 可以传递非常复杂的消息。其底层算法(叫做 “结构化克隆”)可以处理 内部带有循环的数据结构 甚至是 MapSet 。然而,他不能处理 函数 或者 类,因为这些代码在 JavaScript 中无法跨作用域共享。有点恼人的是,通过 postMessage 传一个 函数 会抛出一个 错误,然而一个类被传递的话,只会被静默的转换为一个普通的 JavaScript 对象,并在此过程中丢失所有方法(这背后的细节是有意义的,但是超出了本文讨论的范围)。

另外,postMessage 是一种 “Fire-and-Forget” 的消息传递机制,没有请求 和 响应 的概念。如果你想使用 请求/响应 机制(根据我的经验,大多数应用程序架构都会最终让你不得不这么做),你必须自己搞定。这就是我写了 Comlink 的原因,这是一个底层使用 RPC 协议的库,它能帮助实现 主线程 和 Worker 互相访问彼此对象。使用 Comlink 的时候,你完全不用管 postMessage。唯一需要注意的一点是,由于 postMessage 的异步性,函数并不会返回结果,而是会返回一个 promise。在我看来,Comlink 提炼了 Actor 模式 和 共享内存 两种并发模型中优秀的部分 并 提供给用户。

Comlink 并不是魔法,为了使用 RPC 协议 还是需要使用 postMessage。如果你的应用程序最终罕见的由于 postMessage 而产生瓶颈,那么你可以尝试利用 ArrayBuffers 可 被转移(transferred) 的特性。转移 ArrayBuffer 几乎是即时的,并同时完成所有权的转移:在这个过程中 发送方的 JavaScript 作用域会失去对数据的访问权。当我实验在主线程之外运行 WebVR 应用程序的物理模拟时,用到了这个小技巧。

并发模型 #2:共享内存

就像我之前提到的,传统的线程处理方式是基于 共享内存 的。这种方式在 JavaScript 中是不可行的,因为几乎所有的 JavaScript API 都是假定没有并发访问对象 来设计的。现在要改变这一点要么会破坏 Web,要么会由于目前同步的必要性导致重大的性能损耗。相反,共享内存 这个概念目前被限制在一个专有类型:SharedArrayBuffer (或简称 SAB)。

SAB 就像 ArrayBuffer,是线性的内存块,可以通过 Typed ArrayDataView 来操作。如果 SAB 通过 postMessage 发送,那么另一端不会接收到数据的拷贝,而是收到完全相同的内存块的句柄。在一个线程上的任何修改 在其他所有线程上都是可见的。为了让你创建自己的 互斥锁 和 其他的并发数据结构,Atomics 提供了各种类型的工具 来实现 一些原子操作 和 线程安全的等待机制。

SAB 的 缺点是多方面的。首先,也是最重要的一点,SAB 只是一块内存。SAB 是一个非常低级的原语,以增加 工程复杂度 和 维护复杂度 作为成本,它提供了高灵活度 和 很多能力。而且,你无法按照你熟悉的方式去处理 JavaScript 对象 和 数组。它只是一串字节。

为了提升这方面的工作效率,我实验性的写了一个库 buffer-backed-object。它可以合成 JavaScript 对象,将对象的值持久化到一个底层缓冲区中。另外,WebAssembly 利用 Worker 和 SharedArrayBuffer 来支持 C++ 或 其他语言 的线程模型。WebAssembly 目前提供了实现 共享内存并发 最好的方案,但也需要你放弃 JavaScript 的很多好处(和舒适度)转而使用另一种语言,而且通常这都会产出更多的二进制数据。

案例研究: PROXX

在 2019 年,我和我的团队发布了 PROXX,这是一个基于 Web 的 扫雷游戏,专门针对功能机。功能机的分辨率很低,通常没有触摸界面,CPU 性能差劲,也没有凑乎的 GPU。尽管有这么多限制,这些功能机还是很受欢迎,因为他们的售价低的离谱 而且 有一个功能完备的 Web 浏览器。因为功能机的流行,移动互联网得以向那些之前负担不起的人开放。

为了确保这款游戏在这些功能机上灵敏流畅运行,我们使用了一种 类 Actor 的架构。主线程负责渲染 DOM(通过 preact,如果可用的话,还会使用 WebGL)和 捕捉 UI 事件。整个应用程序的状态 和 游戏逻辑 运行在一个 Worker 中,它会确认你是否踩到雷上了,如果没有踩上,在游戏界面上应该如何显示。游戏逻辑甚至会发送中间结果到 UI 线程 来持续为用户提供视觉更新。

其他好处

我谈论了 流畅度 和 灵敏度 的重要性,以及如何通过 Worker 来更轻松的实现这些目标。另外一个外在的好处就是 Web Worker 能帮助你的应用程序消耗更少的设备电量。通过并行使用更多的 CPU 核心,CPU 会更少的使用 “高性能” 模式,总体来说会让功耗降低。来自微软的 David Rousset 对 Web 应用程序的功耗进行了探索

采用 Web Worker

如果你读到了这里,希望你已经更好的理解了 为什么 Worker 如此有用。那么现在下一个显而易见的问题就是:怎么使用。

目前 Worker 还没有被大规模使用,所以围绕 Worker 也没有太多的实践和架构。提前判断代码的哪些部分值得被迁移到 Worker 中是很困难的。我并不提倡使用某种特定的架构 而抛弃其他的,但我想跟你分享我的做法,我通过这种方式渐进的使用 Worker,并获得了不错的体验:

大多数人都使用过 模块 构建应用程序,因为大多数 打包器 都会依赖 模块 执行 打包 和 代码分割。使用 Web Worker 构建应用程序最主要的技巧就是将 UI 相关 和 纯计算逻辑 的代码 严格分离。这样一来,必须存在于主线程的模块(比如调用了 DOM API 的)数量就能减少,你可以转而在 Worker 中完成这些任务。

此外,尽量少的依靠同步,以便后续采用诸如 回调 和 async/await 等异步模式。如果实现了这一点,你就可以尝试使用 Comlink 来将模块从主线程迁移到 Worker 中,并测算这么做是否能够提升性能。

现有的项目想要使用 Worker 的话,可能会有点棘手。花点时间仔细分析代码中那些部分依赖 DOM 操作 或者 只能在主线程调用的 API。如果可能的话,通过重构删除这些依赖关系,并渐近的使用上面我提出的模型。

无论是哪种情况,一个关键点是,确保 Off-Main-Thread 架构 带来的影响是可测量的。不要假设(或者估算)使用 Worker 会更快还是更慢。浏览器有时会以一种莫名其妙的方式工作,以至于很多优化会导致反效果。测算出具体的数字很重要,这能帮你做出一个明智的决定!

Web Worker 和 打包器(Bundler)

大多数 Web 现代开发环境都会使用打包器来显著的提升加载性能。打包器能够将多个 JavaScript 模块打包到一个文件中。然而,对于 Worker,由于它构造函数的要求,我们需要让文件保持独立。我发现很多人都会将 Worker 的代码分离并编码成 Data URL 或 Blob URL,而不是选择在 打包器 上下功夫来实现需求。Data URL 和 Blob URL 这两种方式都会带来大问题:Data URL 在 Safari 中完全无法工作,Blob URL 虽说可以,但是没有 源(origin) 和 路径 的概念,这意味路径的解析和获取无法正常使用。这是使用 Worker 的另一个障碍,但是最近主流的打包器在处理 Worker 方面都已经加强了不少:

  • Webpack :对于 Webpack v4,worker-loader 插件让 Webpack 能够理解 Worker。而从 Webpack v5 开始,Webpack 可以自动理解 Worker 的构造函数,甚至可以在 主线程 和 Worker 之间共享模块 而 避免重复加载。
  • Rollup : 对于 Rollup,我写过 rollup-plugin-off-main-thread ,这个插件能让 Worker 变得开箱即用
  • Parcel : Parcel 值得特别提一下,它的 v1 和 v2 都支持 Worker 的开箱即用,无需额外配置。

在使用这些打包器开发应用程序时,使用 ES Module 是很常见的。然而,这又会带来新问题。

Web Worker 和 ES Module

所有的现代浏览器都支持通过 <script type="module" src="file.js"> 来运行 JavaScript 模块。Firefox 之外的所有现代浏览器现在也都支持对应 Worker 的一种写法:new Worker("./worker.js", {type: "module"}) 。Safari 最近刚开始支持,所以考虑如何支持稍老一些的浏览器是很重要的。幸运的是,所有的打包器(配合上面提到的插件)都会确保你模块的代码运行在 Worker 中,即使浏览器不支持 Module Worker。从这个意义上来说,使用打包器可以被看作是对 Module Worker 的 polyfill。

未来

我喜欢 Actor 模式。但在 JavaScript 中的并发 设计的并不是很好。我们构建了很多的 工具 和 库 来弥补,但终究这是 JavaScript 应该在语言层面上去完成的。一些 TC39 的工程师对这个话题很感兴趣,他们正尝试找到让 JavaScript 更好的支持这两种模式的方式。目前多个相关的提案都在评估中,比如 允许代码被 postMessage 传输,比如 能够使用 高阶的,类似调度器的 API (这在 Native 上很常见) 来在线程间共享对象。

这些提案目前没还有在 标准化流程中 取得非常重大的进展,所以我不会在这里花时间深入讨论。如果你很好奇,你可以关注 TC39 提案 ,看看下一代的 JavaScript 会包含哪些内容。

总结

Worker 是保证主线程 灵敏 和 流畅 的关键工具,它通过防止长时间运行代码阻塞浏览器渲染来保证这一点。由于和 Worker 通信 存在 内在的异步性,所以采用 Worker 需要对应用程序的架构进行一些调整,但作为回报,你能更轻松的支持各种性能差距巨大的设备来访问。

你应该确保使用一种 方便迁移代码的架构,这样你就能 测算 非主线程架构 带来的性能影响。Web Worker 的设计会导致一定的学习曲线,但是最复杂的部分可以被 Comlink 这样的库抽象出来。


FAQ

总会有人提出一些常见的问题和想法,所以我想先发制人,将我的答案记录在这里。

postMessage 不慢吗?

我针对所有性能问题的核心建议是:先测算!在你测算之前,没有快慢一说。但根据我的经验,postMessage 通常已经 “足够快” 了。这是我的一个经验法则:如果 JSON.stringify(messagePayload) 的参数小于 10kb,即使在速度最慢的手机上,你也不用担心会导致卡帧。如果 postMessage 真的成为了你应用程序中的瓶颈,你可以考虑下面的技巧:

  • 将你的任务拆分,这样你就可以发送更小的信息
  • 如果消息是一个状态对象,其中只有很小一部分发生改变,那就只发送变更的部分而不是整个对象
  • 如果你发送了很多消息,你可以尝试将多条消息整合成一条
  • 最终手段,你可以尝试将你的信息转化为 数字表示,并转移ArrayBuffers 而不是 基于对象的消息

我想从 Worker 中访问 DOM

我收到了很多类似这样的反馈。然而,在大多数情况下,这只是把问题转移了。你有也许能有效地创建第二个主线程,但你还会遇到相同的问题,区别在于这是在不同的线程中。为了让 DOM 在多线程中安全访问,就需要增加锁,这将导致 DOM 操作的速度降低,还可能会损害很多现有的 Web 应用。

另外,同步模型其实也是有优点的。它给了浏览器一个清晰的信号 — 什么时候 DOM 处于可用状态,能够被渲染到屏幕上。在一个多线程的 DOM 世界,这个信号会丢失,我们就不得不手动处理 部分渲染的逻辑 或是 什么其他的逻辑。

我真的不喜欢为了使用 Worker 把我的代码拆分成独立的文件

我同意。TC39 中有一些提案正在被评议,为了能够将一个模块内联到另一个模块中,而不会像 Data URL 和 Blob URL 一样有那么多小问题。虽然目前还没有一个令人满意的解决方案,但是未来 JavaScript 肯定会有一次迭代解决这个问题。

补充总结说明

列举一些目前笔者使用Worker的场景:

  1. 当你的算法程序逻辑的时间相对长(超出了"帧预算"),且阻碍了渲染引擎。
  2. 当你想要尝试并发的设计模式
  3. 任务调度架构设计的调整(JS种种实现的调度机制可能并不是最优)
  4. ... 渲染和计算的完全解耦,计算要合理的拆分到Worker中