之前的课程我们讲过 JavaScript 语言采用的是单线程模型,也就是说,所有任务只能在一个线程上完成,一次只能做一件事。前面的任务没做完,后面的任务只能等着。随着电脑计算能力的增强,尤其是多核 CPU 的出现,单线程带来弊端也就显现出来了,无法充分发挥计算机的计算能力。

Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程(通常负责 UI 交互)就会很流畅,不会被阻塞或拖慢。

如下图所示, Web Worker 实现了多线程运行 JS 能力. 之前页面更新要先串行做 2 件事情; 使用 Worker 后, 2 件事情可并行完成.
webWorker多线程机制 - 图1

Worker 线程一旦新建成功,就会始终运行,不会被主线程上的活动(比如用户点击按钮、提交表单)打断。这样有利于随时响应主线程的通信。但是,这也造成了 Worker 比较耗费资源,不应该过度使用,而且一旦使用完毕,就应该关闭。

主要内容

  • Worker 发展历史
  • 主线程和多线程
  • 什么时候使用Worker
  • worker API
  • 补充: 浏览器的 16ms 渲染帧

Worker 发展历史

技术规范

先从技术规范讲起 Web Worker 属于 HTML 规范, 规范文档见 Web Workers Working Draft, 有兴趣的同学可以读一读. 而它并不是很新的技术, 如下图所示: 2009 年就提出了草案
webWorker多线程机制 - 图2

同年在 FireFox3.5 上率先实现 2012年发布的 IE10 也实现了 Web Worker, 标志着主流浏览器上的全面支持。大家在预研 Worker 方案时, 开发人员会有兼容性顾虑. 这种顾虑的普遍存在, 主要由于业界 Worker 技术实践较少和社区推广不活跃 单从发展历史看, Worker 从 2012 年起就广泛可用;

DedicatedWorker 和 SharedWorker

Web Worker 规范中包括: DedicatedWorkerSharedWorker; 规范并不包括 Service Worker, 本章节也不会展开讨论。
1637934773(1).jpg
如上图所示 DedicatedWorker 简称 Worker, 其线程只能与一个页面渲染进程(Render Process)进行绑定和通信, 不能多 Tab 共享. DedicatedWorker 是最早实现并最广泛支持的 Web Worker 能力.
image.png
而 SharedWorker 可以在多个浏览器 Tab 中访问到同一个 Worker 实例, 实现多 Tab 共享数据, 共享 webSocket 连接等. 看起来很美好, 但 safari 放弃了 SharedWorker 支持, 因为 webkit 引擎的技术原因.

社区也在讨论 是否继续支持 SharedWorker; 多 Tab 共享资源的需求建议在 Service Worker 上寻找方案.
相比之下, DedicatedWorker 有着更广的兼容性和更多业务落地实践, 本文后面讨论中的 Worker 都是特指 DedicatedWorker.

下面的例子中我们创建的worker就是DedicatedWorker。

  1. const worker = new Worker("worker.js");

怎么创建sharedWorker呢?

  1. var myWorker = new SharedWorker('worker.js');

SharedWorker有一个单独的SharedWorker类,和dedicated worker不同的是SharedWorker是通过port对象来进行交互的。

主线程和多线程

用户使用浏览器一般会打开多个页面 (多 Tab), 现代浏览器使用单独的进程 (Render Process) 渲染每个页面, 以提升页面性能和稳定性, 并进行操作系统级别的内存隔离.
webWorker多线程机制 - 图5

主线程 (Main Thread)

页面内, 内容渲染和用户交互主要由 Render Process(渲染进程) 中的主线程进行管理. 主线程渲染页面每一帧 (Frame), 如下图所示, 会包含 6 个步骤: JavaScript → DOM树构建→ Style 样式计算→ Layout 图层构建→ Paint 图层绘制 → 合成显示, 如果 JS 的执行修改了 DOM, 可能还会暂停JavaScript插入并从样式计算开始重新流水线作业。
image.png

