JavaScript之并发编程

581 阅读17分钟

一、为什么要并发

我们都知道,JavaScript 是一门很容易入门的编程语言(早期甚至被认为不是一门真正的编程语言 🤣),而之所以易上手,有这么几个方面:

  • 动态类型

  • 不需要配置复杂的运行环境,只需要一个浏览器

  • 单线程

  • ......

注意到上面加粗的字了吗?没错,就是单线程,正是这一个特点大大降低了 JavaScript 的复杂性,我们不用关心如何进行线程同步,不用关心如何避免线程死锁,代码就这么一行一行按顺序执行下来,简单明了。纵使有“异步”这一稍微复杂的概念,但多看两眼,也就那么回事。

那既然如此,我们为什么还要讨论 JavaScript 的并发,是单线程不好玩吗?时至今日, JavaScript 已经成为世界上最流行的编程语言,它支撑着越来越多的场景,例如可以用来处理音视频、图片,甚至是游戏引擎。像这类复杂的场景,对性能是有一定要求的,而本就不是以性能著称的 JavaScript ,使用单线程更是不能充分利用多核 CPU 的计算能力,甚至过于密集的计算会影响渲染线程的运行从而导致极差的用户体验。

为了响应时代的召唤,HTML5 提出了 Web Workers 标准,它允许用户使用 Web Workers 相关的 API 创建多个线程去运行脚本(真正的操作系统级别的线程)。看起来,如果用这个玩意儿在 Web 上进行并发编程会很酷,

又可以和身边的同事吹牛X了
可能还可以驱动业务的发展、带来更好的用户体验。带着这一单纯的目的,我们一起进入并发编程的世界去探索吧。

(注:本文中的示例均可在笔者的代码仓库下找到 )

二、基于消息通信的并发模型

常见的并发模型主要有两种:基于消息通信以及基于共享内存。共享内存是一种比较传统的并发模型,目前在其他编程语言中应用十分广泛,但由于涉及到共享内存,会引发出一些奇奇怪怪的问题(下一章节会细说)。而消息通信采用另一种方式避免了这些问题,也即数据所占用的内存不被共享,而是通过某种机制在线程之间传递。当前比较流行的消息通信并发模型有 Actor 以及 CSP , HTML5 提出的 Web Workers 也是基于消息通信的,有兴趣的同学可以去了解下这几种模型,会发现很多相似点。下图是 Web Workers 大致的通信模型:


其中 main 是 JavaScript 主线程,而 worker 是基于 Web Workers 创建出来的线程,从图中可以看到,线程间的通信主要通过两种渠道:1、通过 Message Channel 进行线程间的通信;2、通过 Broadcast Channel 进行广播。其中 Web Workers 内部就是用 Message Channel 进行双向通信的,由于 Broadcast 比较简单,本文就略过,主要讲下 Web Workers。

1、Web Workers

引用一段 MDN 上关于 Web Workers 的介绍:

通过使用Web Workers,Web应用程序可以在独立于主线程的后台线程中,运行一个脚本操作。这样做的好处是可以在独立线程中执行费时的处理任务,从而允许主线程(通常是UI线程)不会因此被阻塞/放慢。

简单明了,没有什么拗口的术语,而它的使用方式也是如此:

创建一个 worker :

// 创建 worker ,构造函数接受一个脚本的 URI,这样相当于新建了一个线程,用来运行 worker.js 。
const myWorker = new Worker('worker.js');

主线程与 worker 的通信:

其中主线程的代码如下:

const myWorker = new Worker('worker.js');
// 主线程向 worker 发送消息(postMessage 内部使用了 Message Channel)
myWorker.postMessage('hello worker!');
// 主线程监听来自 worker 发送的消息
myWorker.onMessage = function(e) {
  // 打印结果: hello main!
  console.log(e.data);
}

worker 的代码如下:

// 用来监听来自主线程的消息
onmessage = function(e) {
  // 打印结果: hello worker!
  console.log(e.data);
  // 使用全局作用域下的 postMessage 向主线程发送消息
  postMessage('hello main!');
}

2、一个小栗子

