JavaScript的多线程方案

背景

Javascript是单线程+事件循环+异步IO模型,这种模型带来的天生缺陷有:1. 无法利用多核CPU的优势 2. 阻塞UI响应 3. Timer不能做到足够精确。
同时,针对异步IO所带来的Callback Hell问题,催生了Promise、asyn/await、协程等解决方案。但是这些方案并没有解决多线程的问题。
以下是从现有的方案中挑选的几种比较有代表性的方案。

官方实现方案

实现进度

目前Firefox Chrome Safari Edge均已实现Web Worker。只有Firefox和Chrome实现了Shared Worker。

应用场景

This allows for long-running scripts that are not interrupted by scripts that respond to clicks or other user interactions, and allows long tasks to be executed without yielding to keep the page responsive.
W3官方所给出的场景是:1. 长时间执行,也即意味着worker是一个重量级的解决方案。 2. 不负责响应用户操作,worker是真正的线程,多线程操作UI是GUI编程中的大忌(对此存疑),而且从编译/解释器实现上来说,最简单的方案是起一个单独的线程,创建新的Isolate实例和新的事件循环。 3. 不阻塞页面,显然,这是催生所有多线程方案的直接背景。
给出的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// main.html
var worker = new Worker('worker.js');
worker.onmessage = function (event) {
document.getElementById('result').textContent = event.data;
};
// worker.js
var n = 1;
search: while (true) {
n += 1;
for (var i = 2; i <= Math.sqrt(n); i += 1)
if (n % i == 0)
continue search;
// found a prime!
postMessage(n);
}

通信/共享

postMessage + onmessage本质上属于异步通信,设计理念接近于Golang或者Erlang中的消息复制优于资源共享哲学。个人认为,这种设计的优点是屏蔽了传统的多线程模型编程中的同步细节,postMessage这个API设计的足够简洁,提供了一个较为普遍场景下的并行方案,同时也兼顾了js的异步风格。但这同时也是缺陷:首先,简单的消息传递不适合大块数据的共享,worker和主线程之间不存在共享变量。我在工作中处理过视频帧的编解码,由Worker线程处理好的视频帧数据通过postMessage传递到主线程,然后渲染到canvas上,过程中这个开销是比较大的;其次,Web Worker没有提供足够的同步原语,比如Mutex,用户无法精确控制线程的执行(无法通过Browser Context去postMessage通知worker暂停和恢复,因为现有的方案无法实现最基本的sleep原语。)相比Golang,Golang中虽然也推荐消息传递进行goroutine间通信,但是Golang的消息机制本质上是基于共享内存的,此外,Golang中的消息机制也不能说是异步消息,Golang中提供了suspend/recover机制,因此更接近于协程的概念。(Erlang这门语言我不熟悉,就略过了)
区别于上文中的Worker,官方还定义了SharedWorker。SharedWorker和Worker的关系类似于进程间的Anonymous Pipe和Named Pipe。这个与主题相关性不大。

结论

Web Worker可以说解决了一部分并行计算的需求,但是在数据共享和精确控制线程生命周期方面存在缺陷。

实现进度

SharedArrayBufer已进入ES2017标准。目前各主流浏览器都实现或部分实现,但是均保持默认关闭该特性。(UC Browser倒是实现并开启了)
Atomics仍然处于草案阶段,目前Firefox和Chrome已经实现。

应用场景

SharedArrayBuffer提供了多个Agent(在浏览器中表现为BrowserContext或者WebWorker)之间的数据共享方案。
官方指出的应用场景是:

Support for threaded code in programs written in other languages that are translated to asm.js or plain JS or a combination of the two, notably C and C++ but also other, safe, languages.
Support for hand-written JS or JS+asm.js that makes use of multiprocessing facilities for select tasks, such as image processing, asset management, or game AI.
即:1. 为其它语言转译到JavaScript或WebAssembly提供语义上的支撑。2. 为并行处理重量级的运算(游戏AI)和IO场景(如媒体处理)提供支撑,这一点弥补了WebWorker的大块数据共享方面的缺陷。

Atomics提供了内存的原子操作,也提供了一般意义上的mutex(Atomics.isLockFree)、条件变量(Atomics.wait/Atomics.wake),除此之外也提供了CAS操作(Atomic.compareExchange)以实现lock-free风格的同步方案。可以说是基本补全了WebWorker的同步原语。

第三方方案

  • Napa.js
    Napa.js是微软出产的一个多线程方案,作为NodeJS扩展形式。其中提出的Zone,从概念上可以看作NodeJS中的goroutine:不可对指定特定的线程进行特殊操作,屏蔽了内部的调度细节。从实现上来看,Napa Zone中的工作线程是真正的物理线程,每个线程拥有属于自己的Isolate对象,也因此,Napa.js对Napa Zone中的线程做了限制,只能访问部分Node API,以避免这些工作线程对主线程的事件循环产生干扰。

  • NodeJS的cluster
    使用多进程代替多线程是一种比较常规的思路,但是相比于Erlang的轻量级进程,NodeJS的进程还是太重了。

通信/同步

Napa.js定义了两个API,broadcastexcecute用于主线程控制Napa Zone中的工作线程。broadcast用来对某个zone中的所有线程做统一的状态管理,excecute用于交付实际的计算任务。从这个角度来说还有点像PHP的Swoole框架:通过主线程进行事件监听,在zone中进行具体的业务逻辑的处理。
在数据通信方面,Napa.js中的线程之间可以通过transportAPI传递内置类型或者实现了Transpotable接口的聚合类型。但是transport这个API比较丑陋,变量的传递还要是通过mashall这个过程。
此外也提供了多个线程共享同一个store的方式以实现共享内存。不过文档并不推荐使用store,我猜是因为其内部使用了读写锁之类的机制实现原子操作。

Napa.js是一个比较新的项目,目前并没有见到有什么大规模的应用。

结论

除了以上几种方案,还有一些用于特定场景的伪多线程方案。结论而言,值得期待的是ES2017的普及,Web Worker + SharedArrayBuffer + Atomics足以用在通用场景下。