目录
请支持本书:购买 (PDF, EPUB, MOBI)捐赠
(广告,请不要屏蔽。)

6. 共享内存和原子操作

ECMAScript 2017 的特性“共享内存和原子操作”是由 Lars T. Hansen 设计的。它引入了一个新的构造函数 SharedArrayBuffer 和一个带有辅助函数的命名空间对象 Atomics。本章将详细解释。

6.1 并行与并发

在开始之前,让我们先澄清两个相似但又不同的术语:“并行”和“并发”。它们有很多定义;我将按如下方式使用它们

两者密切相关,但并不相同

然而,很难精确地使用这些术语,这就是为什么互换它们通常不是问题。

6.1.1 并行模型

两种并行模型是

6.2 JS 并行历史

6.2.1 下一步:SharedArrayBuffer

下一步是什么?对于低级并行,方向非常明确:尽可能好地支持 SIMD 和 GPU。然而,对于高级并行,事情就不那么清楚了,尤其是在 PJS 失败之后。

我们需要一种方法来尝试多种方法,以找出将高级并行引入 JavaScript 的最佳方式。遵循可扩展 Web 宣言的原则,“共享内存和原子操作”(又名“共享数组缓冲区”)提案通过提供可用于实现更高级别结构的低级原语来做到这一点。

6.3 共享数组缓冲区

共享数组缓冲区是更高级别并发抽象的基本构建块。它们允许您在多个 Worker 和主线程之间共享 SharedArrayBuffer 对象的字节(缓冲区是共享的,要访问字节,请将其包装在类型化数组中)。这种共享有两个好处

6.3.1 创建和发送共享数组缓冲区

// main.js

const worker = new Worker('worker.js');

// To be shared
const sharedBuffer = new SharedArrayBuffer( // (A)
    10 * Int32Array.BYTES_PER_ELEMENT); // 10 elements

// Share sharedBuffer with the worker
worker.postMessage({sharedBuffer}); // clone

// Local only
const sharedArray = new Int32Array(sharedBuffer); // (B)

创建共享数组缓冲区的方式与创建普通数组缓冲区的方式相同:调用构造函数并以字节为单位指定缓冲区的大小(A 行)。您与 Worker 共享的是缓冲区。为了您自己的本地使用,您通常将共享数组缓冲区包装在类型化数组中(B 行)。

**警告:**克隆共享数组缓冲区是共享它的正确方法,但某些引擎仍在实现旧版本的 API,并且要求您传输它

worker.postMessage({sharedBuffer}, [sharedBuffer]); // transfer (deprecated)

在 API 的最终版本中,传输共享数组缓冲区意味着您将失去对它的访问权限。

6.3.2 接收共享数组缓冲区

Worker 的实现如下所示。

// worker.js

self.addEventListener('message', function (event) {
    const {sharedBuffer} = event.data;
    const sharedArray = new Int32Array(sharedBuffer); // (A)

    // ···
});

我们首先提取发送给我们的共享数组缓冲区,然后将其包装在类型化数组中(A 行),以便我们可以在本地使用它。

6.4 原子操作:安全地访问共享数据

6.4.1 问题:优化使代码在 Worker 之间不可预测

在单线程中,编译器可以进行优化,从而破坏多线程代码。

以以下代码为例

while (sharedArray[0] === 123) ;

在单线程中,sharedArray[0] 的值在循环运行时永远不会改变(如果 sharedArray 是一个未以某种方式修补的数组或类型化数组)。因此,代码可以优化如下

const tmp = sharedArray[0];
while (tmp === 123) ;

但是,在多线程环境中,这种优化会阻止我们使用这种模式来等待另一个线程中所做的更改。

另一个例子是以下代码

// main.js
sharedArray[1] = 11;
sharedArray[2] = 22;

在单线程中,您可以重新排列这些写操作,因为在它们之间没有读取操作。对于多个线程,只要您希望写入操作按特定顺序完成,就会遇到麻烦

// worker.js
while (sharedArray[2] !== 22) ;
console.log(sharedArray[1]); // 0 or 11