接下来我们通过一个简单的例子了解如何使用 Web Workers 进行并发:计算从 1....n 的和(大家假装不知道有 n*(n+1)/2 这个公式)。

单线程版本

为了体现 Web Workers 的强大,我们先来段常规的写法:

(() => {
  	//  记录 1...n 的和
  	let result = 0;
    // 定义 n 的值
    const n = Math.pow(2, 26);
  	// 记录开始时间
    const startTime = performance.now();

  	// 求和
    for(let i = 1; i <= n; i++) {
        result += i;
    }
  	// 打印所消耗的时间
    console.log(performance.now() - startTime);
})();

额外备注:可以看到代码被包含在IIFE(可立即执行的函数表达式)中,这样可以避免操作全局变量,而操作全局变量的开销是比较大的,在去掉IIFE的情况下,耗时约为 1s 。

最终耗时约 90ms (具体耗时视个人电脑配置而定)。

多线程版本

接着是 Web Workers 版本,主要流程为:

  1. 运行在主线程上的 main.js 负责用来创建与系统 cpu核心数 等同数量的 worker

  2. 拆解求和的任务,同样使拆解后的任务数量等同于 worker 数

  3. 将拆解好的任务发送给 worker 处理

  4. 最终由主线程收集并合并所有 worker 计算的结果

代码如下:

if (window.Worker) {
    // 记录 1...n 的和
    let result = 0;
    // 记录从 worker 拿到消息的次数
    let count = 0;
    // 开始时间
    let startTime;
     // 定义线程的数量,与 cpu 核心数相等
    const workNums =  navigator.hardwareConcurrency;
    // 定义 n 的值
    const n = Math.pow(2, 26);
    // 分解求和的任务
    const perSize = n / workNums;
    const onMessage = function({data}) {
        count++;
        result += data;
        // 确保收到所有的计算结果
        if(count === workNums) {
            // 打印耗时
            console.log('耗时为:', performance.now() - startTime);
        }
    }
    for(let i = 0; i < workNums; i++) {
        // 创建 worker ,构造函数接受一个 js 路径
        let myWorker = new Worker('worker.js');
        myWorker.onmessage = onMessage;
        // 向 worker 发送拆解好的任务
        myWorker.postMessage([i * perSize, (i + 1) * perSize]);
    }
  	// 记录开始时间
    startTime = performance.now();
} else {
	alert('你的浏览器不支持 Web Workers');
}

onmessage = function({data}) {
    const min = data[0];
    const max = data[1];
    let result = 0;
    for(let i = min + 1; i <= max; i++) {
        result += i;
    }
    postMessage(result);
}

在 cpu核心数 为 4 的时候,该版本耗时约为:55ms (去掉线程创建的开销后,耗时约为 27ms)。

总结

在上述例子中,多线程版本确实比单线程版本速度要快,但意料之外的是,没有想象中的快,按理说,单线程版本耗时 90ms 的话,四线程理论上应该在 90 / 4 = 22.5ms 左右。实际上,在使用多线程并发编程时,会存在一些因素影响着最终的效率,例如:

  1. 创建线程的开销

  2. 线程通信时进行的序列化与反序列化(在 Web Workers 中,线程之间传递的数据都会经过结构化拷贝而不是共享)

  3. 前置任务的开销,例如在多线程版本中,在计算开始前(即 worker.js 第 5 行代码前),我们仍需要定义一些局部变量,这些就算前置任务。前置任务的开销越大,最终的效率越低

  4. 线程的调度

当然,除了最后一点不可控之外,其他方面或多或少都可以进行一些优化。例如:在计算任务即将开始前,可以提前创建好 worker (上述多线程版本,如果去掉线程创建的时间,耗时可以控制在 27ms 左右,还是相当可观的);又或者消息传递的类型可以改为 Transferable (这用于传递对象的所有权,可以理解为传递对象的引用,只不过原先线程中的对象将不可使用,这也是为了避免共享内存)。

三、基于共享内存的并发模型

前面提到基于共享内存的并发模型会带来一些奇怪的问题,但由于该模型历史悠久,已经有相当多成熟的方案来解决此类问题,例如信号量、管程以及系统内核提供的同步原语,加上共享内存的方案在一些场景下更具优势,所以还是很有必要介绍下这种模型的。

