专用工作者线程

「这是我参与11月更文挑战的第9天,活动详情查看:2021最后一次更文挑战」。

基本概念

可以把专用工作者线程称为后台脚本(background script)。JavaScript 线程的各个方面,包括生命周 期管理、代码路径和输入/输出,都由初始化线程时提供的脚本来控制。该脚本也可以再请求其他脚本, 但一个线程总是从一个脚本源开始。

创建专用工作者线程

创建专用工作者线程最常见的方式是加载 JavaScript 文件。把文件路径提供给 Worker 构造函数, 然后构造函数再在后台异步加载脚本并实例化工作者线程。传给构造函数的文件路径可以是多种形式。

下面的代码演示了如何创建空的专用工作者线程:

emptyWorker.js
// 空的 JS 工作者线程文件
main.js
console.log(location.href); // "https://example.com/"
const worker = new Worker(location.href + 'emptyWorker.js');
console.log(worker); // Worker {}
复制代码

这个例子非常简单,但涉及几个基本概念。

  • emptyWorker.js 文件是从绝对路径加载的。根据应用程序的结构,使用绝对 URL 经常是多余的。
  • 这个文件是在后台加载的,工作者线程的初始化完全独立于 main.js。
  • 工作者线程本身存在于一个独立的 JavaScript 环境中,因此 main.js 必须以 Worker 对象为代理实

现与工作者线程通信。在上面的例子中,该对象被赋值给了 worker 变量。

  • 虽然相应的工作者线程可能还不存在,但该 Worker 对象已在原始环境中可用了。

前面的例子可修改为使用相对路径。不过,这要求 main.js 必须与 emptyWorker.js 在同一个路径下:

const worker = new Worker('./emptyWorker.js');
console.log(worker); // Worker {}
复制代码

工作者线程安全限制

工作者线程的脚本文件只能从与父页面相同的源加载。从其他源加载工作者线程的脚本文件会导致错误,如下所示:

// 尝试基于 https://example.com/worker.js 创建工作者线程
const sameOriginWorker = new Worker('./worker.js');
// 尝试基于 https://untrusted.com/worker.js 创建工作者线程
const remoteOriginWorker = new Worker('https://untrusted.com/worker.js');
// Error: Uncaught DOMException: Failed to construct 'Worker':
// Script at https://untrusted.com/main.js cannot be accessed
// from origin https://example.com
复制代码

注意 不能使用非同源脚本创建工作者线程,并不能影响执行其他源的脚本。在工作者线程 内部,使用 importScripts()可以加载其他源的脚本

基于加载脚本创建的工作者线程不受文档的内容安全策略限制,因为工作者线程在与父文档不同的 上下文中运行。不过,如果工作者线程加载的脚本带有全局唯一标识符(与加载自一个二进制大文件一 样),就会受父文档内容安全策略的限制。

使用 Worker 对象

Worker()构造函数返回的 Worker 对象是与刚创建的专用工作者线程通信的连接点。它可用于在 工作者线程和父上下文间传输信息,以及捕获专用工作者线程发出的事件。 注意 要管理好使用 Worker()创建的每个 Worker 对象。在终止工作者线程之前,它不 会被垃圾回收,也不能通过编程方式恢复对之前 Worker 对象的引用。 Worker 对象支持下列事件处理程序属性。

  • onerror:在工作者线程中发生 ErrorEvent 类型的错误事件时会调用指定给该属性的处理程序。
    • 该事件会在工作者线程中抛出错误时发生。
    • 该事件也可以通过 worker.addEventListener('error', handler)的形式处理。
  • onmessage:在工作者线程中发生 MessageEvent 类型的消息事件时会调用指定给该属性的处

理程序。 - 该事件会在工作者线程向父上下文发送消息时发生。 - 该事件也可以通过使用 worker.addEventListener('message', handler)处理。

  • onmessageerror:在工作者线程中发生 MessageEvent 类型的错误事件时会调用指定给该属

