前言
- 本文为转载
- 翻译原文标题:2021 Web Worker 现状
- 翻译原文作者:Tapir
- 翻译原文地址:知乎
- 原文地址: The State Of Web Workers In 2021
导读: 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。AudioWorklet,CSS Painting API 和 Animation 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 可以传递非常复杂的消息。其底层算法(叫做 “结构化克隆”)可以处理 内部带有循环的数据结构 甚至是 Map
和 Set
。然而,他不能处理 函数 或者 类,因为这些代码在 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 Array 或 DataView 来操作。如果 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的场景:
- 当你的算法程序逻辑的时间相对长(超出了"帧预算"),且阻碍了渲染引擎。
- 当你想要尝试并发的设计模式
- 任务调度架构设计的调整(JS种种实现的调度机制可能并不是最优)
- ... 渲染和计算的完全解耦,计算要合理的拆分到Worker中
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。