1、SharedArrayBuffer

在 JavaScript ,用于提供共享内存的对象是 SharedArrayBuffer,以下是来自 MDN 的一段简介:

SharedArrayBuffer 对象用来表示一个通用的,固定长度的原始二进制数据缓冲区,类似于 ArrayBuffer 对象,它们都可以用来在共享内存(shared memory)上创建视图。与 ArrayBuffer 不同的是,SharedArrayBuffer 不能被分离。

实际上, SharedArrayBuffer 并不能直接操作,而是要通过 TypedArray 来操作(或者 DataView)。TypedArray 也不是一个可以直接使用的全局对象, 而是一类对象的统称,其中包含 Int8ArrayUint8Array 等一些表示特定元素类型的类数组视图。下面我们用一个例子来简单说明 SharedArrayBuffer 的使用:

// 创建一个大小为 32 字节的 sab
const sab = new SharedArrayBuffer(32);
// 从 sab 的第 0 个字节开始,取长度为 1 字节的内存,创建一个能表示 8 位二进制带符号整数的类数组视图
const typedArray = new Int8Array(sab, 0, 1);
// 创建一个视图,用来操作 sab
var dataView = new DataView(sab);

typedArray[0] = 8;
// 打印 sab 索引为 0 的值, 打印出来为 8 
console.log(dataView.getInt8(0));

2、一个小栗子

接下来,我们用上个章节的例子,计算 1...n 的和,来说明如何用 SharedArrayBuffer 来进行基于共享内存的并发编程。同样,我们分为两个文件 main.js 和 worker.js :

if (window.Worker) {
    // 申请一个 12 字节大小的 sab
    const sab = new SharedArrayBuffer(8);
    // 用于记录在计算 1...n 过程中,当前待求和的值
    const countArray = new Int32Array(sab, 0, 1);
    // 记录 1...n 中 n 的值, 由于长度为 1 的 Int32Array 占用了 4 个字节,所以索引要从 4 开始
    const maxNumArray = new Int32Array(sab, 4, 1);
    // 记录 1...n 的和
    let result = 0;
    // 记录从 worker 拿到消息的次数
    let count = 0;
    // 开始时间
    let startTime;
    // 定义线程的数量,与 cpu 核心数相等
    const workNums = navigator.hardwareConcurrency;
  	// 记录所有 worker 数量
    const workers = [];

    countArray[0] = 0;
    maxNumArray[0] = Math.pow(2, 26);
    const onMessage = function ({data}) {
        count++;
      	// worker 创建完后,会向主线程发送 'ready'
        if(data === 'ready'){
            if(count === workNums) {
                count = 0;
                workers.forEach((worker, idx) => {
                    // 向 worker 发送共享的数据
                    worker.postMessage(sab);
                });
              	// 在这里记录开始时间,可以避免算上线程创建的开销
                startTime = performance.now();
            }
        } else {
            result += data;
            if(count === workNums) {
                console.log('耗时为:', performance.now() - startTime);
                console.log('和为:', result);
            }
        }
    };
    for (let i = 0; i < workNums; i++) {
        let myWorker = new Worker('worker.js');
        myWorker.onmessage = onMessage;
        workers.push(myWorker);
    }
} else {
    console.log('你的浏览器不支持 Web Workers');
}

onmessage = function ({data}) {
    const sab = data;
    const countArray = new Int32Array(sab, 0, 1);
    const maxNumArray = new Int32Array(sab, 4, 1);
    const maxNum = maxNumArray[0];
    let result = 0;

    while (true) {
        const count = countArray[0]++;
        if (count <= maxNum) {
            result += count;
        } else {
            break;
        }
    }
    postMessage(result);
};

postMessage('ready');

但代码跑起来之后,我们发现一个很奇怪的现象,计算出来的和,每次运行都不一样。相信大家也都怀疑到了 worker.js 中的第 8 行代码 ”

