# Web Worker

# 概览

# 简介

Web Worker 作为浏览器多线程技术,在当下,成为缓解页面卡顿,,提升应用性能的可选方案。Web Worker 实现了多线程运行 JS 能力,使 JS 可以并行执行,进而提升执行效率,加之运行任务拆分,减少页面卡顿。

由于 JavaScript 语言采用的是单线程,同一时刻只能做一件事,如果有多个同步计算任务执行,则在这段同步计算逻辑执行完之前,它下方的代码不会执行,从而造成了阻塞,用户的交互也可能无响应。但如果把这段同步计算逻辑放到 Web Worker 执行,在这段逻辑计算运行期间依然可以执行它下方的代码,用户的操作也可以响应了。Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程就会很流畅,不会被阻塞或拖慢。

# 多线程与主线程

Web Worker 会创建操作系统级别的线程。JS 多线程, 是有独立于主线程的 JS 运行环境。

webworker1

如图所示:,Worker 线程有独立的内存空间, Message Queue, Event Loop, Call Stack 等, 线程间通过 postMessage 通信。

Web Worker 多线程并发与异步编程中的并发之间的区别

Web Worker 多个线程可以并发运行 JS。这里不同于 JS 异步编程中的并发(Promise.all),JS 单线程中的"并发", 准确来说是 Concurrent。运行时只有一个函数调用栈, 通过 Event Loop 实现不同 Task 的上下文切换(Context Switch)。这些 Task 通过 BOM API 调起其他线程为主线程工作,但回调函数代码逻辑依然由 JS 串行运行。

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

# 分类

Web Worker 规范中包括(不包括 Service Worker):

  • DedicatedWorker(专用线程):简称 Worker, 其线程只能与一个页面渲染进程(Render Process)进行绑定和通信, 不能多 Tab 共享。DedicatedWorker 是最早实现并最广泛支持的 Web Worker 能力。

  • SharedWorker(共享线程):可以在多个浏览器 Tab 中访问到同一个 Worker 实例,实现多 Tab 共享数据,共享 webSocket 连接等。看起来很美好, 但 safari 放弃了 SharedWorker 支持,因为 webkit 引擎的技术原因。

本文后面讨论中的 Worker 都是特指 DedicatedWorker

# 使用限制

Worker 运行环境与主线程的共同点主要包括:

  • 包含完整的 JS 运行时, 支持 ECMAScript 规范定义的语言语法和内置对象.
  • 支持 XmlHttpRequest (opens new window), 能独立发送网络请求与后台交互.
  • 包含只读的 Location (opens new window), 指向 Worker 线程执行的 script url, 可通过 url 传递参数给 Worker 环境.
  • 包含只读的 Navigator (opens new window), 用于获取浏览器信息, 如通过 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 线程运行的脚本文件,必须与主线程的脚本文件同源。

# 文件限制

