利用 Web Worker 在浏览器里让 JavaScript「多线程」

Spencer Woo
SpencerWeekly
Published in
10 min readJul 27, 2019

JavaScript 是一门优雅的语言。我们都知道 JavaScript 是一门单线程的语言,也就是说我们 JS 代码中所有任务都会在一个 thread 里面解决。有时候,「单线程」异步执行方式会给我们带来很多好处,但是有些任务并不适合「单线程」执行。

任务的分类

在正文开始之前,我们先来了解一下在编码过程中我们可能会遇到的诸多任务种类。对于一般性质的任务来说,我们往往可以将它们分为:

  • I/O 密集型任务(I/O Intensive Tasks):比如请求网络、读写文件、读写数据库
  • CPU 密集型任务(CPU Intensive Tasks):比如进行复杂算法的执行 — — 密码学中的加密算法等(以及「挖矿」)
CPU Intensive vs I/O Intensive Tasks

起因

在密码学里面,非常多的任务是「CPU 密集型」的。我以我这次比赛的具体情况为例子:

项目:匿名课程评价系统

架构

FATES: The Fully Anonymous Teaching Evaluation System
  • 客户端 Client:学生用户
  • 匿名身份认证 AIP:Anonymous Identity Provider
  • 匿名课程评价 TES:Teaching Evaluation System

听说过 OPAAK 的同学应该知道,上面的架构就是:Client(客户端)、AIP 和 Relying Party(我们系统里面 TES)的体现。我们要通过 CL 签名以及零知识证明等密码学原理来让 AIP 和 Relying Party 即使将数据合在一起也无法得知用户 Client 的具体身份。

其中客户端向 TES 证明身份的步骤里,有一部分「零知识证明」的算法需要在客户端(网页)进行,这部分算法的计算过程非常耗时,在没做优化之前往往需要 2–3 秒的时间,这些时间足够让网页卡一下,反馈到用户层面就是:点哪里都没反应。这不能忍啊。

为什么?

对于「I/O 密集型任务」,由于我们在处理任务的时候需要等待数据的返回(我们可以将它称为 callback),因此我们完全不必将自己阻塞在原地,而是可以继续往下处理其他任务。当我们请求的数据返回了,我们就可以(放下手中的活)继续处理这个数据相关的任务。

JS 单线程的多任务机制就是为这类任务而生的。在 JS 中,多项任务是「异步」处理的,对于 I/O 密集型任务,JS 原生就会通过 async/await 以及 Promise 等机制处理,这种机制让 I/O 密集型任务不会阻塞浏览器中运行的 JS Event Loop(可以理解为 UI 进程),等待 callback 即可。

JavaScript deal with tasks asynchronously

「CPU 密集型任务」则不然,由于 JS 需要直接的处理(运算)CPU 密集型任务,此时这部分运算只能在 Event Loop 中进行处理,因此即使 CPU 密集型任务只需要 1–2 秒的运算时间,反映到 UI 上面也会造成整个界面的卡顿和无响应。如果任务处理时间过长,浏览器可能直接报出「网页无响应」的错误。

解决方案

CPU 密集型任务对于我们传统桌面客户端来说,已经是老生常谈的问题了。我们最为方便的解决方法就是另起一个线程,让任务在新线程中进行计算,得到结果之后再反馈给 UI 线程即可。

在 JS 的世界里,上面的解决方法则是行不通的。包括浏览器和 Node.js 的 JS 环境都是单线程的。JS 语言特性让多线程不可能实现。但是:承载浏览器运行的环境本身,包括承载 Node.js 运行的环境本身,它们都不是单线程的 —— 操作系统不是单线程的。因此我们到这里就有下面这几种解决办法:

  1. 将 CPU 密集型任务放到服务端进行运算,浏览器通过网络请求进行任务执行和数据获取
  2. 在浏览器中,使用 Web Worker 将任务放到「新线程」中处理

实际上第二种方法就是我这个项目中使用的办法 —— Web Worker。我们可以理解为浏览器给 JS 提供了另一个线程,让 CPU 密集型任务可以不在 Event Loop 中进行处理。

实际应用

基本使用

Web Worker 实际上分为 Dedicated Worker 和 Shared Worker 两种,功能如其名。下面我主要介绍的是 Dedicated Worker。

A Worker can only be accessed from the script that created it, a Shared Worker can be accessed by any script that comes from the same domain.

在一个普通的 HTML5 页面中,调用 Web Worker 非常简单:

其中 worker.js 是一个单独的文件,我们将「复杂任务」封装在另外一个文件中,通过 Web Worker 进行调用即可。创建 Web Worker 的代码和 Web Worker 之间是通过 postMessage() 方法以及 onmessage 事件进行互相沟通的。比如在主线程代码中,我们通过这样的方式发送消息(这里是发送一个 array):