const count = countArray[0]++;
“,真相也的确如此,那这是为什么呢?我们都知道,JS 代码最终都要转成 CPU 能识别的机器指令,而
“const count = countArray[0]++;
" 这段 JS 代转换的结果大概如下:

  1. register <- countArray[0]

  2. register <- register + 1

  3. count <- register

由于 countArray 同时被多个线程读写,就可能会发生这么一个现象:当线程 1 执行到指令 1 ,由于各种原因发生中断,轮到线程 2 运行,线程 2 很幸运地执行完了这段代码,再轮到线程 1 的时候,register 的值已经不等于 countArray[0] 的值了,必然会导致运行结果的错误。

其中 countArray[0] 我们称之为临界资源(一次仅允许一个线程/进程使用的资源称为临界资源),而 countArray[0]++ 我们称之为临界区(访问临界资源的代码)。要解决上述问题,有一种思路是保持临界区对临界资源的互斥访问。而 JavaScript 提供了这种能力,这就是 Atomics。

3、Atomics

老样子,先来一段 MDN 简介:

Atomics 对象提供了一组静态方法用来对 SharedArrayBuffer 对象进行原子操作。

虽然描述挺简单的,但它的作用可不小。从 API 的层面来看,它提供了两大类:

读与写

Atomics 提供对 SharedArrayBuffer 原子操作的静态方法,包含了 add,sub,store,load 等。例如你要对 sab 某个位置上的值进行 +1:

const sab = new SharedArrayBuffer(4);
const ia = new Int32Array(sab,0,1);
ia[0] = 1;
// 对 sab 位置为 0 的元素进行 +1,打印出来的值为 1 ,也即操作之前的元素的值
console.log(Atomics.add(ia, 0, 1));
// 打印的值为 2 
console.log(Atomics.load(ia, 0));

线程的挂起与唤醒

Atomics 提供了 wait 和 notify 两个 API 可以对线程进行挂起与唤醒操作(主线程无法进行挂起):

// 当 ia[0] 上的值为 1 时,则挂起,等待其他线程唤醒
Atomics.wait(ia,0,1);
// 本例中,输出为 2
console.log(Atomics.load(ia, 0));

// 此时 ia[0] 上的值仍为 1,所以无法唤醒上面的 worker
Atomics.notify(ia,0,1);
// 对 ia[0] 加一
Atomics.add(ia,0,1);
// 唤醒 1 个在 ia[0] 位置上等待的线程,由于 ia[0] 已经不为 1,上面的 worker 被唤醒
Atomics.notify(ia,0,1);

另一方面,也使得此种并发模型的三要素有了保证:

内存可见性

指的是多个线程访问同一个变量时,如果其中一个线程对其作了修改,其他线程能立即获取到最新的值。在简单的场景下,通过 wait 和 notify 这两个 API ,即可保证一个线程对共享内存的修改,其他线程能够感知到。稍微高级点的用法,可以基于这俩实现信号量机制用于线程的同步(这儿有相应的实现,感兴趣的同学可以研究下)。

有序性

程序执行的顺序按照代码的先后顺序执行。通常我们的代码在经过编译时,会进行一些优化,可能会导致代码的顺序与我们原先写的不一致,在单线程的情况下,会确保执行结果的正确性。但在多线程下,可就不敢保证了。比如有如下代码段:

ia[1] = 123; // 假定原来的值是 1
ia[0] = 321; // 假定原来的值是 0

假设有在其他线程中,对这两个值进行了读取:

console.log(ia[0]);
console.log(ia[1]);

这可能会输出一些奇怪的结果,第一个输出 321 ,而第二个输出为 1 。因为编译器的优化,第一段代码的顺序可能发生了变化。而 Atomics 提供了类似 Memory barrier 的机制(Memory barrier 能够保证其之前的内存访问操作先于其后的完成):

ia[1] = 123;
Atomics.store(ia, 0, 321);

这样一来就能保证所有的写操作都会在 Atomics 的写操作之前完成。

原子性

指的是一个或者多个操作在 CPU 执行的过程中不被中断。Atomics 的读与写操作都具备原子性,不用担心竞态条件的发生。

4、小栗子的改进

Atomics 介绍了这么多,那要解决怎么上个小栗子遇到的问题呢?很简单,只需改动 worker.js 中的第 9 行代码:

// 对 countArray 索引为 0 的元素进行加 1,并返回相加前该元素的值。
const count = Atomics.add(countArray, 0, 1);

这样一来就能保证 countArray[0] 在同一时间最多只能有一个线程访问,最终的执行结果也就正确了。

但是!!!运行时间却有点让人疑惑,可以看下下面的表格(笔者使用的电脑 CPU 核心数为 4):

线程数

1

2

3

4

运行时间

943ms

3829ms

4882ms

5789ms

可以看到线程数量与运行时间成正比,这与第二章节中的栗子截然相反。其实前面咱已经有提到了,用 Atomics 对某个临界资源进行操作时,会保持临界区对临界资源的互斥访问,这就导致了某个线程在操作 countArray[0] 时,其他线程都会经历挂起->等待->唤醒这一流程,线程越多,这些额外开销自然越大。所以在日常使用中,我们应谨慎使用这些会阻塞其他线程的操作。那怎么优化呢?仍然可以参照第二章节中的栗子,对任务进行拆分,这样就无需使用 Atomics 的原子操作,这也意味着可以充分利用多核 CPU 的计算能力,相信聪明的童鞋们脑补下就会明白了🤪。

四、该用哪种模型

介绍了这么多,可能还是会有人想问,那我应该选用哪种并发模型呢?看起来,基于消息通信会更加简单、更加贴近我们程序员的思维,那除了这点之外,我们还需要考虑哪些方面呢?

1、数据一致性

举一个简单的例子:比如用户在某商城对一个订单进行了支付,此时会对用户的银行卡进行扣款,接着商家也会收到一笔账。虽然这个例子永远不可能发生在 JavaScript 的世界当中,但还是能借助这个例子说明下两个模型的区别。另外,我们对这个例子的实现添加一些说明:

  • 假设用户和商家用的不是一家银行

  • 银行对外提供了查询余额的接口

  • 商家与银行的业务逻辑需要分离

  • 可同时发起多笔支付

其中,基于消息通信的并发模型可以这么设计:


其中A、B银行的业务逻辑和数据都在自己 worker 中,那么在支付行为发生时,会分别给A、B银行发送通知进行扣款或者入账,这就可能会导致某一时刻下,查询用户与商家的余额,出现了不一致的现象。因为可能A银行对用户扣款成功了,但B银行还未完成入账。这种场景下,再怎么优化,也只能保证数据的最终一致性。

而如果基于共享内存,可以这么设计:


  1. 支付时(假设在 worker1),先对相应的数据进行加锁

  2. 通知A、B银行扣款/入账(可以用信号量进行同步),并在操作成功后通知 worker1

  3. worker1 收到A银行和B银行的通知后就释放掉锁

同时,在读取银行余额的接口中,判断当前是否有其他任务正在写入数据,有的话则挂起等待,没有的话直接读取,这样一来就能保证数据的强一致性了。

不过,在这个对比中,我们也能感受到消息通信的内聚性更强,而且比较少关心业务之外的逻辑。

2、数据结构

就目前来看,基于内存共享中所使用的 SharedArrayBuffer 支持的数据类型还是相当有限的,只支持 TypedArray,而且对非线性结构的数据十分不友好。相比之下, postMessage 支持的数据类型就非常多了,只不过在消息传递的过程中会有一定性能损耗。

3、浏览器兼容性

作为一名前端开发,每每看到一个新技术的诞生,总是对未来充满憧憬。但是到 can i use 一查,心中的热情就又凉了一大半。不过大家可以放心,这次不会凉一大半,只会凉一小半(逃...。

首先,浏览器对 Web Workers 的支持度还是不错的:


略微遗憾的是 SharedArrayBuffer 和 Atomics ,PC 中两者都支持的只有 Chrome 68+ ,而移动端则是全军覆没。

五、最后

鲁迅说过:我们要用发展的眼光看待事物。即使现在浏览器对某些并发相关的技术支持度仍不高,但随着前端中所承担的业务越来越复杂,遇到的场景越来越多样,相信并发编程在前端的未来中必定占有一席之地。