作者:jolamjiang,腾讯 WXG 前端开发工程师

一篇关于 Web Worker、SharedArrayBuffer、Atomics 的文章。

为什么要多线程编程

大家看到文章的标题《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:

查看视频,点击 Demo 的在线地址 自己来试试吧。

Web Worker

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

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

main.js

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

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 一份再传给主线程。

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

SharedArrayBuffer

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

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

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

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

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

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

Atomics

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

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 大概是这样的:

let mutex = new Lock(shareArrayBuffer, ...);
mutex.lock();
doSomething();
mutex.unlock();

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

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

  1. UNLOCK: 未锁定

  2. LOCKED: 被锁定

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

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

加锁 lock()

  1. 初始状态为UNLOCK: 锁未被抢占,将状态扭转为 LOCKED,线程进行后续操作。

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

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

释放 unlock()

1.初始状态为LOCKED: 锁被抢占且未被等待,将状态扭转为 UNLOCK,线程进行后续操作。

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

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

// 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 anyif (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(): 唤醒一个正在等待态的线程。

具体使用方法如下:

// 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 态,重新唤醒一个处于等待态的线程,然后进行后续操作。

异步锁

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

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

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

demo 实现

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

如图所示,在线解压缩这个 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)。

另外微信支付行业缴费、微信支付支付分行业招聘前端和后台工程师,在这里你可以:

挂个微信支付招聘

  • 微信支付行业缴费开发工程师(深圳)

  • 微信支付分行业Web前端工程师

  • 微信支付分行业后台开发工程师(深圳)

Javascript 多线程编程​的前世今生相关推荐

  1. Web Worker javascript多线程编程(一)

    什么是Web Worker? web worker 是运行在后台的 JavaScript,不占用浏览器自身线程,独立于其他脚本,可以提高应用的总体性能,并且提升用户体验. 一般来说Javascript ...

  2. JavaScript多线程编程介绍

    综述:两种JavaScript多线程编程的方法,在以后的具有大量的数据计算的业务场景下可能会使用到 1.背景介绍 JavaScript 语言采用的是单线程模型,也就是说,所有任务只能在一个线程上完成, ...

  3. JavaScript 异步编程的前世今生(下)

    ES6 中的 Generator 在 ES6 出现之前,基本都是各式各样类似Promise的解决方案来处理异步操作的代码逻辑,但是 ES6 的Generator却给异步操作又提供了新的思路,马上就有人 ...

  4. javaScript 多线程并行编程 施工中~

    关键词:javascript 单线程本质,html5,worker,nodejs 封面P站画师ID:12191398 查看全文 http://www.taodudu.cc/news/show-4981 ...

  5. 深入理解javascript异步编程障眼法h5 web worker实现多线程

    0.从一道题说起 var t = true; setTimeout(function(){ t = false; }, 1000); while(t){ } alert('end'); 1 2 3 4 ...

  6. HTML5之Javascript多线程

    Javascript执行机制      在HTML5之前,浏览器中JavaScript的运行都是以单线程的方式工作的,虽然有多种方式实现了对多线程的模拟(例如:Javascript 中的 setint ...

  7. 写给初学者的JavaScript异步编程和背后思想

    导读: 对于接触JavaScript这门编程语言没有多久的本菜鸡而言,在相当长的一段时间内,我都完全无法理解这门语言中的异步编程,不明白什么叫异步编程以及为什么需要异步编程.为什么顺序执行程序就不行了 ...

  8. 浅谈.Net异步编程的前世今生----APM篇

    前言 在.Net程序开发过程中,我们经常会遇到如下场景: 编写WinForm程序客户端,需要查询数据库获取数据,于是我们根据需求写好了代码后,点击查询,发现界面卡死,无法响应.经过调试,发现查询数据库 ...

  9. 对比Java和.NET多线程编程

    这篇文章以对比的方式总结Java和.NET多线程编程. 基本概念 多线程:很多开发语言都提供多线程编程支持,比如Java,C#. 并发(concurrent):即使对于单核CPU,我们也会采用多线程等 ...

最新文章

  1. 服务发现对比:Zookeeper vs. etcd vs. Consul
  2. jq常用过滤器_Jquery过滤器
  3. imdb导mysql_keras如何导入本地下载的imdb数据集?
  4. 强制浏览器使用兼容模式,Web.config,httpProtocol
  5. caffe 关于Deconvolution的初始化注意事项
  6. 前置交换机数据交换_我们的数据科学交换所
  7. WinAPI: PolylineTo - 绘制一组连续线段(更新当前位置)
  8. php 创建zip,如何使用PHP ZipArchive创建zip文件并下载?
  9. 【利用FLASH制作交互式课件】
  10. 计算机控制系统编程语言有哪些,PLC编程语言有哪些种类
  11. 阿里云学生服务器购买教程(附用途案例)
  12. 概率论与数理统计,基础知识、公式、定理、概念(一)
  13. WINVNC源码阅读(一)
  14. C#拆分PPT、PPT转视频、PPT转图片
  15. All One Needs to Know about Metaverse
  16. maltab-图像拼接(左右两幅图)
  17. java 中文转码_java 下载文件中文名称转码详解
  18. win键失效、win+e、win+r等及桌面左下角开始点击无响应
  19. 家里摄像头密码忘记找回
  20. 智鼎逻辑推理题及答案_安庆北森测评104题库系统

热门文章

  1. CSS中的选择器之类选择器和id选择器
  2. 我们工作的意义到底在哪?
  3. docker基础用法
  4. NSIS打包软件(一)
  5. Windows命令行下的进程管理
  6. 在 TreeView 控件中显示分层数据
  7. CodeForces - 1287C Garland(贪心)
  8. BZOJ3209(n的二进制表示中1的个数的乘积)
  9. 指令和伪指令和宏指令区别
  10. PyCairo 中的形状和填充