Javascript 多线程编程的前世今生

什么要多线程编程

大家看到文章的标题《Javascript 多线程编程》可能立马会产生疑问:Javascript 不是单线程的吗?Javascript IO 阻塞和其他异步的需求(例如 setTimeout, Promise, requestAnimationFrame, queueMicrotask 等)不是通过事件循环(Event Loop)来解决的吗?

没有错,Javascript 的确是单线程的,阻塞和其他异步的需求的确是通过实现循环来解决的,但是这套机制当线程需要处理大规模的计算的时候就不大适用了,试想一下一下的场景:

  1. 你需要实现对文件的加解密。
  2. 你的 VirtualDom 树有很多元素(例如上万个),你需要对这棵树进行 Diff 操作。
  3. 你需要在浏览器“挖矿”。

上面这些场景都会阻塞主线程,也就是当进行这些操作的时候,你的页面是卡住的,设置当页面卡住一段时间之后,Chrome 等浏览器或者操作系统会建议你 Kill 掉整个 Tab 或者进程。这显然不是我们想看到的事情。正因为这些场景的存在,浏览器提出了 W3C 在 2013 年提出了 Web Worker 草案,这个草案的提出就是为了解决上述这些问题。

为了让大家感受 JS 多线程能够干什么,笔者写了一个基于 Web Worker(线程)、ShareArrayBuffer(共享内存)、Atomics(锁)等 Web API 的在前端压缩和解压文件(基于 DEFLATE 算法)的 demo:

在线地址

Web Worker

Chrome 浏览器中每个 Tab 都是一个进程,每个进程都会有一个主线程,网页的渲染(Style, Layout, Paint, Composite)会在主线程进行操作。主线程可以发起多个 Web Worker,Web Worker 对应“线程”的概念。

Graph 1

每个 Web Worker 都对应一个脚本文件,主线程可以通过像以下的代码去发起多个 Web Worker,并且通过基于事件的 API 与 Web Worker 通信:

main.js

1
2
3
4
5
let worker = new Worker("work.js");
worker.postMessage("Hello World");
worker.onmessage = function (event) {
console.log("Received message " + event.data);
}

Web Worker 也通过相应的实现 API 与主线程进行通信

worker.js

1
2
3
this.addEventListener("message", function (e) {
this.postMessage("You said: " + e.data);
}, false);

Web Worker 通讯的效率与同步问题

主线程与 Web Worker 通过 postMessage(data: any) 通信的时候,data 会先被 copy 一份再传给 Web Worker;同样地,当 Web Worker 通过 postMessage(data: any) 与主线程通信的时候,data 也会同样先被 copy 一份再传给主线程。

Graph 2

这样做显然会导致通信上的效率问题,试想一下你需要在 Web Worker 里面解压一个 1G 大小的问题,你需要把整个 1G 的文件 copy 到 Web Worker 里,Web Worker 解压完这个 1G 文件后,再把解压完的文件 copy 回主线程里。

SharedArrayBuffer

为了解决通讯效率问题,浏览器提出了 ShareArrayBuffer,ShareArrayBuffer 基于 ArrayBuffer 和 TypedArray API。ArrayBuffer 对应一段内存(二进制内容),为了操作这段内存,浏览器需要提供一些视图(Int8Array 等),例如把这段内存可以当做每 8 位一个单元的 byte 数组,每 16 位一个单元的 16 位有符号数数组。

Graph 3

* 注意:ArrayBuffer 中的二进制流被翻译成各种视图的时候采用小端还是大端是由具体硬件决定的,绝大部分情况下是采用小端字节顺序

这段内存可以在不同的 Worker 之间共享,但是内存的共享又会产生另外的问题,也就是竞争的问题(race conditions):

计算机指令对内存操作进行运算的时候,我们可以看做分两步:一是从内存中取值,二是运算并给某段内存赋值。当我们有两个线程对同一个内存地址进行 +1 操作的时候,假设线程是先后顺序运行的,为了简化模型,我们可以如下图表示:

Graph 4

上面两个线程的运行结果也符合我们的预期,也即线程分别都对同一地址进行了 +1 操作,最后得到结果 3。但因为两个线程是同时运行的,往往会发生下图所表示的问题,也即读取与写入可能不在一个事务中发生:

Graph 5

这种情况就叫做竞争问题(Race Condition)。

Atomics