Worker 线程无法读取本地文件(file://),会拒绝使用 file 协议来创建 Worker实例,它所加载的脚本,必须来自网络。

# DOM 操作限制

Worker 线程所在的全局对象,与主线程不一样,区别是:

  • 无法读取主线程所在网页的 DOM 对象
  • 无法使用documentwindowparent这些对象

# 通信限制

Worker 线程和主线程不在同一个上下文环境,它们不能直接通信,必须通过消息完成,交互方法是postMessageonMessage,并且在数据传递的时候, Worker 是使用拷贝的方式。

# 脚本限制

Worker 线程不能执行alert()方法和confirm()方法,但可以使用 XMLHttpRequest 对象发出 AJAX 请求,也可以使用setTimeout/setInterval等API

# 使用方式

# 基本 API

const worker = new Worker(aURL, options);
1
  • worker.postMessage: 向 worker 的内部作用域发送一个消息,消息可由任何 JavaScript 对象组成
  • worker.terminate: 立即终止 worker。该方法并不会等待 worker 去完成它剩余的操作;worker 将会被立刻停止
  • worker.onmessage:当 worker 的父级接收到来自其 worker 的消息时,会在 Worker 对象上触发 message 事件
  • worker.onerror: 当 worker 出现运行中错误时,它的 onerror 事件处理函数会被调用。它会收到一个扩展了 ErrorEvent 接口的名为 error 的事件
worker.addEventListener('error', function (e) {
    console.log(e.message) // 可读性良好的错误消息
    console.log(e.filename) // 发生错误的脚本文件名
    console.log(e.lineno) // 发生错误时所在脚本文件的行号
})
1
2
3
4
5

主要流程为:

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

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

Worker 事件驱动(postMessage/onmessage) 的通信 API 虽然简洁, 但大多数场景下通信需要等待响应(类似 HTTP 请求的 Request 和 Response), 并且多次同类型通信要匹配到各自的响应. 所以业务使用一般会封装原生 API, 如封装为 Promise 调用。

# 直接指定脚本文件

const myWorker = new Worker(aURL, options);
1

aURL表示 worker 将执行的脚本的 URL(脚本文件), 即 Web Worker 所要执行的任务。案例如下:

// 主线程下创建worker线程
const worker = new Worker('./worker.js')

// 监听接收worker线程发的消息
worker.onmessage = function (e) {
  console.log('主线程收到worker线程消息:', e.data)
}

// 向worker线程发送消息
worker.postMessage('主线程发送hello world')

// worker.js
// self 代表子线程自身,即子线程的全局对象
self.addEventListener("message", function (e) {
  // e.data表示主线程发送过来的数据
  self.postMessage("worker线程收到的:" + e.data); // 向主线程发送消息
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

Web Worker 的执行上下文名称是 self,无法调用主线程的 window 对象的。上述写法等同于以下写法:

this.addEventListener("message", function (e) {
  // e.data表示主线程发送过来的数据
  this.postMessage("worker线程收到的:" + e.data); // 向主线程发送消息
});

// 将JS文件引入html挂在本地开发环境运行,运行结果如下:
// 主线程收到 worker 线程消息: worker 线程收到的:主线程发送 hello world 
1
2
3
4
5
6
7

# 使用 Blob URL 创建

除了这种通过引入js文件的方式,也可以通过URL.createObjectURL()创建URL对象,创建内嵌的worker

/**
 * const blob = new Blob(array, options);
 * Blob() 构造函数返回一个新的 Blob 对象。blob 的内容由参数数组中给出的值的串联组成。
 * @params array 是一个由ArrayBuffer, ArrayBufferView, Blob, DOMString 等对象构成的 Array
 * @options type,默认值为 "",它代表了将会被放入到 blob 中的数组内容的 MIME 类型。还有两个这里忽略不列举了
 */
 
/**
 * URL.createObjectURL():静态方法会创建一个 DOMString,其中包含一个表示参数中给出的对象的 URL。这个 URL 的生命周期和创建它的窗口中的 document 绑定。这个新的 URL 对象表示指定的 File 对象或 Blob 对象
 */
const worker = new Worker(URL.createObjectURL(blob));

function func() {
  console.log('hello')
}

function createWorker(fn) {
  // const blob = new Blob([fn.toString() + ' fn()'], { type: 'text/javascript' })
  const blob = new Blob([`(${fn.toString()})()`], { type: 'text/javascript' })
  return URL.createObjectURL(blob)
}

createWorker(func)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# Worker 线程中引入其他脚本

Worker线程内部要加载其他脚本,可以使用 importScripts()

// worker.js
importScripts("constants.js");

// self 代表子线程自身,即子线程的全局对象
self.addEventListener("message", function (e) {
  self.postMessage(foo); // 可拿到 `foo`、`getAge()`、`getName`的结果值 
});


// constants.js
const foo = "变量";

function getAge() {
  return 25;
}

const getName = () => {
  return "jacky";
};
复制代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

还可以同时加载多个脚本

importScripts('script1.js', 'script2.js');
1

Worker 多线程虽然实现了 JS 任务的并行运行, 也带来额外的通信开销。在线程计算能力固定的情况下, 要通过多线程提升更多性能, 需要尽量减少通信消耗。而且主线程 postMessage 会占用主线程同步执行, 占用时间与数据传输方式和数据规模相关. 要避免多线程通信导致的主线程卡顿, 需选择合适的传输方式, 并控制每个渲染周期内的数据传输规模。

webworker2

# 数据传输方式

我们先来聊聊主线程和 Worker 线程的数据传输方式。根据计算机进程模型, 主线程和 Worker 线程属于同一进程, 可以访问和操作进程的内存空间. 但为了降低多线程并发的逻辑复杂度, 部分传输方式直接隔离了线程间的内存, 相当于默认加了锁。通信方式有 3 种:

  • Structured Clone,
  • Transfer Memory
  • Shared Array Buffer.

# Structured Clone

Structured Clone (opens new window) 是 postMessage 默认的通信方式。复制一份线程A 的 JS Object 内存给到线程B, 线程B 能获取和操作新复制的内存。

webworker3

Structured Clone 通过复制内存的方式简单有效地隔离不同线程内存, 避免冲突; 且传输的 Object 数据结构很灵活. 但复制过程中, 线程A 要同步执行 Object Serialization, 线程B 要同步执行 Object Deserialization; 如果 Object 规模过大, 会占用大量的线程时间.

# Transfer Memory

Transfer Memory (opens new window) 意为转移内存, 它不需要 Serialization/Deserialization, 能大大减少传输过程占用的线程时间. 如下图所示 , 线程A 将指定内存的所有权和操作权转给线程B, 但转让后线程A 无法再访问这块内存.

webworker4

Transfer Memory 以失去控制权来换取高效传输, 通过内存独占给多线程并发加锁. 但只能转让 ArrayBuffer (opens new window) 等大小规整的二进制(Raw Binary)数据; 对矩阵数据(如 RGB 图片)比较适用. 实践上也要考虑从 JS Object 生成二进制数据的运算成本.

# Shared Array Buffers

Shared Array Buffer (opens new window) 是共享内存, 线程A 和线程B 可以同时访问和操作同一块内存空间. 数据都共享了, 也就没有传输什么事了。

webworker5

但多个并行的线程共享内存, 会产生竞争问题(Race Conditions). 不像前 2 种传输方式默认加锁, Shared Array Buffers 把难题抛给开发者, 开发者可以用 Atomics (opens new window) 来维护这块共享的内存. 作为较新的传输方式, 浏览器兼容性可想而知。

# 传输方式小结

  • 全浏览器兼容的 Structured Clone 是较好的选择, 但要考虑数据传输规模, 下文我们会详细展开.
  • Transfer Memory 的兼容性也不错(IE11+), 但数据独占和数据类型的限制, 使得它是特定场景的最优解, 不是通用解;
  • Shared Array Buffers 当下糟糕的兼容性和线程锁的开发成本, 建议先暗中观察

使用 Structured Clone 传输数据时, 有个阴影一直笼罩着我们: postMessage 前要不要对数据 JSON.stringify 一把, 听说那样更快?

相同的数据规模, 直接 postMessage 的传输时间普遍比 JSON.stringify 更少。当下, 不需要再使用 JSON.stringify. 其一是 Structured Clone 内置的 serialize/deserializeJSON.stringify 性能更高; 其二是 JSON.stringify 只适合序列化基本数据类型, 而 Structured Clone 还支持复制其他内置数据类型(如 Map, Blob, RegExp 等, 虽然大部分应用场景只用到基本数据类型)

Structured Cloneserialize/deserialize 执行耗时主要受数据对象复杂度影响, 这很好理解, 因为 serialize/deserialize 至少要以某种方式遍历对象. 数据对象的复杂度本身难以度量, 可以用序列化后的数据规模(size)作为参考。

总之, 数据传输规模并没有最佳实践. 而是充分理解 Worker postMessage 的传输成本, 在实际应用中, 根据业务场景去评估和控制数据规模。

# 应用场景

# 减少卡顿

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

# 性能提升

# 逻辑异步化

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

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

不是所有 JS 逻辑都可拆分. 比如数组排序, 树的递归查找, 图像处理算法等, 执行中需要维护当前状态, 且调用上非线性, 无法轻易地拆分为子任务。

可以拆分的逻辑难以把控粒度. 拆分的子任务在高性能机器(iphoneX)上可以控制在 16ms 内, 但在性能落后机器(iphone6)上就超过了 deadline. 16ms 的用户感知时间, 并不会因为用户手上机器的差别而变化, Google 给出的建议是再拆小到 3-4ms。

拆分的子任务并不稳定. 对同步 JS 逻辑的拆分, 需要根据业务场景寻找原子逻辑, 而原子逻辑会跟随业务变化, 每次改动业务都需要去 review 原子逻辑。

# Worker 一步到位

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

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

# 性能提升

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

# 把主线程还给 UI

Worker 的应用场景, 本质上是从主线程中剥离逻辑, 让主线程专注于 UI 渲染. 这种架构设计并非 Web 技术上的独创。

Android 和 iOS 的原生开发中, 主线程负责 UI 工作 (opens new window); 前端领域热门的小程序, 实现原理上就是渲染和逻辑的完全分离 (opens new window)

Web Worker 现状 (opens new window)

Web Worker 文献综述 (opens new window)

一文搞懂 Web Worker(原理到实践) (opens new window)

梳理 Web Worker 及实战场景 (opens new window)

浅探 Web Worker 与 JavaScript 沙箱 (opens new window)

一文彻底了解Web Worker,十万、百万条数据 (opens new window)

将 useReducer 应用于 Web Worker (opens new window)

换一种风格理解 Chrome 浏览器渲染全过程 (opens new window)