这些类型的优化使得几乎不可能同步多个 Worker 对同一个共享数组缓冲区的操作。

6.4.2 解决方案:原子操作

该提案提供了一个全局变量 Atomics,其方法有三个主要用例。

6.4.2.1 用例:同步

Atomics 方法可用于与其他 Worker 同步。例如,以下两个操作允许您读取和写入数据,并且永远不会被编译器重新排列

其思想是使用普通操作来读取和写入大多数数据,而 Atomics 操作(loadstore 和其他操作)则确保读取和写入操作安全完成。通常,您将使用自定义同步机制,例如锁,其实现基于 Atomics

这是一个非常简单的例子,由于 Atomics 的存在,它总是有效(我省略了设置 sharedArray 的部分)

// main.js
console.log('notifying...');
Atomics.store(sharedArray, 0, 123);

// worker.js
while (Atomics.load(sharedArray, 0) !== 123) ;
console.log('notified');
6.4.2.2 用例:等待通知

使用 while 循环等待通知效率不高,这就是 Atomics 提供帮助操作的原因

6.4.2.3 用例:原子操作

一些 Atomics 操作执行算术运算,并且在执行过程中不能中断,这有助于同步。例如

粗略地说,此操作执行

ta[index] += value;

6.4.3 问题:撕裂值

共享内存的另一个问题是*撕裂值*(垃圾):读取时,您可能会看到一个中间值——既不是写入新值之前的旧值,也不是新值。

规范中的“无撕裂读取”一节指出,当且仅当满足以下条件时,才不会出现撕裂:

换句话说,每当通过以下方式访问同一个共享数组缓冲区时,就会出现撕裂值问题:

为了避免在这些情况下出现撕裂值,请使用 Atomics 或进行同步。

6.5 共享数组缓冲区的应用

6.5.1 共享数组缓冲区和 JavaScript 的运行至完成语义

JavaScript 具有所谓的*运行至完成语义*:每个函数都可以依赖于在完成之前不会被另一个线程中断。函数成为事务,可以执行完整的算法,而无需任何人看到它们在中间状态下操作的数据。

共享数组缓冲区打破了运行至完成 (RTC):函数正在处理的数据可能会在函数运行期间被另一个线程更改。但是,代码可以完全控制是否发生这种违反 RTC 的情况:如果它不使用共享数组缓冲区,则它是安全的。

这与异步函数违反 RTC 的方式大致相似。在那里,您可以通过关键字 await 选择阻塞操作。

6.5.2 共享数组缓冲区与 asm.js 和 WebAssembly

共享数组缓冲区使 emscripten 能够将 pthreads 编译为 asm.js。引用 emscripten 文档页面 中的内容

[共享数组缓冲区允许] Emscripten 应用程序在 Web Worker 之间共享主内存堆。这与用于低级原子操作和 futex 支持的原语一起,使 Emscripten 能够实现对 Pthreads(POSIX 线程)API 的支持。

也就是说,您可以将多线程 C 和 C++ 代码编译为 asm.js。

关于如何最好地将多线程引入 WebAssembly 的讨论正在进行中。鉴于 Web worker 相对重量级,WebAssembly 可能会引入轻量级线程。您还可以看到线程在 WebAssembly 未来路线图上

6.5.3 共享整数以外的数据

目前,只有整数数组(最长 32 位)可以共享。这意味着共享其他类型数据的唯一方法是将它们编码为整数。可能有用的工具包括

最终,可能会出现用于共享数据的其他更高级别的机制。并且实验将继续找出这些机制应该是什么样子。

6.5.4 使用共享数组缓冲区的代码速度有多快?

Lars T. Hansen 编写了 Mandelbrot 算法的两种实现(如他的文章“JavaScript 新并行原语初探”中所述,您可以在线试用它们):一个串行版本和一个使用多个 Web worker 的并行版本。对于最多 4 个 Web worker(以及处理器核心),速度提升几乎呈线性,从每秒 6.9 帧(1 个 Web worker)到每秒 25.4 帧(4 个 Web worker)。更多 Web worker 会带来额外的性能提升,但提升幅度不大。

