【译】在何时你须要使用 Web Workers?

在何时须要使用 Web Workers?

你应该在何时都使用 Web Workers。与此同时在咱们当前的框架世界中,这几乎不可能。html

我这么说吸引到你的注意吗?很好。固然对于任何一个主题,都会有其精妙之处,我会将他们都展现出来。但我会有本身的观点,而且它们很重要。系紧你的安全带,咱们立刻出发。前端

性能差别正在扩大

注意: 我讨厌“新兴市场”这个词,可是为了让这篇博客尽量地通俗易懂,我会在这里使用它。react

手机正变得愈来愈快。我想不会有人不一样意。更强大的 GPU,更快而且更多的 CPU,更多的 RAM。手机正经历与 2000 年代早期桌面计算机经历过的同样的快速发展时期。android

图片展现了从 iPhone 4 到 iPhone X 的不断上涨的 geekbench 分数

Geekbench 得到的基准测试分数(单核)。ios

然而,这仅仅是真实状况的其中一个部分。低阶的手机还留在 2014 年。用于制做 5 年前的芯片的流程已经变得很是便宜,以致于手机可以以大约 20 美圆的价格卖出,同时便宜的手机能吸引更广的人群。全世界大约有 50% 的人能接触到网络,同时也意味着还有大约 50% 的人没有。然而,这些还没上网的人也正在去上网的路上而且主要是在新兴市场,那里的人买不起有钱的西方网络(Wealthy Western Web)的旗舰手机。git

在 Google I/O 2019 大会期间,Elizabeth SweenyBarb Palser 在一个合做伙伴会议上拿出了 Nokia 2 并鼓励合做伙伴去使用它一个星期,去真正感觉一下这个世界上不少人平常是在用什么级别的设备。Nokia 2 是颇有意思的,由于它看起来有一种高端手机的感受可是在外表下面它更像是一台有着现代浏览器和操做系统的 5 年前的智能手机 —— 你能感觉到这份不协调。github

让事情变得更加极端的是,功能手机正在回归。记得哪些没有触摸屏,相反有着数字键和十字键的手机吗?是的,它们正在回归而且如今它们运行着一个浏览器。这些手机有着更弱的硬件,也许有些奇怪,却有着更好的性能。部分缘由是它们只须要控制更少的像素。或者换另外一种说法,对比 Nodia 2,它们有更高的 CPU 性能 - 像素比。web

一张保罗正在使用 Nokia 8110 玩 PROXX 的照片

Nokia 8110,或者说“香蕉手机”算法

虽然咱们每一个周期都能拿到更快的旗舰手机,可是大部分人负担不起这些手机。更便宜的手机还留在过去并有着高度波动的性能指标。在接下来的几年里,这些低端手机更有可能被大量的人民用来上网。最快的手机与最慢的手机之间的差距正在变大,中位数在减小。编程

一个堆叠柱状图展现了低端手机用户占全部手机用户的比例在不断增长。

手机性能的中位数在下降,全部上网用户中使用低端手机的比例则在上升。**这不是一个真实的数据,只是为了直观展示。**我是根据西方世界和新兴市场的人口增加数据以及对谁会拥有高端手机的猜想推断出来的。

JavaScript 是阻塞的

也许有必要解释清楚:长时间运行的 JavaScript 的缺点就是它是阻塞的。当 JavaScript 在运行时,不能去作任何其余事情。**除了运行一个网页应用的 JavaScript 之外,主线程还有别的指责。**它也须要渲染页面,及时将全部像素展现在屏幕上,而且监听诸如点击或者滑动这样的用户交互。在 JavaScript 运行的时候这些都不能发生。

浏览器已经对此作了一些缓解措施,例如在特定状况下会把滚动逻辑放到不一样的线程。不过总体而言,若是你阻塞了主线程,那么你的用户将会有不好的体验。他们会愤怒地点击你的按钮,被卡顿的动画与滚动所折磨。

人类的感知

多少的阻塞才算过多的阻塞?RAIL 经过给不一样的任务提供基于人类感知的时间预算来尝试回答这个问题。好比说,为了让人眼感到动画流畅,在下一帧被渲染以前你要有大约 16 毫秒的间隔。这些数字是固定的,由于人类心理学不会由于你所拿着的设备而改变。