性的处理程序。 - 该事件会在工作者线程收到无法反序列化的消息时发生。 - 该事件也可以通过使用 worker.addEventListener('messageerror', handler)处理。 Worker 对象还支持下列方法。

  • postMessage():用于通过异步消息事件向工作者线程发送信息。
  • terminate():用于立即终止工作者线程。没有为工作者线程提供清理的机会,脚本会突然停止。

DedicatedWorkerGlobalScope

在专用工作者线程内部,全局作用域是 DedicatedWorkerGlobalScope 的实例。因为这继承自 WorkerGlobalScope,所以包含它的所有属性和方法。工作者线程可以通过 self 关键字访问该全局 作用域。

globalScopeWorker.js

console.log('inside worker:', self);
复制代码

main.js

const worker = new Worker('./globalScopeWorker.js');
console.log('created worker:', worker);
// created worker: Worker {}
// inside worker: DedicatedWorkerGlobalScope {}
复制代码

如此例所示,顶级脚本和工作者线程中的 console 对象都将写入浏览器控制台,这对于调试非常 有用。因为工作者线程具有不可忽略的启动延迟,所以即使 Worker 对象存在,工作者线程的日志也会 在主线程的日志之后打印出来。 注意 这里两个独立的 JavaScript 线程都在向一个 console 对象发消息,该对象随后将消 息序列化并在浏览器控制台打印出来。浏览器从两个不同的 JavaScript 线程收到消息,并 按照自己认为合适的顺序输出这些消息。为此,在多线程应用程序中使用日志确定操作顺 序时必须要当心。 DedicatedWorkerGlobalScope 在 WorkerGlobalScope 基础上增加了以下属性和方法。

  • name:可以提供给 Worker 构造函数的一个可选的字符串标识符。
  • postMessage():与 worker.postMessage()对应的方法,用于从工作者线程内部向父上下

文发送消息。

  • close():与 worker.terminate()对应的方法,用于立即终止工作者线程。没有为工作者线

程提供清理的机会,脚本会突然停止。

  • importScripts():用于向工作者线程中导入任意数量的脚本。

专用工作者线程与隐式 MessagePorts

专用工作者线程的 Worker 对象和 DedicatedWorkerGlobalScope 与 MessagePorts 有一些相 同接口处理程序和方法:onmessage、onmessageerror、close()和 postMessage()。这不是偶然 的,因为专用工作者线程隐式使用了 MessagePorts 在两个上下文之间通信。

父上下文中的 Worker 对象和 DedicatedWorkerGlobalScope 实际上融合了 MessagePort,并 在自己的接口中分别暴露了相应的处理程序和方法。换句话说,消息还是通过 MessagePort 发送,只 是没有直接使用 MessagePort 而已。

也有不一致的地方,比如 start()和 close()约定。专用工作者线程会自动发送排队的消息,因 此 start()也就没有必要了。另外,close()在专用工作者线程的上下文中没有意义,因为这样关闭 MessagePort 会使工作者线程孤立。因此,在工作者线程内部调用 close()(或在外部调用 terminate())不仅会关闭 MessagePort,也会终止线程。

专用工作者线程的生命周期

调用 Worker()构造函数是一个专用工作者线程生命的起点。调用之后,它会初始化对工作者线程 脚本的请求,并把 Worker 对象返回给父上下文。虽然父上下文中可以立即使用这个 Worker 对象,但 与之关联的工作者线程可能还没有创建,因为存在请求脚本的网格延迟和初始化延迟。

一般来说,专用工作者线程可以非正式区分为处于下列三个状态:初始化(initializing)、活动(active) 和终止(terminated)。这几个状态对其他上下文是不可见的。虽然 Worker 对象可能会存在于父上下文 中,但也无法通过它确定工作者线程当前是处理初始化、活动还是终止状态。换句话说,与活动的专用 工作者线程关联的 Worker 对象和与终止的专用工作者线程关联的 Worker 对象无法分别。