Hansen 指出,速度提升令人印象深刻,但并行化的代价是代码更加复杂。

6.6 示例

让我们看一个更全面的例子。其代码可在 GitHub 上的shared-array-buffer-demo存储库中找到。您也可以在线运行它。

6.6.1 使用共享锁

在主线程中,我们设置共享内存,使其编码一个关闭的锁,并将其发送给一个 worker(A 行)。一旦用户点击,我们就打开锁(B 行)。

// main.js

// Set up the shared memory
const sharedBuffer = new SharedArrayBuffer(
    1 * Int32Array.BYTES_PER_ELEMENT);
const sharedArray = new Int32Array(sharedBuffer);

// Set up the lock
Lock.initialize(sharedArray, 0);
const lock = new Lock(sharedArray, 0);
lock.lock(); // writes to sharedBuffer

worker.postMessage({sharedBuffer}); // (A)

document.getElementById('unlock').addEventListener(
    'click', event => {
        event.preventDefault();
        lock.unlock(); // (B)
    });

在 worker 中,我们设置锁的本地版本(其状态通过共享数组缓冲区与主线程共享)。在 B 行中,我们等待锁被解锁。在 A 行和 C 行中,我们将文本发送到主线程,主线程将其显示在页面上(之前的代码片段中没有显示它是如何做到的)。也就是说,我们在这两行中使用 self.postMessage() 就像 console.log() 一样。

// worker.js

self.addEventListener('message', function (event) {
    const {sharedBuffer} = event.data;
    const lock = new Lock(new Int32Array(sharedBuffer), 0);

    self.postMessage('Waiting for lock...'); // (A)
    lock.lock(); // (B) blocks!
    self.postMessage('Unlocked'); // (C)
});

值得注意的是,在 B 行中等待锁会停止整个 worker。这是真正的阻塞,这在 JavaScript 中是以前不存在的(异步函数中的 await 是一个近似值)。

6.6.2 实现共享锁

接下来,我们将查看 Lars T. Hansen 编写的Lock 实现的 ES6 版本,该版本基于 SharedArrayBuffer

在本节中,我们将需要(除其他外)以下 Atomics 函数

实现从几个常量和构造函数开始

const UNLOCKED = 0;
const LOCKED_NO_WAITERS = 1;
const LOCKED_POSSIBLE_WAITERS = 2;

// Number of shared Int32 locations needed by the lock.
const NUMINTS = 1;

class Lock {

    /**
     * @param iab an Int32Array wrapping a SharedArrayBuffer
     * @param ibase an index inside iab, leaving enough room for NUMINTS
     */
    constructor(iab, ibase) {
        // OMITTED: check parameters
        this.iab = iab;
        this.ibase = ibase;
    }

构造函数主要将其参数存储在实例属性中。

锁定方法如下所示。

/**
 * Acquire the lock, or block until we can. Locking is not recursive:
 * you must not hold the lock when calling this.
 */
lock() {
    const iab = this.iab;
    const stateIdx = this.ibase;
    var c;
    if ((c = Atomics.compareExchange(iab, stateIdx, // (A)
    UNLOCKED, LOCKED_NO_WAITERS)) !== UNLOCKED) {
        do {
            if (c === LOCKED_POSSIBLE_WAITERS // (B)
            || Atomics.compareExchange(iab, stateIdx,
            LOCKED_NO_WAITERS, LOCKED_POSSIBLE_WAITERS) !== UNLOCKED) {
                Atomics.wait(iab, stateIdx, // (C)
                    LOCKED_POSSIBLE_WAITERS, Number.POSITIVE_INFINITY);
            }
        } while ((c = Atomics.compareExchange(iab, stateIdx,
        UNLOCKED, LOCKED_POSSIBLE_WAITERS)) !== UNLOCKED);
    }
}

在 A 行中,如果锁的当前值为 UNLOCKED,则将其更改为 LOCKED_NO_WAITERS。仅当锁已锁定(在这种情况下,compareExchange() 不会更改任何内容)时,我们才会进入 then 块。

在 B 行(在 do-while 循环内),我们检查锁是否已锁定并带有等待者,或者是否已解锁。鉴于我们将要等待,如果当前值为 LOCKED_NO_WAITERS,则 compareExchange() 也会切换到 LOCKED_POSSIBLE_WAITERS

在 C 行中,如果锁值为 LOCKED_POSSIBLE_WAITERS,则等待。最后一个参数 Number.POSITIVE_INFINITY 表示等待永不超时。

唤醒后,如果我们没有被解锁,我们将继续循环。如果锁为 UNLOCKED,则 compareExchange() 也会切换到 LOCKED_POSSIBLE_WAITERS。我们使用 LOCKED_POSSIBLE_WAITERS 而不是 LOCKED_NO_WAITERS,因为我们需要在 unlock() 临时将其设置为 UNLOCKED 并唤醒我们之后恢复此值。

解锁方法如下所示。