看一下日趋扩大的性能差距。你能够构建你的 app,作你的尽职调查以及性能分析,解决全部的瓶颈并达成全部目标。可是除非你是在最低端的手机上开发,否则是没法预测一段代码在现在最低端手机上要运行多久,更不要说将来的最低端手机。

这就是由不同的水平带给 web 的负担。你没法预测你的 app 将会运行在什么级别的设备上。你能够说“Sura,这些性能低下的手机与我/个人生意无关!”,但对我来说,这如同“那些依赖屏幕阅读器的人与我/个人生意无关!”同样的恶心。**这是一个包容性的问题。我建议你 仔细想一想,是否正在经过不支持低端手机来排除掉某些人群。**咱们应该努力使每个人都能获取到这个世界的信息,而无论喜不喜欢,你的 app 正是其中的一部分。

话虽如此,因为涉及到不少术语和背景知识,本博客没法给全部人提供指导。上面的那些段落也同样。我不会伪装无障碍访问或者给低端手机编程是一件容易的事,但我相信做为一个工具社区和框架做者仍是有不少事情能够去作,去以正确的方式帮助人们,让他们的成果默认就更具无障碍性而且性能更好,默认就更加包容。

解决它

好了,尝试从沙子开始建造城堡。尝试去制做那些能在各类各样的,你都没法预测一段在代码在上面须要运行多久的设备上都能保持符合 RAIL 模型性能评估的时间预算的 app。

共同合做

一个解决阻塞的方式是“分割你的 JavaScript”或者说是“让渡给浏览器”。意思是经过在代码添加一些固定时间间隔的断点来给浏览器一个暂停运行你的 JavaScript 的机会而后去渲染下一帧或者处理一个输入事件。一旦浏览器完成这些工做,它就会回去执行你的代码。这种在 web 应用上让渡给浏览器的方式就是安排一个宏任务,而这能够经过多种方式实现。

必要的阅读: 若是你对宏任务或者宏任务与微任务的区别,我推荐你去阅读 Jake Archibald谈谈事件循环

在 PROXY,咱们使用一个 MessageChannel 而且使用 postMessage() 去安排一个宏任务。为了在添加断点以后代码仍能保持可读性,我强烈推荐使用 async/await。在 PROXX 上,用户在主界面与游戏交互的同时,咱们在后台生成精灵。

const { port1, port2 } = new MessageChannel();
port2.start();

export function task() {
  return new Promise(resolve => {
    const uid = Math.random();
    port2.addEventListener("message", function f(ev) {
      if (ev.data !== uid) {
        return;
      }
      port2.removeEventListener("message", f);
      resolve();
    });
    port1.postMessage(uid);
  });
}

export async function generateTextures() {
  // ...
  for (let frame = 0; frame < numSprites; frame++) {
    drawTexture(frame, ctx);
    await task(); // 断点
  }
  // ...
}
复制代码

可是**分割依旧受到日趋扩大的性能差距的影响:**一段代码运行到下一个断点的时间是取决于设备的。在一台低端手机上耗时小于 16 毫秒,但在另外一台低端手机上也许就会耗费更多时间。

移出主线程

我以前说过,主线程除了执行网页应用的 JavaScript 之外,还有别的一些职责。而这就是为何咱们要不惜代价避免长的,阻塞的 JavaScript 在主线程。但假如说咱们把大部分的 JavaScript 移动到一条专门用来运行咱们的 JavaScript,除此以外不作别的事情的线程中呢。一条没有其余职责的线程。在这样的状况下,咱们不须要担忧咱们的代码受到日趋扩大的性能差距的影响,由于主线程不会收到影响,依然能处理用户输入并保持帧率稳定。

Web Workers 是什么?

