浏览器多线程-Web Worker

在浏览器中必须通过 Web Worker 使用多线程。Web Worker 是 HTML5 中新增的概念,在 Web Worker 线程中可以运行任何 JavaScript 代码(和主线程不同,在 Web Worker 线程中不能直接操作 DOM 节点,也不能使用 window 对象的默认方法和属性,不过可以使用其他 window 对象下的东西,包括 WebSockets、IndexedDB 及 Data Store API 等数据存储机制)。

Web Worker的工作原理

Web Worker 使用起来非常简单,需要先在主线程中通过 new Worker(脚本路径)语句创建一个 Worker 线程,然后在主线程中和在 Worker 线程中都可以使用 postMessage(消息)方法向另一个线程发送消息,使用 onmessage(事件参数)事件函数接收另一个线程发来的消息。

例如,主线程可以使用 postMessage(消息)方法来向 Worker 线程发送消息,而 Worker 线程使用 onmessage(event) 事件函数接收消息(消息包含在 onmessage(event) 事件函数参数的 data 属性中),然后进行运算处理,最后将处理结果通过 postMessage(消息)方法发送给主线程,而主线程同样使用 onmessage(event) 事件函数接收消息。主线程和 Worker 线程的通信机制如图17-1所示。

image 2024 02 19 22 25 30 595
Figure 1. 图17-1 主线程和Worker线程的通信机制

根据 Worker 线程是否被其他多个线程共享,Worker 线程分为专用 Worker 线程和共享 Worker 线程。专用 Worker 线程只有一个父线程,共享 Worker 线程可以有多个父线程。

专用Worker线程

基本使用

在正式介绍专用 Worker 线程前,我们先通过单线程模式执行一个需要大量运算的 JavaScript 任务,该任务的代码如下。

console.time("主线程占用时长");
let runTimes = 300000000;
let result = 0;
for (let i = 0; i < runTimes; i++) {
    result += i;
}
console.timeEnd("主线程占用时长")
console.log("计算结果: "+result);

这段代码会计算 1+2+3+…+300000000 的和。打开浏览器开发工具,执行以上代码,会发现在运算期间整个浏览器都陷入了卡顿,单击页面上的按钮没有任何反应,运算完成后输出结果如下,整个主线程被占用了超过 27s,在此期间浏览器页面完全无法操作。

> 主线程占用时长: 27486.993896484375 ms
> 计算结果: 4499999997067114000

下面将这段代码改写为多线程模式,由 Worker 线程来进行大量运算。

主线程为 a.ts 文件,其代码如下。

console.time("主线程占用时长");
let worker = new Worker("b.js");
worker.onmessage = function (event) {
    console.log('计算结果: ' + event.data);
}
let runTimes = 300000000;
worker.postMessage(runTimes);
console.timeEnd("主线程占用时长");

a.ts 文件中先实例化一个 Worker 对象,该对象使用 b.js 文件(b.ts 文件编译后为 b.js 文件),然后为 Worker 对象指定 onmessage() 事件函数,该函数有一个 event 参数,用来接收来自 b.js 文件的消息,通过 event.data 属性获取并输出 b.js 文件返回的计算结果,最后调用 Worker 对象的 postMessage() 方法,将运算参数传递给 b.js 文件。

Worker 线程为 b.ts 文件,其代码如下。

self.onmessage = function (event) {
    console.time("Worker线程占用时长");
    let result = 0;
    for (let i = 0; i < event.data; i++) {
        result += i;
    }
    self.postMessage(result);
    console.timeEnd("Worker线程占用时长");
}

b.ts 文件为 self 对象(self 代表子线程自身,即子线程的全局对象)指定了 onmessage() 事件函数,该函数同样先使用 event.data 获取另一个线程传来的值,并将其作为运算参数,计算 1+2+3+…+event.data 的和,计算完成后,再调用 self 对象的 postMessage() 方法,将运结果传递给主线程。

然后,再创建一个 test.html 文件,用来引用并执行 a.js。test.html 文件的内容如下。

<script src="a.js"></script>

根据浏览器跨域安全策略,无法直接打开 test.html 文件来运行 a.js 文件。要解决这个问题,你可以在本地架设 Web 服务器,用浏览器访问 Web 服务器的形式访问此 HTML 页面。

live-server 是一个具有实时加载功能的小型服务器工具,你可以通过它架设临时 Web 服务器。首先,执行以下命令安装 live-server。

$ npm install -g live-server

安装完成后,在 HTML 页面所在的目录下启动 live-server,命令如下。

$ live-server

live-server 启动后,会默认使用 8080 端口架设服务器。此时就用浏览器访问本机 8080 端口下的 test.html 页面,如图17-2所示。

image 2024 02 19 22 34 18 808
Figure 2. 图17-2 用浏览器访问Web服务器的形式获取test.html页面