而我们熟知的 JS 单线程和 Event Loop, 是主线程的一部分. JS 单线程执行避免了多线程开发中的复杂场景 (如竞态和死锁). 但单线程的主要困扰是: 主线程同步 JS 执行耗时过久时 (浏览器理想帧间隔约 16ms), 会阻塞用户交互和页面渲染.
webWorker多线程机制 - 图7
如上图所示, 长耗时任务执行时, 页面将无法更新, 也无法响应用户的输入/点击/滚动等操作. 如果卡死太久, 浏览器可能会抛出卡顿的提示。

多线程

Web Worker 会创建操作系统级别的线程.
The Worker interface spawns real OS-level threads. — MDN
JS 多线程, 是独立于主线程的 JS 运行环境. 如下图所示: Worker 线程有独立的内存空间, Message Queue, Event Loop, Call Stack 等, 线程间通过 postMessage 通信. n
-

webWorker多线程机制 - 图8
多个线程可以并发运行 JS. 熟悉 JS 异步编程的同学可能会说, setTimeout / Promise.all 不就是并发吗, 我写得可溜了

JS 单线程中的” 并发”, 准确来说是 Concurrent. 如下图所示, 运行时只有一个函数调用栈, 通过 Event Loop 实现不同 Task 的上下文切换 (Context Switch). 这些 Task 通过 BOM API 调起其他线程为主线程工作, 但回调函数代码逻辑依然由 JS 串行运行.

Web Worker 是 JS 多线程运行技术, 准确来说是 Parallel. 其与 Concurrent 的区别如下图所示: Parallel 有多个函数调用栈, 每个函数调用栈可以独立运行 Task, 互不干扰

webWorker多线程机制 - 图9

webWorker应用场景

讨论完主线程和多线程, 我们能更好地理解 Worker 多线程的应用场景:

  • 可以减少主线程卡顿.
  • 可能会带来性能提升.

    减少卡顿

    根据 Chrome 团队提出的用户感知性能模型 RAIL, 同步 JS 执行时间不能过长. 量化来说, 播放动画时建议小于 16ms, 用户操作响应建议小于 100ms, 页面打开到开始呈现内容建议小于 1000ms。

逻辑异步化

减少主线程卡顿的主要方法为异步化执行, 比如播放动画时, 将同步任务拆分为多个小于 16ms 的子任务, 然后在页面每一帧前通过 requestAnimationFrame 按计划执行一个子任务, 直到全部子任务执行完毕。

webWorker多线程机制 - 图10

拆分同步逻辑的异步方案对大部分场景有效果, 但并不是一劳永逸的银弹. 有以下几个问题:

  • 不是所有 JS 逻辑都可拆分. 比如数组排序, 树的递归查找, 图像处理算法等, 执行中需要维护当前状态, 且调用上非线性, 无法轻易地拆分为子任务.
  • 可以拆分的逻辑难以把控粒度. 如下图所示, 拆分的子任务在高性能机器 (iphoneX) 上可以控制在 16ms 内, 但在性能落后机器 (iphone6) 上就超过了 deadline. 16ms 的用户感知时间, 并不会因为用户手上机器的差别而变化, Google 给出的建议是再拆小到 3-4ms.
  • 拆分的子任务并不稳定. 对同步JavaScript逻辑的拆分, 需要根据业务场景寻找原子逻辑, 而原子逻辑会跟随业务变化, 每次改动业务都需要去审查原子逻辑。

Worker 一步到位

Worker 的多线程能力, 使得同步 JS 任务的拆分一步到位: 从宏观上将整个同步 JS 任务异步化. 不需要再去苦苦寻找原子逻辑, 逻辑异步化的设计上也更加简单和可维护。

这给我们带来更多的想象空间. 如下图所示, 在浏览器主线程渲染周期内, 将可能阻塞页面渲染的JavaScript运行任务 (Jank Job) 迁移到 Worker 线程中, 进而减少主线程的负担, 缩短渲染间隔, 减少页面卡顿。
webWorker多线程机制 - 图11

性能提升