Web Workers,也被叫作 “Dedicated Workers”,是 JavaScript 在线程方面的尝试。JavaScript 引擎在设计时就假设只有一条线程,所以时没有并发访问的 JavaScript 对象内存,而这符合全部同步机制的需求。若是一条具备共享内存模型的普通线程被添加到 JavaScript,那么少说也是一场灾难。相反,咱们有了 Web Workers,它基本上就是一个运行在另外一条独立线程上的完整的 JavaScript 做用域,没有任何的共享内存或者共享值。为了使这些彻底分离而且孤立的 JavaScript 做用域能共同工做,你可使用 postMessage(),它使你可以在另外一个 JavaScript 做用域内触发一个 message 事件并带有一个你提供的值的拷贝(使用结构化克隆算法 来拷贝)。

到目前为止,除了一些一般涉及长时间运行的计算密集任务的“银弹”用例之外 workers 基本没获得采用。我想这应该被改变。咱们应该开始使用 workers。常用。

全部酷小孩都在这么作

这不是一个新的想法,实际上还挺老的。大部分原平生台都把主线程称为 UI 线程,由于它应该只会被用来处理 UI 工做,而且它们给你提供了工具去实现。安卓从很早的版本开始就有一个叫 AsyncTask 的东西,并从那开始添加了更多更方便的 API(最近的是 Coroutines 它能够很容易地被派发在不一样线程)。若是你选用了“严格模式”,那么在 UI 线程上使用某些 API —— 例如文件操做 —— 会致使你的应用奔溃,以此来提醒你在 UI 线程上作了一些与 UI 无关的操做。

从一开始 iOS 就有一个叫 Grand Central Dispatch (“GCD”)的东西,用来在不一样的系统提供的线程池上派发任务,其中包括 UI 线程。经过这方式他们强制了两个模式:你老是要将你的逻辑分割成若干任务,而后才能被放到队列中,容许 UI 线程在须要的时候将其放入对应的线程,但同时也容许你经过简单地将任务放到不一样的队列来在不一样的线程执行非 UI 相关的工做。锦上添花的是还能够给任务指定优先级,这样帮助咱们确保时间敏感的工做能尽快被完成,而且不会牺牲系统总体的响应。

个人观点是这些原平生台从一开始就已经支持使用非 UI 线程。我以为能够公正地说,通过这么多时间,他们已经证实来这是一个好主意。将在 UI 线程的工做量降到最低有助于让你的 app 保持响应灵敏。为何不把这样的模式用在 web 上呢?

开发体验是一个障碍

咱们只能经过 Web Worker 这么一个简陋的工具在 web 上使用线程。当你开始使用 Workers 以及他们提供的 API 时,message 事件处理器就是其中的核心。这感受并很差。此外,Workers 线程,但又跟线程不彻底同样。你没法让多个线程访问同一个变量(例如一个静态对象),全部的东西都要经过消息传递,这些消息能携带不少但不是所有 JavaScript 值。例如你不能发送一个 Event 或者没有数据损失的对象实例。我想,对于开发者来讲这是最大的阻碍。

Comlink

由于这样的缘由,我编写了 Comlink 它不只帮你隐藏掉 postMessage(),甚至能让你忘记正在使用 Workers。感受就像是你可以访问到来自别的线程的共享变量:

// main.js
import * as Comlink from "https://unpkg.com/comlink?module";

const worker = new Worker("worker.js");
// 这个 `state` 变量实际上是在别的 worker 中!
const state = await Comlink.wrap(worker);
await state.inc();
console.log(await state.currentCount);
复制代码
// worker.js
import * as Comlink from "https://unpkg.com/comlink?module";

const state = {
  currentCount: 0,

  inc() {
    this.currentCount++;
  }
}

Comlink.expose(state);
复制代码

**说明:**我用了顶层 await 以及模块 worker(modules-in-workers)来让例子变短。请到 Comlink 的代码仓库查看真实的例子以及更多细节。

在这问题上 Comlink 不是惟一的解决方案,只是我最熟悉它(很正常,考虑到是我写的 🙄)。若是你对其余方法感兴趣,看一下 Andrea Giammarchiworkway 或者 Jason Millerworkerize

我不在乎你用哪一个库,只要你最终转换到“离开主线程”架构。咱们在 PROXXSquoosh 上成功使用了 Comlink,由于它很小(gzip 后 1.2KiB)而且让咱们不须要在开发上改动太多就能使用不少来自其余有“真正”线程的语言的经常使用模式。