打开浏览器开发工具,在控制台中可以看到代码执行结果。

> 主线程占用时长: 0.174072265625 ms
> Worker线程占用时长: 29707.93701171875 ms
> 计算结果: 44999999767108860

可以看到,通过执行多线程任务,主线程只占用了的 0.00017s,浏览器上没有任何卡顿,所有的计算都交给了子线程,约 29s 后,子线程向主线程返回了计算结果,执行了回调函数,在控制台输出了计算结果。

错误处理

主线程可以监听 Worker 线程是否出错。如果出错,Worker 线程会触发主线程中 Worker 对象的 onerror 事件,并将与错误相关的信息放置到事件参数的以下几个属性中。

  • message:错误消息。

  • filename:发生错误的脚本文件名。

  • lineno:错误在脚本文件中的行的编号。

接下来是错误处理的示例。主线程是 a.ts 文件,其代码如下。

let worker = new Worker("b.js");
worker.onmessage = function (event) {
    console.log(`Result is "${event.data}"`);
};
worker.onerror = function (event) {
    console.log("message: " + event.message);
    console.log("filename: " + event.filename);
    console.log("lineno: " + event.lineno);
}
worker.postMessage(null);

其中,为 Worker 对象指定了 onerror() 事件函数,用于输出与错误相关的信息。

Worker 线程是 b.ts 文件,其代码如下。

self.onmessage = function (event) {
    throw new Error("Something wrong!");
};

其中,为 self 对象指定了 onmessage() 事件函数,在函数中刻意抛出了一个自定义错误。

运行 Web 服务器,访问 test.html 页面,在控制台中可以看到代码执行结果。

> message: Uncaught Error: Something wrong!
> filename: http://127.0.0.1:8080/b.js
> lineno: 2

关闭线程

当子线程的任务执行完毕后,子线程并未关闭,而处于闲置状态,以等待下一次任务。为了节省资源,应该及时关闭不再使用的子线程。

在主线程中,使用以下代码关闭子线程。

worker.terminate();

在子线程中,也可以关闭子线程自身,使用以下代码即可。

self.close();

引入其他脚本

在主线程中可以使用 ECMAScript 5 的模块导入(import)与导出(export)语句,但 Worker 线程存在限制,无法使用模块导入与导出语句。

要解决这个问题,可以在 Worker 线程中使用 self.importScripts(脚本地址)方法引入外部资源,该方法支持多个参数。以下示例代码都是合法调用的。

self.importScripts();               //不引入任何脚本
self.importScripts('c.js');         //引入c.js文件
self.importScripts('c.js','d.js');  //同时引入引入c.js和d.js文件

使用这种方式引入外部脚本文件,相当于把外部脚本文件和当前脚本文件合成一个脚本文件来执行,因此各个文件中所有的声明都是共享的。

使用多个Worker线程

在主线程中,同时开启多个 Worker 线程,可以提高并发计算能力,示例代码如下。

let worker1 = new Worker('worker1.js');
let worker2 = new Worker('worker2.js');
let worker3 = new Worker('worker3.js');
...

不仅在主线程中可以开启 Worker 线程,而且在 Worker 线程中可以嵌套开启 Worker 子线程,示例代码如下。

a.ts 文件为主线程,代码如下。

let worker = new Worker('b.js');
...

b.ts 文件为 Worker 线程,代码如下。

let worker = new Worker('c.js');
...

c.ts 文件为 Worker 线程,代码如下。