初始化时,虽然工作者线程脚本尚未执行,但可以先把要发送给工作者线程的消息加入队列。这些 消息会等待工作者线程的状态变为活动,再把消息添加到它的消息队列。下面的代码演示了这个过程。

initializingWorker.js
self.addEventListener('message', ({data}) => console.log(data));
main.js
const worker = new Worker('./initializingWorker.js');
// Worker 可能仍处于初始化状态
// 但 postMessage()数据可以正常处理
worker.postMessage('foo');
worker.postMessage('bar');
worker.postMessage('baz');
// foo
// bar
// baz 
复制代码

创建之后,专用工作者线程就会伴随页面的整个生命期而存在,除非自我终止(self.close()) 或通过外部终止(worker.terminate())。即使线程脚本已运行完成,线程的环境仍会存在。只要工 作者线程仍存在,与之关联的 Worker 对象就不会被当成垃圾收集掉。

自我终止和外部终止最终都会执行相同的工作者线程终止例程。来看下面的例子,其中工作者线程 在发送两条消息中间执行了自我终止:

closeWorker.js
self.postMessage('foo');
self.close();
self.postMessage('bar');
setTimeout(() => self.postMessage('baz'), 0);
main.js
const worker = new Worker('./closeWorker.js');
worker.onmessage = ({data}) => console.log(data);
// foo
// bar
复制代码

虽然调用了 close(),但显然工作者线程的执行并没有立即终止。close()在这里会通知工作者线 程取消事件循环中的所有任务,并阻止继续添加新任务。这也是为什么"baz"没有打印出来的原因。工 作者线程不需要执行同步停止,因此在父上下文的事件循环中处理的"bar"仍会打印出来。 下面来看外部终止的例子。

terminateWorker.js
self.onmessage = ({data}) => console.log(data);
main.js
const worker = new Worker('./terminateWorker.js');
// 给 1000 毫秒让工作者线程初始化
setTimeout(() => {
 worker.postMessage('foo');
 worker.terminate();
 worker.postMessage('bar');
 setTimeout(() => worker.postMessage('baz'), 0);
}, 1000);
// foo 
复制代码

这里,外部先给工作者线程发送了带"foo"的 postMessage,这条消息可以在外部终止之前处理。 一旦调用了 terminate(),工作者线程的消息队列就会被清理并锁住,这也是只是打印"foo"的原因。

注意 close()和 terminate()是幂等操作,多次调用没有问题。这两个方法仅仅是将 Worker 标记为 teardown,因此多次调用不会有不好的影响。

在整个生命周期中,一个专用工作者线程只会关联一个网页(Web 工作者线程规范称其为一个文档)。 除非明确终止,否则只要关联文档存在,专用工作者线程就会存在。如果浏览器离开网页(通过导航或 关闭标签页或关闭窗口),它会将与其关联的工作者线程标记为终止,它们的执行也会立即停止。

配置 Worker 选项

Worker()构造函数允许将可选的配置对象作为第二个参数。该配置对象支持下列属性。

  • name:可以在工作者线程中通过 self.name 读取到的字符串标识符。
  • type:表示加载脚本的运行方式,可以是"classic"或"module"。"classic"将脚本作为常

规脚本来执行,"module"将脚本作为模块来执行。

  • credentials:在 type 为"module"时,指定如何获取与传输凭证数据相关的工作者线程模块

脚本。值可以是"omit"、"same-orign"或"include"。这些选项与 fetch()的凭证选项相同。 在 type 为"classic"时,默认为"omit"。

注意 有的现代浏览器还不完全支持模块工作者线程或可能需要修改标志才能支持。

在 JavaScript 行内创建工作者线程

工作者线程需要基于脚本文件来创建,但这并不意味着该脚本必须是远程资源。专用工作者线程也 可以通过 Blob 对象 URL 在行内脚本创建。这样可以更快速地初始化工作者线程,因为没有网络延迟。 下面展示了一个在行内创建工作者线程的例子。