参与者

最近我和 Paul Lewis 一块儿评估过其余的方法。除了说隐藏你正在使用 Worker 的事实以及 postMessage,咱们还从 70 年代和使用过的参与者模式中获得灵感,这种架构模式将消息传递看成基本的积木。通过那次思想实验,咱们编写了一个支撑参与者模式的库,一个入门套件,并在 2018 Chrome 开发者峰会上作了一次演讲,介绍了这个架构以及它的应用。

“基准测试”

你也许会想:**是否是值得去使用“离开主线程”架构?**让咱们来作一个投入/产出分析:有了 Comlink 这样的库,切换到“离开主线程”架构的代价应该会比之前有显著的下降,很是接近于零。那么好处呢?

Dion Almaer 叫过我去给 PROXX 写一个彻底运行在主线程上的版本,这也许能解答那个问题。所以我就这么作了。在 Pixel 3 或者 MacBook 上仅仅有一点可感知的差异。可是在 Nokia 2 上则有了明显不一样。若是把全部东西都运行在主线程上,在最差的情形下应用卡住了高达 6.6 秒。而且还有不少正在流通的设备的性能比 Nokia 2 还要低!而运行使用了“离开主线程”架构的 PROXX 版本,执行一个 tap 事件处理函数仅仅耗时 48 毫秒,由于所作的仅仅是经过调用 postMessage() 发了一条消息到 Worker 中。这表明着,特别是考虑到日趋扩大的性能差距,“离开主线程”架构可以提升处理意想不到的大且长的任务的韧性

一个采用“离开主线程”架构的 PROXX 的运行跟踪

PROXX 的事件处理器是很是简洁的而且只会被用来给指定的 worker 发送消息。总而言之这个任务耗时 48 毫秒。

一个采用全部都运行在主线程的 PROXX 的运行跟踪

在一个全部东西都运行在主线程的 PROXX 版本,执行一个事件处理器须要耗时超过 6 秒。

有一个须要注意的是,任务并无消失。即便使用了“离开主线程”架构,代码仍须要运行大约 6 秒的事件(在 PROXX 这实际上会更加长)。然而因为这些工做是在另外一个线程上进行的,UI 线程仍然能保持响应。咱们的 worker 也会把中间结果传回主线程。经过保持事件处理器的简洁,咱们保证了 UI 线程能保持响应并能更新视觉状态。

框架的窘困

如今说一下我一个脱口而出的意见:咱们现有的框架让“离开主线程”架构变得困难并减小了它的回归。 UI 框架应该去作 UI 的工做,也所以有权去运行在 UI 线程。然而实际上,它们所作的工做是 UI 工做以及其余一些相关可是非 UI 的工做。

让咱们拿 VDOM diff 作例子:虚拟 DOM 的目的将开发者的代码与真实 DOM 的更新解耦。虚拟 DOM 仅仅是一个模拟真实 DOM 的数据结构,这样它的改变就不会引发高消耗的反作用。只有当框架认为时机合适的时候,虚拟 DOM 的改变才会引发真实 DOM 的更新。这一般被称为“冲洗(flushing)”。直到冲洗以前的全部工做是绝对不须要运行在 UI 线程的。然而实际上它正在耗费你宝贵的 UI 线程资源。鉴于低端手机没法应付 diff 的工做量,在 PROXX 咱们去除了 VDOM diff 并实现了咱们本身的 DOM 操做。

VDOM diff 仅仅是其中一个框架引导的开发体验的例子,或者一个简单的克服用户设备性能的例子。一个面向全球发布的框架,除非它明确代表本身只针对哪些富有的西方网络不然他是有责任去帮助开发者开发支持不一样级别手机的应用。

结论

Web Worker 帮助你的应用运行在更普遍的设备上。像 Comlink 这样的库协助你在无需放弃便利以及开发速度的状况下使用 worker。我想咱们应该思考的是,为何除了 web 之外的全部平台都在尽量的少占用 UI 线程的资源。咱们应该改变本身的老办法,并帮助促成下一代框架改变。


特别感谢 Jose AlcérrecaMoritz Lang,他们帮我了解原平生台是如何解决相似问题的。

若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索