let worker = new Worker(d.js');
...

共享Worker线程

专用 Worker 线程只有一个父线程,且每个 Worker 对象都是独立线程,即使两个 Worker 对象使用同一个脚本文件,它们也是属于两个不同的线程的,数据无法共享。

假设 a.ts 文件的内容如下,在代码中声明了两个 Worker 对象,都引用了 b.js 文件,用于输出访问 b.js 文件的次数。

let worker1 = new Worker('b.js');
let worker2 = new Worker('b.js');

function showmsg(event) {
    console.log("访问b.js次数:" + event.data);
}

worker1.onmessage = showmsg;
worker2.onmessage = showmsg;

worker1.postMessage("");
worker2.postMessage("");

b.ts 文件的内容如下。

let visitCount = 0;
self.onmessage = function (event) {
    visitCount++;
    self.postMessage(visitCount);
}

在代码中声明了一个全局变量 visitCount,每触发一次 onmessage 事件,全局变量 visitCount 就会加 1,并将全局变量 visitCount 的值返回主线程。

运行 Web 服务器,访问 test.html 页面,在控制台中可以看到代码执行结果。由于两个 Worker 对象分别属于两个不同的 Worker 线程,因此它们拥有各自独立的存储空间,不会互相干扰。此时,新打开一个浏览器标签页,访问 test.html 页面,由于两个页面共有 4 个独立的 Worker 线程,因此新打开的页面控制台依然会输出以下结果。

> 访问b.js次数:1
> 访问b.js次数:1

要想对同一个文件使用同一个线程,就需要使用共享 Worker 对象。共享 Worker 对象可以被多个父线程同时使用。

在父线程中共享 Worker 对象和在专用 Worker 对象在使用上存在以下区别。

  • 共享 Worker 对象通过 SharedWorker 关键字实例化。

  • 共享 Worker 对象不能直接设置事件或进行操作,设置事件和进行操作都要基于 Port 对象。

当使用共享 Worker 对象时,a.ts 文件的内容如下。

let worker1 = new SharedWorker('b.js');
let worker2 = new SharedWorker('b.js');

function showmsg(event) {
    console.log("访问b.js次数:" + event.data);
}
worker1.port.onmessage = showmsg;
worker2.port.onmessage = showmsg;

worker1.port.postMessage("");
worker2.port.postMessage("");

在代码中声明了两个共享 Worker 对象,都引用了 b.js 文件,用于输出访问 b.js 文件的次数,它们的区别在于使用了 SharedWorker 关键字,且 onmessage() 事件函数和 postMessage() 方法都基于共享 Worker 对象的 port 属性。

在子线程中共享 Worker 对象和在专用 Worker 对象在使用上存在以下区别。

共享 Worker 对象无法直接设置事件或进行操作,需要在 self 对象中指定 onconnect() 事件函数,在该函数中通过 event.ports[0] 来获取 Port 对象,并在该 Port 对象上设置事件和进行操作。

当使用共享 Worker 对象时,b.ts 文件的内容如下。

let visitCount = 0;

self.onconnect = function (connectEvent) {
    let port = connectEvent.ports[0];
    port.onmessage = function (msgEvent) {
        visitCount++;
        port.postMessage(visitCount);
    }
}

在代码中声明了一个全局变量 visitCount,每当有一个父线程连接到该子线程时,都会触发 onconnect() 事件函数,为对应端口绑定 onmessage() 事件函数。每当触发 onmessage() 事件函数时,全局变量 visitCount 就会加 1,并将 visitCount 返回主线程。

之后运行 Web 服务器,访问 test.html 页面,在控制台中可以看到代码执行结果。由于两个 Worker 对象都指向同一个 Worker 线程,因此 visitCount 变量是公用的,输出结果如下。

> 访问b.js次数:1
> 访问b.js次数:2

此时,新打开一个浏览器标签页,访问 test.html 页面,由于两个页面共有 4 个共享 Worker 对象,但都指向同一个线程,因此新打开的页面的控制台会输出以下结果。

> 访问b.js次数:3
> 访问b.js次数:4

Worker线程间的数据传递

由于多线程之间的数据传递是通过深拷贝而不是共享来实现的,因此传到另一个线程的数据与原始数据并非同一份数据。浏览器内部的运行机制是先将需要传递的内容序列化,然后把序列化后的字符串发给另一个线程,并在该线程中反序列化。

在同一个线程中,一个引用类型的值可以传递给多个函数,并在不同的函数中修改,由于引用类型的地址指向同一处,因此修改的始终是同一个对象。但在多线程中,数据传递是通过深拷贝实现的,因此传递后的数据和原始数据已经不是同一份数据了。以下示例代码将很好地说明此问题。

a.ts 文件的代码如下。

var worker = new Worker("b.js");
let person1 = { name: "Nick", age: 17, isMale: true }
worker.onmessage = function (event) {
    console.log(event.data);
    console.log(person1);
}
worker.postMessage(person1);

以上代码创建了一个名为 person1 的对象,其 name 属性为 Nick,并将其传给 Worker 线程,线程处理完之后,将输出处理后的结果及 person1 的值。

b.ts 文件的代码如下。

self.onmessage = function (event) {
    event.data.name = "Worker";
    self.postMessage(event.data);
}

该文件接受来自父线程的对象,并将其 name 属性修改为 Worker,最后再返回父线程。

执行 a.ts 文件的结果如下。

> {name: 'Worker', age: 17, isMale: true}
> {name: 'Nick', age: 17, isMale: true}

event.data 的 name 属性为 Worker,person1 的 name 属性为 Nick,两者是独立的对象,对各自的编辑互不干扰。若要在不同线程之间实现共享数据,你可以使用 Transferable 对象。这种方式主要采用二进制的存储方式来解决数据交换的实时性问题。Transferable 对象支持的常用数据类型有 ArrayBuffer 和 ImageBitmap,由于本身较复杂,使用场景并不多(通常用在影像处理或 3D 运算等场景中),因此本章不多做介绍,感兴趣的读者可以自行了解。