Worker 多线程并不会直接带来计算性能的提升, 能否提升性能是跟设备 CPU 核数和线程策略有关。

多线程与 CPU 核数

进程是操作系统资源分配的基本单位,线程是操作系统调度 CPU 的基本单位. 操作系统对线程能占用的 CPU 计算资源有复杂的分配策略. 如下图所示:

  • 单核多线程通过时间切片交替执行.
  • 多核多线程可在不同核中真正并行.

webWorker多线程机制 - 图12
所以你要明白一台设备上相同任务在各线程中运行耗时是一样的 。我们将主线程 JS 任务交给新建的 Worker 线程, 任务在 Worker 线程上运行并不会比原本主线程更快, 而线程新建消耗和通信开销使得渲染间隔可能变得更久。

在单核机器上, 计算资源是内卷的, 新建的 Worker 线程并不能为页面争取到更多的计算资源. 在多核机器上, 新建的 Worker 线程和主线程都能做运算, 页面总计算资源增多, 但对单次任务来说, 在哪个线程上运行耗时是一样的.
真正带来性能提升的是多核多线程并发.

Worker 的应用场景, 本质上是从主线程中剥离逻辑, 让主线程专注于 UI 渲染 利用设备的多核多线程并发的能力解决cpu密集运算的时候导致ui卡顿的问题。所以worker 不是万能的只有在有规模的任务下才能产生正向收益。比如一些细分领域,网盘、在线文档、图像视频处理等工具型产品。

Worker API

通信 API

webWorker多线程机制 - 图13
如上图所示的 Worker 通信流程, Worker 通信 API 非常简单双向通信只需 7 行代码.
webWorker多线程机制 - 图14
主要流程为:

  1. 主线程调用 new Worker(url) 创建 Worker 实例, url 为 Worker JS 资源 url.
  2. 主线程调用 postMessage 发送 hello, 在 onmesssage 中监听 Worker 线程消息.
  3. Worker 线程在 onmessage 中监听主线程消息, 收到主线程的 hello; 通过 postMessage 回复 world.
  4. 主线程在消息回调中收到 Worker 的 world 信息.

postMessage 会在接收线程创建一个 MessageEvent, 传递的数据添加到 event.data, 再触发该事件; MessageEvent 的回调函数进入 Message Queue, 成为待执行的宏任务. 因此 postMessage 顺序发送的信息, 在接收线程中会顺序执行回调函数. 而且我们无需担心实例化 Worker 过程中 postMessage 的信息丢失问题, 对此 Worker 内部机制已经处理。

运行环境

在 Worker 线程中运行 JavaScript 会创建独立于主线程的 JS 运行环境, 你需要去关注 Worker 环境和主线程环境的异同。
webWorker多线程机制 - 图15
上图展示了 Worker 线程与主线程的异同点. Worker 运行环境与主线程的共同点主要包括:

  • 包含完整的 JS 运行时, 支持 ECMAScript 规范定义的语言语法和内置对象.
  • 支持 XmlHttpRequest, 能独立发送网络请求与后台交互.
  • 包含只读的 Location, 指向 Worker 线程执行的 script url, 可通过 url 传递参数给 Worker 环境.
  • 包含只读的 Navigator, 用于获取浏览器信息, 如通过 Navigator.userAgent 识别浏览器.
  • 支持 setTimeout / setInterval 计时器, 可用于实现异步逻辑.
  • 支持 WebSocket 进行网络 I/O; 支持 IndexedDB 进行文件 I/O.

从共同点上看, Worker 线程其实很强大, 除了利用独立线程执行重度逻辑外, 其网络 I/O 和文件 I/O 能力给业务和技术方案带来很大的想象空间。

另一方面, Worker 线程运行环境和主线程的差异点有:

  • Worker 线程没有 DOM API, 无法新建和操作 DOM; 也无法访问到主线程的 DOM Element.
  • Worker 线程和主线程间内存独立, Worker 线程无法访问页面上的全局变量 (window, document 等) 和 JS 函数.
  • Worker 线程不能调用 alert() 或 confirm() 等 UI 相关的 BOM API
  • Worker 线程被主线程控制, 主线程可以新建和销毁 Worker
  • Worker 线程可以通过 self.close 自行销毁