为了解决上述的竞争问题,浏览器提供了 Atomics API,这组 API 是一组原子操作,可以将读取和写入绑定起来,例如下图中的 S1 到 S3 操作就被浏览器封装成 Atomics.add() 这个 API,从而解决竞争问题。

Graph 6

Atomics API 具体包含:

  1. Atomics.add()
  2. Atomics.and()
  3. Atomics.compareExchange()
  4. Atomics.exchange()
  5. Atomics.isLockFree()
  6. Atomics.load()
  7. Atomics.notify()
  8. Atomics.or()
  9. Atomics.store()
  10. Atomics.sub()
  11. Atomics.wait()
  12. Atomics.xor()

有了这套 API,我们可以实现像 Golang 中的 Golang Synchronization Primitives 的功能。Mutex 和 Cond 的实现会在下面介绍。

WebAssembly

有了 SharedArrayBuffer 和 Atomics 能力之后,证明浏览器能够提供内存共享和锁的实现了,也就是说 WebAssembly 线程在浏览器机制上能够高效地得到保证。

其实我严重怀疑 SharedArrayBuffer 和 Atomics 是为了支持 WebAssembly 才把 API 顺便提供给 JS Runtime 的,因为目前为止没有看到 ES 有比较丰富的关于锁的草案(例如像 Java 中的 synchronized 关键字)。

Mutext 和 Cond 的实现

上面提到了,基于 ShareArrayBuffer 和 Atomics 可以开发像 Golang Synchronization Primitives 一样的 API,下面介绍一下 Mutex 和 Cond 的实现。实现的介绍是基于 Mozzila Javascript 编译器工程师 Lars T Hansen 实现关于锁的库

Mutex

首先说一下 Mutex 的功能,Mutex 的 API 大概是这样的:

1
2
3
4
let mutex = new Lock(shareArrayBuffer, ...);
mutex.lock();
doSomething();
mutex.unlock();

Mutex 可以保证 lock()unlock() 之间的代码可以不被打断。下面是介绍具体实现:

首先定义 Mutex 的三个状态以及对应的状态机

  1. UNLOCK: 未锁定
  2. LOCKED: 被锁定
  3. WAITED: 被锁定且大于等于 1 个线程在等待该锁

Graph 7

对于 Worker 线程来说 Mutex 的每个状态都可能是初始态,状态与状态间扭转会产生一些操作且进入下一状态:

加锁 lock()

  1. 初始状态为UNLOCK: 锁未被抢占,将状态扭转为 LOCKED,线程进行后续操作。
  2. 初始状态为LOCKED: 锁已被抢占,将状态扭转为 WAITED,并将线程设置为等待态,并将线程设置为当锁的状态不为 WAITED 的时候可能被唤醒,一旦被唤醒则该线程拥有锁,线程进行后续操作。
  3. 初始状态为WAITED: 锁已被抢占,并将线程设置为等待态,并将线程设置为当锁的状态不为 WAITED 的时候可能被唤醒,一旦被唤醒则该线程拥有锁,线程进行后续操作。

释放 unlock()

1.初始状态为LOCKED: 锁被抢占且未被等待,将状态扭转为 UNLOCK,线程进行后续操作。
2. 初始状态为WAITED: 锁被抢占且被等待,将状态扭转为 LOCKED,唤醒一个在等待态的线程,线程进行后续操作。

上面描述的逻辑的对应的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// lock
Lock.prototype.lock = function () {
const iab = this._iab;
const stateIdx = this._ibase;
let c;
if ((c = Atomics.compareExchange(iab, stateIdx, 0, 1)) != 0) {
do {
if (c == 2 || Atomics.compareExchange(iab, stateIdx, 1, 2) != 0)
Atomics.wait(iab, stateIdx, 2);
} while ((c = Atomics.compareExchange(iab, stateIdx, 0, 2)) != 0);
}
}

// unlock
Lock.prototype.unlock = function () {
const iab = this._iab;
const stateIdx = this._ibase;
let v0 = Atomics.sub(iab, stateIdx, 1);
// Wake up a waiter if there are any
if (v0 != 1) {
Atomics.store(iab, stateIdx, 0);
Atomics.notify(iab, stateIdx, 1);
}
}

可以看到锁的实现用到了 Atomics.compareExchange()Atomics.wait()(相当于 Linux 中的 futex)两个原子操作。

Cond