// 创建要执行的 JavaScript 代码字符串
const workerScript = `
 self.onmessage = ({data}) => console.log(data);
`;
// 基于脚本字符串生成 Blob 对象
const workerScriptBlob = new Blob([workerScript]);
// 基于 Blob 实例创建对象 URL
const workerScriptBlobUrl = URL.createObjectURL(workerScriptBlob);
// 基于对象 URL 创建专用工作者线程
const worker = new Worker(workerScriptBlobUrl);
worker.postMessage('blob worker script');
// blob worker script
复制代码

在这个例子中,通过脚本字符串创建了 Blob,然后又通过 Blob 创建了对象 URL,最后把对象 URL 传给了 Worker()构造函数。该构造函数同样创建了专用工作者线程。

在工作者线程中动态执行脚本

工作者线程中的脚本并非铁板一块,而是可以使用 importScripts()方法通过编程方式加载和执 行任意脚本。该方法可用于全局 Worker 对象。这个方法会加载脚本并按照加载顺序同步执行。比如, 下面的例子加载并执行了两个脚本:

main.js
const worker = new Worker('./worker.js');
// importing scripts
// scriptA executes
// scriptB executes
// scripts imported
scriptA.js
console.log('scriptA executes');

scriptB.js
console.log('scriptB executes');
worker.js
console.log('importing scripts');
importScripts('./scriptA.js');
importScripts('./scriptB.js');
console.log('scripts imported');
复制代码

importScripts()方法可以接收任意数量的脚本作为参数。浏览器下载它们的顺序没有限制,但 执行则会严格按照它们在参数列表的顺序进行。因此,下面的代码与前面的效果一样:

console.log('importing scripts');
importScripts('./scriptA.js', './scriptB.js');
console.log('scripts imported');
复制代码

脚本加载受到常规 CORS 的限制,但在工作者线程内部可以请求来自任何源的脚本。这里的脚本导 入策略类似于使用生成的

main.js
const worker = new Worker('./worker.js', {name: 'foo'});
// importing scripts in foo with bar
// scriptA executes in foo with bar
// scriptB executes in foo with bar
// scripts imported
scriptA.js
console.log(`scriptA executes in ${self.name} with ${globalToken}`);
scriptB.js
console.log(`scriptB executes in ${self.name} with ${globalToken}`);
worker.js
const globalToken = 'bar';
console.log(`importing scripts in ${self.name} with ${globalToken}`);
importScripts('./scriptA.js', './scriptB.js');
console.log('scripts imported'); 
复制代码

委托任务到子工作者线程

有时候可能需要在工作者线程中再创建子工作者线程。在有多个 CPU 核心的时候,使用多个子工 作者线程可以实现并行计算。使用多个子工作者线程前要考虑周全,确保并行计算的投入确实能够得到 收益,毕竟同时运行多个子线程会有很大计算成本。

除了路径解析不同,创建子工作者线程与创建普通工作者线程是一样的。子工作者线程的脚本路径 根据父工作者线程而不是相对于网页来解析。来看下面的例子(注意额外的 js 目录):

main.js
const worker = new Worker('./js/worker.js');
// worker
// subworker
js/worker.js
console.log('worker');
const worker = new Worker('./subworker.js');
js/subworker.js
console.log('subworker');
复制代码

顶级工作者线程的脚本和子工作者线程的脚本都必须从与主页相同的源加载

处理工作者线程错误

如果工作者线程脚本抛出了错误,该工作者线程沙盒可以阻止它打断父线程的执行。如下例所示, 其中的 try/catch 块不会捕获到错误:

main.js
try {
 const worker = new Worker('./worker.js');
 console.log('no error');
} catch(e) {
 console.log('caught error');
}
// no error
worker.js
throw Error('foo');
复制代码

不过,相应的错误事件仍然会冒泡到工作者线程的全局上下文,因此可以通过在 Worker 对象上设 置错误事件侦听器访问到。下面看这个例子:

main.js
const worker = new Worker('./worker.js');
worker.onerror = console.log;
// ErrorEvent {message: "Uncaught Error: foo"}
worker.js
throw Error('foo');
复制代码

与专用工作者线程通信

与工作者线程的通信都是通过异步消息完成的,但这些消息可以有多种形式。

使用 postMessage()

最简单也最常用的形式是使用 postMessage()传递序列化的消息。下面来看一个计算阶乘的例子:

factorialWorker.js
function factorial(n) {
 let result = 1;
 while(n) { result *= n--; }
 return result;
}
self.onmessage = ({data}) => {
 self.postMessage(`${data}! = ${factorial(data)}`);
};
main.js
const factorialWorker = new Worker('./factorialWorker.js');
factorialWorker.onmessage = ({data}) => console.log(data);
factorialWorker.postMessage(5);
factorialWorker.postMessage(7);
factorialWorker.postMessage(10);
// 5! = 120
// 7! = 5040
// 10! = 3628800
复制代码

对于传递简单的消息,使用 postMessage()在主线程和工作者线程之间传递消息,与在两个窗口 间传递消息非常像。主要区别是没有 targetOrigin 的限制,该限制是针对 Window.prototype. postMessage 的,对 WorkerGlobalScope.prototype.postMessage 或 Worker.prototype. postMessage 没有影响。这样约定的原因很简单:工作者线程脚本的源被限制为主页的源,因此没有 必要再去过滤了。

使用 MessageChannel

无论主线程还是工作者线程,通过 postMessage()进行通信涉及调用全局对象上的方法,并定义 一个临时的传输协议。这个过程可以被 Channel Messaging API 取代,基于该 API 可以在两个上下文间明 确建立通信渠道。

MessageChannel 实例有两个端口,分别代表两个通信端点。要让父页面和工作线程通过 MessageChannel 通信,需要把一个端口传到工作者线程中,如下所示:

worker.js
// 在监听器中存储全局 messagePort
let messagePort = null;
function factorial(n) {
 let result = 1;
 while(n) { result *= n--; }
 return result;
}
// 在全局对象上添加消息处理程序
self.onmessage = ({ports}) => {
 // 只设置一次端口
 if (!messagePort) {
     // 初始化消息发送端口,
     // 给变量赋值并重置监听器
     messagePort = ports[0];
     self.onmessage = null;
     // 在全局对象上设置消息处理程序
     messagePort.onmessage = ({data}) => {
         // 收到消息后发送数据
         messagePort.postMessage(`${data}! =${factorial(data)}`);
    };
 }
};
main.js
const channel = new MessageChannel();
const factorialWorker = new Worker('./worker.js');
// 把`MessagePort`对象发送到工作者线程
// 工作者线程负责处理初始化信道
factorialWorker.postMessage(null, [channel.port1]);
// 通过信道实际发送数据
channel.port2.onmessage = ({data}) => console.log(data);
// 工作者线程通过信道响应
channel.port2.postMessage(5);
// 5! = 120
复制代码

使用 BroadcastChannel

同源脚本能够通过 BroadcastChannel 相互之间发送和接收消息。这种通道类型的设置比较简单, 不需要像 MessageChannel 那样转移乱糟糟的端口。这可以通过以下方式实现:

main.js
const channel = new BroadcastChannel('worker_channel');
const worker = new Worker('./worker.js');
channel.onmessage = ({data}) => {
 console.log(`heard ${data} on page`);
}
setTimeout(() => channel.postMessage('foo'), 1000);
// heard foo in worker
// heard bar on page
worker.js
const channel = new BroadcastChannel('worker_channel'); 
channel.onmessage = ({data}) => {
 console.log(`heard ${data} in worker`);
 channel.postMessage('bar');
}
复制代码

这里,页面在通过 BroadcastChannel 发送消息之前会先等 1 秒钟。因为这种信道没有端口所有 权的概念,所以如果没有实体监听这个信道,广播的消息就不会有人处理。在这种情况下,如果没有 setTimeout(),则由于初始化工作者线程的延迟,就会导致消息已经发送了,但工作者线程上的消息 处理程序还没有就位。