myWorker.postMessage([first.value, second.value])
console.log('Message posted to worker')

在 Web Worker 的 worker.js 中,我们就可以这样接收消息 e,并再通过 postMessage 将消息发送回主线程:

onmessage = e => {
console.log('Message received from main script')
var workerResult = 'Result: ' + (e.data[0] * e.data[1])
console.log('Posting message back to main script')
postMessage(workerResult)
}

回到主线程代码,我们再次接收 Web Worker 发给我们的消息:

myWorker.onmessage = e => {
result.textContent = e.data
console.log('Message received from worker')
}

非常简单吧?但是 Web Worker 如果想要使用外部模块,比如 npm 中的模块,就需要 importScripts() 等等奇形怪状的东西了。如果使用了 Webpack,那么配置就更为复杂。但是我们现如今大部分前端框架都会使用 Webpack 来打包我们的项目,那么如何才能方便的让 Webpack 将 Web Worker 中使用的模块打包起来呢?

在 Webpack 项目中使用

简单来说,我们需要借助于:GitHub — developit/workerize-loader: 🏗️ Automatically move a module into a Web Worker (Webpack loader) 这一模块打包 Web Worker 中使用的库。接下来我结合我这次项目的具体代码进行说明。这次项目前端我使用的技术栈是:

  • Vue.js 作为前端整体框架
  • Vuetify 作为前端 CSS 框架

项目代码部分的大致文件结构如下:

src
├── App.vue
├── assets
│ ├── ...
├── components
│ ├── ...
│ └── Contents.vue
├── credentials.js
├── main.js
├── ...
└── views
└── ...

其中,项目调用复杂算法的部分位于 Contents.vue 中。算法本身我封装在了 credentials.js 里面,大致长这样:

我们首先在项目中安装 workerize-loader

yarn add workerize-loader

之后,在 Contents.vue 中引入模块:

import worker from 'workerize-loader!./../credentials'

可以看到,后面的路径是相对路径,需要正确引用才能访问 Web Worker。

之后,在 Contents.vue 需要调用算法的部分:

可以看到 credentialWorker.generate() 返回的实际上是一个 Promise,因此我们可以非常方便的通过 .then().catch() 等等来处理返回数据。

使用 Web Worker 需要注意的事情

虽然 Web Worker 看起来非常好,让 JS 也能「多线程」了,但是实际上浏览器环境里面的 Web Worker 有非常多的局限性。简单来说,Web Worker:

  • 不能直接访问 DOM 元素:不能直接加载图片、不能创建 canvas 元素并用 Web Worker 绘制
  • 不能直接调用 alert() 或者 confirm() 函数
  • 不能直接访问 windowparent 对象等,因此也不能直接访问 localStorage

所以说,使用 Web Worker 的最佳时机是:需要处理复杂且耗时的算法计算时。

其他

接下来跟 Web Worker 没什么大关系了。

前面我介绍了:将复杂的算法计算放到服务端是最好、最方便的解决方案。但是我并没有这样做。

为什么我不能将这部分计算放到服务端?

简单来说是我本次项目的架构和原理导致的。首先,项目架构有三个基本实体:

  • 客户端(学生用户)
  • AIP(服务端):提供匿名身份
  • TES(服务端):提供评教服务

客户端用户拥有一个 uk,相当于 user secret key,在 AIP 处我们会对 uk 进行盲化签名。之后,在评教时,我们需要让客户端向 TES 证明自己的合法性,从而获取评教任务、提交评教结果。

在向 TES 证明身份前,客户端拥有:用户身份 uk 以及 AIP 对 uk 签名之后得到的 (s,e,v),这两个都是重要的用户匿名身份凭证,但是这两个参数我们都不能直接提供给 TES,我们需要对这两个参数进行「盲化」,通过零知识证明来验证身份。盲化的算法非常复杂,需要对四位数长度的大整数进行 50 余次的「快速幂取模」,也就是我们项目中比较耗时,也必须在客户端进行的计算。

显而易见,我们的服务端就是 TES,那么我当然也不可能将这部分运算放在 TES 中进行。

对了,除了通过「多线程」或者将计算放到服务端,还有其他方法吗?

还有别的解决方案吗?

有。Chrome 等浏览器的普遍 UI 渲染帧率是 60fps,也就是浏览器对每一帧画面的渲染工作要在 16ms 内完成。如果我们将算法的计算时间优化至 16ms 以下,就可以让 UI 界面流畅渲染了。

Render frame

到这里就是我的学弟的处理方法了:他使用 Rust 和 Web Assembly 将算法优化为 30ms 左右,这是在加密算法的参数为 4096 位时的平均计算时间,如果我们稍微将加密参数降低,就完全可能将这部分计算直接放在 Event Loop 中进行。(但是最后还没有这样重构。)

参考与延伸阅读

鞠躬。🙇‍♂️

--

--