    /**
     * Unlock a lock that is held.  Anyone can unlock a lock that
     * is held; nobody can unlock a lock that is not held.
     */
    unlock() {
        const iab = this.iab;
        const stateIdx = this.ibase;
        var v0 = Atomics.sub(iab, stateIdx, 1); // A

        // Wake up a waiter if there are any
        if (v0 !== LOCKED_NO_WAITERS) {
            Atomics.store(iab, stateIdx, UNLOCKED);
            Atomics.wake(iab, stateIdx, 1);
        }
    }

    // ···
}

在 A 行中,v0 获取从 iab[stateIdx] 中减去 1 之前的值。减法意味着我们从(例如)LOCKED_NO_WAITERS 变为 UNLOCKED,从 LOCKED_POSSIBLE_WAITERS 变为 LOCKED

如果该值以前是 LOCKED_NO_WAITERS,那么它现在是 UNLOCKED,一切都很好(没有人需要唤醒)。

否则,该值要么是 LOCKED_POSSIBLE_WAITERS,要么是 UNLOCKED。在前一种情况下,我们现在已解锁,必须唤醒某人(通常会再次锁定)。在后一种情况下,我们必须修复由减法创建的非法值,并且 wake() 什么也不做。

6.6.3 示例结论

这使您大致了解了基于 SharedArrayBuffer 的锁的工作原理。请记住,多线程代码 notoriously 难以编写,因为事情随时都可能发生变化。例如:lock.js 基于一篇记录 Linux 内核 futex 实现的论文。那篇论文的标题是“Futex 很棘手”(PDF)。

如果您想更深入地了解使用共享数组缓冲区的并行编程,请查看synchronic.js它所基于的文档(PDF)

6.7 共享内存和原子操作的 API

6.7.1 SharedArrayBuffer

构造函数

静态属性

实例属性

6.7.2 Atomics

Atomics 函数的主要操作数必须是 Int8ArrayUint8ArrayInt16ArrayUint16ArrayInt32ArrayUint32Array 的实例。它必须包装一个 SharedArrayBuffer

所有函数都以原子方式执行其操作。存储操作的顺序是固定的,不能由编译器或 CPU 重新排序。

6.7.2.1 加载和存储
6.7.2.2 简单修改类型化数组元素

以下每个函数都会更改给定索引处的类型化数组元素:它将运算符应用于元素和参数,并将结果写回元素。它返回元素的原始值

6.7.2.3 等待和唤醒

等待和唤醒要求参数 taInt32Array 的实例。

6.7.2.4 杂项

6.8 常见问题解答

6.8.1 哪些浏览器支持共享数组缓冲区?

目前,我知道的有

6.9 延伸阅读

有关共享数组缓冲区和支持技术的更多信息

与并行性相关的其他 JavaScript 技术

并行化的背景

致谢:我非常感谢 Lars T. Hansen 审阅本章并回答我与 SharedArrayBuffer 相关的问题。

下一页:7. Object.entries()Object.values()