Web Worker 现状

程序员成长指北

共 10738字,需浏览 22分钟

 ·

2022-04-11 22:12

大厂技术  高级前端  Node进阶

点击上方 程序员成长指北,关注公众号

回复1,加入高级Node交流群

导读: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

所有的现代浏览器都支持通过