乐趣区

关于javascript:2021-Web-Worker-需要了解的现状

前言

  • 本文为转载
  • 翻译原文题目: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 能够传递非常复杂的音讯。其底层算法(叫做“结构化克隆”)能够解决 外部带有循环的数据结构 甚至是 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 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 的场景:

  1. 当你的算法程序逻辑的工夫绝对长(超出了 ” 帧估算 ”), 且妨碍了渲染引擎。
  2. 当你想要尝试并发的设计模式
  3. 任务调度架构设计的调整(JS 种种实现的调度机制可能并不是最优)
  4. … 渲染和计算的齐全解耦, 计算要正当的拆分到 Worker 中
退出移动版