从差异点上看, Worker 线程无法染指 UI, 并受主线程控制, 适合默默干活。

通信开销

Worker 多线程虽然实现了 JS 任务的并行运行, 也带来额外的通信开销. 如下图所示, 从线程 A 调用 postMessage 发送数据到线程 B onmessage 接收到数据有时间差, 这段时间差称为通信消耗
webWorker多线程机制 - 图16
性能提升(具体指标) = 并行提升的性能 – 通信消耗的性能. 在线程计算能力固定的情况下, 要通过多线程提升更多性能, 所以你要知道web worker 并不是完美没有缺陷的,它也只有在特定的场景下才会带来性能的提升。

题外:浏览器的 16ms 渲染帧

根据 Chrome 团队提出的用户感知性能模型 RAIL, 同步 JS 执行时间不能过长. 量化来说, 播放动画时建议小于 16ms, 用户操作响应建议小于 100ms, 页面打开到开始呈现内容建议小于 1000ms。

怎么计算的?

由于现在广泛使用的屏幕都有固定的刷新率(比如最新的一般在 60Hz), 在两次硬件刷新之间浏览器进行两次重绘是没有意义的只会消耗性能。 浏览器会利用这个间隔 16ms(1000ms/60)适当地对绘制进行节流, 因此 16ms 就成为页面渲染优化的一个关键时间。

为方便查阅源码和相关资料,本文以 Chromium 的 Blink 引擎为例分析。如下是一些分析结论:

  • 一个渲染帧内 commit 的多次 DOM 改动会被合并渲染;
  • 耗时 JS 会造成丢帧;
  • 渲染帧间隔为 16ms 左右;
  • 避免耗时脚本、交错读写样式以保证流畅的渲染。

渲染帧的流程

渲染帧是指浏览器一次完整绘制过程,帧之间的时间间隔是 DOM 视图更新的最小间隔。 由于主流的屏幕刷新率都在 60Hz,那么渲染一帧的时间就必须控制在 16ms 才能保证不掉帧。 也就是说每一次渲染都要在 16ms 内页面才够流畅不会有卡顿感。 这段时间内浏览器需要完成如下事情:

  • 脚本执行(JavaScript):脚本造成了需要重绘的改动,比如增删 DOM、请求动画等
  • 样式计算(CSS Object Model):级联地生成每个节点的生效样式。
  • 布局(Layout):计算布局,执行渲染算法
  • 重绘(Paint):各层分别进行绘制(比如 3D 动画)
  • 合成(Composite):合成各层的渲染结果

最初 Webkit 引擎是使用定时器进行渲染间隔控制, 2014 年时开始 使用显示器的 vsync 信号控制渲染(其实直接控制的是合成这一步)。 这意味着 16ms 内多次 commit 的 DOM 改动会合并为一次渲染。

JavaScript在事件循环的一次 Tick 中, 如果要执行的逻辑太多会一直阻塞下一个 Tick,所有异步过程都会被阻塞。 一个流畅的页面中,JavaScript 引擎中的执行队列可能是这样的:

执行 JS -> 空闲 -> 绘制(16ms)-> 执行 JS -> 空闲 -> 绘制(32ms)-> …

如果在某个时刻有太多 JavaScript 要执行,就会丢掉一次帧的绘制:

执行很多 JS…(20ms)-> 空闲 -> 绘制(32ms)-> …

避免耗时的 JavaScript 代码

耗时超过 16ms 的 JavaScript 可能会丢帧让页面变卡。如果有太多事情要做可以把这些工作重新设计,分割到各个阶段中执行。并充分利用缓存和懒初始化等策略。不同执行时机的 JavaScript 有不同的优化方式:

  • 服务器端渲染或者应用懒初始化策略。
  • 耗时脚本可以优化算法或者迁移到 Worker 中。