结构化克隆算法

结构化克隆算法可用于在两个独立上下文间共享数据。该算法由浏览器在后台实现,不能直接调用。 在通过 postMessage()传递对象时,浏览器会遍历该对象,并在目标上下文中生成它的一个副本。 下列类型是结构化克隆算法支持的类型。

  • 除 Symbol 之外的所有原始类型
  • Boolean 对象
  • String 对象
  • BDate
  • RegExp
  • Blob
  • File
  • FileList
  • ArrayBuffer
  • ArrayBufferView
  • ImageData
  • Array
  • Object
  • Map
  • Set

关于结构化克隆算法,有以下几点需要注意。

  • 复制之后,源上下文中对该对象的修改,不会传播到目标上下文中的对象。
  • 结构化克隆算法可以识别对象中包含的循环引用,不会无穷遍历对象。
  • 克隆 Error 对象、Function 对象或 DOM 节点会抛出错误。
  • 结构化克隆算法并不总是创建完全一致的副本。
  • 对象属性描述符、获取方法和设置方法不会克隆,必要时会使用默认值。
  • 原型链不会克隆。
  • RegExp.prototype.lastIndex 属性不会克隆

结构化克隆算法在对象比较复杂时会存在计算性消耗。因此,实践中要尽可能避免 过大、过多的复制。

可转移对象

使用可转移对象(transferable objects)可以把所有权从一个上下文转移到另一个上下文。在不太可 能在上下文间复制大量数据的情况下,这个功能特别有用。只有如下几种对象是可转移对象:

  • ArrayBuffer
  • MessagePort
  • ImageBitmap
  • OffscreenCanvas

SharedArrayBuffer

注意 由于 Spectre 和 Meltdown 的漏洞,所有主流浏览器在 2018 年 1 月就禁用了 SharedArrayBuffer。从 2019 年开始,有些浏览器开始逐步重新启用这一特性。 既不克隆,也不转移,SharedArrayBuffer 作为 ArrayBuffer 能够在不同浏览器上下文间共享。 在把 SharedArrayBuffer 传给 postMessage()时,浏览器只会传递原始缓冲区的引用。结果是,两 个不同的 JavaScript 上下文会分别维护对同一个内存块的引用。每个上下文都可以随意修改这个缓冲区, 就跟修改常规 ArrayBuffer 一样。

线程池

因为启用工作者线程代价很大,所以某些情况下可以考虑始终保持固定数量的线程活动,需要时就 把任务分派给它们。工作者线程在执行计算时,会被标记为忙碌状态。直到它通知线程池自己空闲了, 才准备好接收新任务。这些活动线程就称为“线程池”或“工作者线程池”。

线程池中线程的数量多少合适并没有权威的答案,不过可以参考 navigator.hardware Concurrency 属性返回的系统可用的核心数量。因为不太可能知道每个核心的多线程能力,所以最好把这个数字作为 线程池大小的上限。

一种使用线程池的策略是每个线程都执行同样的任务,但具体执行什么任务由几个参数来控制。通 过使用特定于任务的线程池,可以分配固定数量的工作者线程,并根据需要为他们提供参数。工作者线 程会接收这些参数,执行耗时的计算,并把结果返回给线程池。然后线程池可以再将其他工作分派给工 作者线程去执行。接下来的例子将构建一个相对简单的线程池,但可以涵盖上述思路的所有基本要求。

首先是定义一个 TaskWorker 类,它可以扩展 Worker 类。TaskWorker 类负责两件事:跟踪线程 是否正忙于工作,并管理进出线程的信息与事件。另外,传入给这个工作者线程的任务会封装到一个期 约中,然后正确地解决和拒绝

草率地采用并行计算不一定是最好的办法。线程池的调优策略会因计算任务不同和 系统硬件不同而不同。

猜你喜欢

转载自juejin.im/post/7033949469532127263