共计 9693 个字符,预计需要花费 25 分钟才能阅读完成。
前言
- 本文为转载
- 翻译原文题目: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 中