ECMAScript 2017 的特性“共享内存和原子操作”是由 Lars T. Hansen 设计的。它引入了一个新的构造函数 SharedArrayBuffer
和一个带有辅助函数的命名空间对象 Atomics
。本章将详细解释。
在开始之前,让我们先澄清两个相似但又不同的术语:“并行”和“并发”。它们有很多定义;我将按如下方式使用它们
两者密切相关,但并不相同
然而,很难精确地使用这些术语,这就是为什么互换它们通常不是问题。
两种并行模型是
Blob
对象、ImageData
对象等)。它甚至可以正确处理对象之间的循环引用。但是,错误对象、函数对象和 DOM 节点不能被克隆。SharedArrayBuffer
下一步是什么?对于低级并行,方向非常明确:尽可能好地支持 SIMD 和 GPU。然而,对于高级并行,事情就不那么清楚了,尤其是在 PJS 失败之后。
我们需要一种方法来尝试多种方法,以找出将高级并行引入 JavaScript 的最佳方式。遵循可扩展 Web 宣言的原则,“共享内存和原子操作”(又名“共享数组缓冲区”)提案通过提供可用于实现更高级别结构的低级原语来做到这一点。
共享数组缓冲区是更高级别并发抽象的基本构建块。它们允许您在多个 Worker 和主线程之间共享 SharedArrayBuffer
对象的字节(缓冲区是共享的,要访问字节,请将其包装在类型化数组中)。这种共享有两个好处
postMessage()
相比)。创建共享数组缓冲区的方式与创建普通数组缓冲区的方式相同:调用构造函数并以字节为单位指定缓冲区的大小(A 行)。您与 Worker 共享的是缓冲区。为了您自己的本地使用,您通常将共享数组缓冲区包装在类型化数组中(B 行)。
**警告:**克隆共享数组缓冲区是共享它的正确方法,但某些引擎仍在实现旧版本的 API,并且要求您传输它
在 API 的最终版本中,传输共享数组缓冲区意味着您将失去对它的访问权限。
Worker 的实现如下所示。
我们首先提取发送给我们的共享数组缓冲区,然后将其包装在类型化数组中(A 行),以便我们可以在本地使用它。
在单线程中,编译器可以进行优化,从而破坏多线程代码。
以以下代码为例
在单线程中,sharedArray[0]
的值在循环运行时永远不会改变(如果 sharedArray
是一个未以某种方式修补的数组或类型化数组)。因此,代码可以优化如下
但是,在多线程环境中,这种优化会阻止我们使用这种模式来等待另一个线程中所做的更改。
另一个例子是以下代码
在单线程中,您可以重新排列这些写操作,因为在它们之间没有读取操作。对于多个线程,只要您希望写入操作按特定顺序完成,就会遇到麻烦
这些类型的优化使得几乎不可能同步多个 Worker 对同一个共享数组缓冲区的操作。
该提案提供了一个全局变量 Atomics
,其方法有三个主要用例。
Atomics
方法可用于与其他 Worker 同步。例如,以下两个操作允许您读取和写入数据,并且永远不会被编译器重新排列
Atomics.load(ta : TypedArray<T>, index) : T
Atomics.store(ta : TypedArray<T>, index, value : T) : T
其思想是使用普通操作来读取和写入大多数数据,而 Atomics
操作(load
、store
和其他操作)则确保读取和写入操作安全完成。通常,您将使用自定义同步机制,例如锁,其实现基于 Atomics
。
这是一个非常简单的例子,由于 Atomics
的存在,它总是有效(我省略了设置 sharedArray
的部分)
使用 while
循环等待通知效率不高,这就是 Atomics
提供帮助操作的原因
Atomics.wait(ta: Int32Array, index, value, timeout)
ta[index]
处的通知,但前提是 ta[index]
为 value
。Atomics.wake(ta : Int32Array, index, count)
ta[index]
处等待的 count
个 Worker。一些 Atomics
操作执行算术运算,并且在执行过程中不能中断,这有助于同步。例如
Atomics.add(ta : TypedArray<T>, index, value) : T
粗略地说,此操作执行
共享内存的另一个问题是*撕裂值*(垃圾):读取时,您可能会看到一个中间值——既不是写入新值之前的旧值,也不是新值。
规范中的“无撕裂读取”一节指出,当且仅当满足以下条件时,才不会出现撕裂:
换句话说,每当通过以下方式访问同一个共享数组缓冲区时,就会出现撕裂值问题:
为了避免在这些情况下出现撕裂值,请使用 Atomics
或进行同步。
JavaScript 具有所谓的*运行至完成语义*:每个函数都可以依赖于在完成之前不会被另一个线程中断。函数成为事务,可以执行完整的算法,而无需任何人看到它们在中间状态下操作的数据。
共享数组缓冲区打破了运行至完成 (RTC):函数正在处理的数据可能会在函数运行期间被另一个线程更改。但是,代码可以完全控制是否发生这种违反 RTC 的情况:如果它不使用共享数组缓冲区,则它是安全的。
这与异步函数违反 RTC 的方式大致相似。在那里,您可以通过关键字 await
选择阻塞操作。
共享数组缓冲区使 emscripten 能够将 pthreads 编译为 asm.js。引用 emscripten 文档页面 中的内容
[共享数组缓冲区允许] Emscripten 应用程序在 Web Worker 之间共享主内存堆。这与用于低级原子操作和 futex 支持的原语一起,使 Emscripten 能够实现对 Pthreads(POSIX 线程)API 的支持。
也就是说,您可以将多线程 C 和 C++ 代码编译为 asm.js。
关于如何最好地将多线程引入 WebAssembly 的讨论正在进行中。鉴于 Web worker 相对重量级,WebAssembly 可能会引入轻量级线程。您还可以看到线程在 WebAssembly 未来路线图上。
目前,只有整数数组(最长 32 位)可以共享。这意味着共享其他类型数据的唯一方法是将它们编码为整数。可能有用的工具包括
TextEncoder
和 TextDecoder
:前者将字符串转换为 Uint8Array
的实例。后者则相反。ArrayBuffer
和 SharedArrayBuffer
)中。JavaScript+FlatJS 被编译为纯 JavaScript。支持 JavaScript 方言(TypeScript 等)。最终,可能会出现用于共享数据的其他更高级别的机制。并且实验将继续找出这些机制应该是什么样子。
Lars T. Hansen 编写了 Mandelbrot 算法的两种实现(如他的文章“JavaScript 新并行原语初探”中所述,您可以在线试用它们):一个串行版本和一个使用多个 Web worker 的并行版本。对于最多 4 个 Web worker(以及处理器核心),速度提升几乎呈线性,从每秒 6.9 帧(1 个 Web worker)到每秒 25.4 帧(4 个 Web worker)。更多 Web worker 会带来额外的性能提升,但提升幅度不大。
Hansen 指出,速度提升令人印象深刻,但并行化的代价是代码更加复杂。
让我们看一个更全面的例子。其代码可在 GitHub 上的shared-array-buffer-demo
存储库中找到。您也可以在线运行它。
在主线程中,我们设置共享内存,使其编码一个关闭的锁,并将其发送给一个 worker(A 行)。一旦用户点击,我们就打开锁(B 行)。
在 worker 中,我们设置锁的本地版本(其状态通过共享数组缓冲区与主线程共享)。在 B 行中,我们等待锁被解锁。在 A 行和 C 行中,我们将文本发送到主线程,主线程将其显示在页面上(之前的代码片段中没有显示它是如何做到的)。也就是说,我们在这两行中使用 self.postMessage()
就像 console.log()
一样。
值得注意的是,在 B 行中等待锁会停止整个 worker。这是真正的阻塞,这在 JavaScript 中是以前不存在的(异步函数中的 await
是一个近似值)。
接下来,我们将查看 Lars T. Hansen 编写的Lock
实现的 ES6 版本,该版本基于 SharedArrayBuffer
。
在本节中,我们将需要(除其他外)以下 Atomics
函数
Atomics.compareExchange(ta : TypedArray<T>, index, expectedValue, replacementValue) : T
ta
在 index
处的当前元素是 expectedValue
,则将其替换为 replacementValue
。返回 index
处的先前(或未更改)元素。实现从几个常量和构造函数开始
构造函数主要将其参数存储在实例属性中。
锁定方法如下所示。
在 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
并唤醒我们之后恢复此值。
解锁方法如下所示。
在 A 行中,v0
获取从 iab[stateIdx]
中减去 1 之前的值。减法意味着我们从(例如)LOCKED_NO_WAITERS
变为 UNLOCKED
,从 LOCKED_POSSIBLE_WAITERS
变为 LOCKED
。
如果该值以前是 LOCKED_NO_WAITERS
,那么它现在是 UNLOCKED
,一切都很好(没有人需要唤醒)。
否则,该值要么是 LOCKED_POSSIBLE_WAITERS
,要么是 UNLOCKED
。在前一种情况下,我们现在已解锁,必须唤醒某人(通常会再次锁定)。在后一种情况下,我们必须修复由减法创建的非法值,并且 wake()
什么也不做。
这使您大致了解了基于 SharedArrayBuffer
的锁的工作原理。请记住,多线程代码 notoriously 难以编写,因为事情随时都可能发生变化。例如:lock.js
基于一篇记录 Linux 内核 futex 实现的论文。那篇论文的标题是“Futex 很棘手”(PDF)。
如果您想更深入地了解使用共享数组缓冲区的并行编程,请查看synchronic.js
和它所基于的文档(PDF)。
SharedArrayBuffer
构造函数
new SharedArrayBuffer(length)
length
字节的缓冲区。静态属性
get SharedArrayBuffer[Symbol.species]
this
。覆盖以控制 slice()
返回的内容。实例属性
get SharedArrayBuffer.prototype.byteLength()
SharedArrayBuffer.prototype.slice(start, end)
this.constructor[Symbol.species]
的新实例,并使用从(包括)start
到(不包括)end
的索引处的字节填充它。Atomics
Atomics
函数的主要操作数必须是 Int8Array
、Uint8Array
、Int16Array
、Uint16Array
、Int32Array
或 Uint32Array
的实例。它必须包装一个 SharedArrayBuffer
。
所有函数都以原子方式执行其操作。存储操作的顺序是固定的,不能由编译器或 CPU 重新排序。
Atomics.load(ta : TypedArray<T>, index) : T
ta
在 index
处的元素。Atomics.store(ta : TypedArray<T>, index, value : T) : T
value
写入 ta
在 index
处的元素,并返回 value
。Atomics.exchange(ta : TypedArray<T>, index, value : T) : T
ta
在 index
处的元素设置为 value
,并返回该索引处的先前值。Atomics.compareExchange(ta : TypedArray<T>, index, expectedValue, replacementValue) : T
ta
在 index
处的当前元素是 expectedValue
,则将其替换为 replacementValue
。返回 index
处的先前(或未更改)元素。以下每个函数都会更改给定索引处的类型化数组元素:它将运算符应用于元素和参数,并将结果写回元素。它返回元素的原始值。
Atomics.add(ta : TypedArray<T>, index, value) : T
ta[index] += value
并返回 ta[index]
的原始值。Atomics.sub(ta : TypedArray<T>, index, value) : T
ta[index] -= value
并返回 ta[index]
的原始值。Atomics.and(ta : TypedArray<T>, index, value) : T
ta[index] &= value
并返回 ta[index]
的原始值。Atomics.or(ta : TypedArray<T>, index, value) : T
ta[index] |= value
并返回 ta[index]
的原始值。Atomics.xor(ta : TypedArray<T>, index, value) : T
ta[index] ^= value
并返回 ta[index]
的原始值。等待和唤醒要求参数 ta
是 Int32Array
的实例。
Atomics.wait(ta: Int32Array, index, value, timeout=Number.POSITIVE_INFINITY) : ('not-equal' | 'ok' | 'timed-out')
ta[index]
处的当前值不是 value
,则返回 'not-equal'
。否则进入休眠状态,直到我们通过 Atomics.wake()
被唤醒或休眠超时。在前一种情况下,返回 'ok'
。在后一种情况下,返回 'timed-out'
。timeout
以毫秒为单位指定。此函数功能的助记符:“如果 ta[index]
是 value
则等待”。Atomics.wake(ta : Int32Array, index, count)
ta[index]
处等待的 count
个 worker。Atomics.isLockFree(size)
size
(以字节为单位)的操作数。这可以告知算法它们是希望依赖内置原语(compareExchange()
等)还是使用自己的锁定。Atomics.isLockFree(4)
始终返回 true
,因为这是当前所有相关支持的内容。目前,我知道的有
about:config
并将 javascript.options.shared_memory
设置为 true
chrome://flags/
(“在 JavaScript 中启用实验性 SharedArrayBuffer 支持”)--js-flags=--harmony-sharedarraybuffer --enable-blink-feature=SharedArrayBuffer
有关共享数组缓冲区和支持技术的更多信息
与并行性相关的其他 JavaScript 技术
并行化的背景
致谢:我非常感谢 Lars T. Hansen 审阅本章并回答我与 SharedArrayBuffer
相关的问题。