Cond 是基于 Mutex 实现的,它的大致功能是持有锁的情况下可进行两种操作:

  1. wait(): 本线程进度进入等待态,并且被唤醒的时候重新持有锁。
  2. notifyOne(): 唤醒一个正在等待态的线程。

具体使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// thread A
var msg = new Int32Array(sab, msgLoc, 1);
lock.lock();
while (msg[0] < numWorkers)
cond.wait();
lock.unlock();

// thread B, C, D, E, …
var msg = new Int32Array(sab, msgLoc, 1);
lock.lock();
msg[0]++;
cond.notifyOne();
lock.unlock();

由于 Cond 是基于 Mutex,前置条件是持有锁,后置条件是释放锁,你可以看做 Cond 只有两个状态:

  1. NORMAL: 非等待态,调用 wait() 转化为 WAITED 状态,并把线程设置为等待态,并且被唤醒的时候重新持有锁,然后进行后续操作。
  2. WAITED: 等待态(不对应上述 Lock 的 WAITED 态),调用 notifyOne() 将状态设置为 NORMAL 态,重新唤醒一个处于等待态的线程,然后进行后续操作。

Graph 8

异步锁

上述介绍的锁都是同步的,Atomics.wait 不能在主线程使用,在主线程使用的话浏览器会抛出异常:

Uncaught TypeError: Atomics.wait cannot be called in this context

所以我们需要设计所谓的”异步锁“,所谓的异步锁原理很简单,就是将同步锁里面的 Atomics.wait() 操作交给一个新的线程,主线程和这个线程通过事件通信来异步化这里的操作。具体实现可以参照这个文件)。

demo 实现

介绍完上述的知识之后,就可以用相关的 API 就可以实现我们的 demo 了,首先画一下我们 demo 的架构图:

Graph 9

如图所示,在线解压缩这个 demo 主要分为两个线程:

  1. 主线程:负责调用 Dom API 等,主要负责 UI 更新。
  2. 工作线程:负责文件的压缩/解压。

两个线程间的通信是通过读写两段共享内存来实现的,对于共享内存的访问,通过锁来解决竞争问题。需要注意的是,主线程的写缓存也即工作线程的读缓存,反之亦然。

demo 的具体实现可以参照 demo 的 Github 地址

目前多线程编程的不足

目前只通过浏览器提供的 API 来进多线程开发的话成本非常大,主要有两方面问题:

过于底层的 API

  1. 需要你实现语言级、或者系统级的 lock API,参照 Golang 的 lock API。
  2. 没有语法上的支持,例如 Java synchronized 关键字等。

普通的 Javascript Object 无法共享

这其实也是 API 过于底层的另一方面的体现,也就是说对 JS 对象进行内存共享的话,你需要开辟一段 SharedArrayBuffer,然后在此之上实现对 JS 对象的序列化、反序列化、更新等操作,实现成本也是比较大的。

事实上我们也不应该轻易手动实现相关的库或者功能,因为相关领域的问题非常复杂、需要仔细的设计和实现。例如我们可以先使用下面这两个库:

  1. parlib-simple: 这个库里面有类似于 Golang 里面 channel 一样的 API。
  2. js-lock-and-condition: 这里库有 Mutex 和 Cond 实现。

总结

浏览器提供给了我们进行多线程的能力,例如 PWA 或者 WebAseembly 与 JS 混用等场景都会用到上述的机制,如果你想实现一个高性能的网页客户端程序(例如 Figma 一样的杀手级应用),你最好也用上上述的机制。值得注意的是,用了锁可能会降低你的程序的性能,具体要看线程切换和等待是的成本是否能够抵消内存拷贝的成本,例如 demo 完全可以改成无锁的,代价将文件内容拷贝到共享线程,并把工作线程的内容拷贝回主线程。

虽然上面建议不要轻易实现自己的库,例如上面的 lock 代码短短几行,但是其中的推导可以足够写十几页的 Paper 了,但是这里的基础能力很匮乏,据笔者了解,TC39 提案中鲜少出现关于多线程编程的提案,目前仅发现以下这个:

  1. proposal-atomics-wait-async

但是,如果自信有能力和时间建设这些基础能力的话,这个领域的确是“广阔天地,大有作为”,特别是如果你的项目准备用 WebAseembly 和 JS 混用的情况(例如 Figma 就是用了 WebAssembly 和 React)。