ECMAScript 2017 功能「共享記憶體與原子操作」是由 Lars T. Hansen 設計的。它引進了一個新的建構函式 `SharedArrayBuffer` 和一個具有輔助函式的命名空間物件 `Atomics`。本章將說明詳細資訊。
在開始之前,讓我們釐清兩個相似的術語:「並行處理」和「並發處理」。它們有許多定義;我將它們用於以下情況
兩者密切相關,但並不相同
但是,很難精確使用這些術語,因此通常可以互換使用它們。
兩種並行處理模型為
Blob
物件、ImageData
物件等)。它甚至可以正確處理物件之間的循環參照。然而,錯誤物件、函式物件和 DOM 節點無法被複製。SharedArrayBuffer
下一步是什麼?對於低階平行處理,方向相當明確:盡可能支援 SIMD 和 GPU。然而,對於高階平行處理,情況就沒那麼明確了,特別是在 PJS 失敗之後。
需要的是一種方法來嘗試多種方法,找出如何將高階平行處理最佳化帶入 JavaScript。遵循可擴充網路宣言的原則,「共享記憶體和原子」提案(又稱「共享陣列緩衝區」)透過提供可被用於實作高階建構的低階原語來達成此目的。
共享陣列緩衝區是高階並行抽象的原始建構模組。它們允許您在多個工作執行緒和主執行緒之間共享 SharedArrayBuffer
物件的位元組(緩衝區是共享的,若要存取位元組,請將其包裝在類型化陣列中)。這種共享具有兩個好處
postMessage()
相比,工作執行緒之間的協調變得更簡單、更快速。
// 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 行)。與工作執行緒共用的是緩衝區。對於您自己的區域使用,您通常會將共用陣列緩衝區包裝在型化陣列中(B 行)。
警告:複製共用陣列緩衝區是共用的正確方式,但有些引擎仍實作較舊版本的 API,並要求您傳輸它
worker
.
postMessage
({
sharedBuffer
},
[
sharedBuffer
]);
// transfer (deprecated)
在 API 的最終版本中,傳輸共用陣列緩衝區表示您會失去對它的存取權。
工作執行緒的實作如下。
// worker.js
self
.
addEventListener
(
'message'
,
function
(
event
)
{
const
{
sharedBuffer
}
=
event
.
data
;
const
sharedArray
=
new
Int32Array
(
sharedBuffer
);
// (A)
// ···
});
我們首先擷取傳送給我們的共用陣列緩衝區,然後將它包裝在型化陣列中(A 行),以便我們可以在區域使用它。
在單一執行緒中,編譯器可以進行最佳化,而破壞多執行緒程式碼。
例如,以下程式碼
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
這些最佳化會讓在同一個共用陣列緩衝區上作業的多個工作執行緒的活動幾乎無法同步。
此提案提供全域變數 Atomics
,其方法有三個主要用例。
Atomics
方法可用於與其他工作執行緒同步。例如,下列兩個操作讓您讀取和寫入資料,且絕不會被編譯器重新排列
Atomics.load(ta : TypedArray<T>, index) : T
Atomics.store(ta : TypedArray<T>, index, value : T) : T
其概念是使用一般操作來讀取和寫入大部分資料,而 Atomics
操作(load
、store
等)則確保讀取和寫入安全地完成。通常,您會使用自訂同步機制,例如鎖定,其實作基於 Atomics
。
這是一個非常簡單的範例,多虧了 Atomics
,它總是有效(我省略了設定 sharedArray
)
// main.js
console
.
log
(
'notifying...'
);
Atomics
.
store
(
sharedArray
,
0
,
123
);
// worker.js
while
(
Atomics
.
load
(
sharedArray
,
0
)
!==
123
)
;
console
.
log
(
'notified'
);
使用 while
迴圈來等待通知效率不佳,因此 Atomics
有有助於此的操作
Atomics.wait(ta: Int32Array, index, value, timeout)
ta[index]
等待通知,但僅當 ta[index]
等於 value
時。Atomics.wake(ta : Int32Array, index, count)
ta[index]
等待的 count
個工作執行緒。多個 Atomics
操作執行算術運算,且執行期間無法中斷,這有助於同步。例如
Atomics.add(ta : TypedArray<T>, index, value) : T
此操作大致執行
ta
[
index
]
+=
value
;
共用記憶體的另一個問題效應是撕裂值(垃圾):讀取時,您可能會看到中間值,既不是寫入記憶體的新值之前的值,也不是新值。
規格中的「無撕裂讀取」章節指出,僅當下列情況成立時,才不會發生撕裂:
sharedArray
.
byteOffset
%
sharedArray
.
BYTES_PER_ELEMENT
===
0
換句話說,只要透過以下方式存取同一個共用陣列緩衝區,就會產生撕裂值的問題:
若要避免在這些情況下產生撕裂值,請使用 Atomics
或同步。
JavaScript 具有所謂的執行完畢語意:每個函式都可以依賴在完成之前不會被其他執行緒中斷。函式會變成交易,並可以在沒有任何人看到它們在中間狀態下操作的資料的情況下執行完整的演算法。
共用陣列緩衝區會中斷執行完畢 (RTC):函式正在處理的資料可以在函式執行期間被其他執行緒變更。但是,程式碼可以完全控制是否發生這種 RTC 違反:如果它不使用共用陣列緩衝區,則安全無虞。
這與非同步函式如何違反 RTC 類似。在那裡,您透過關鍵字 await
選擇封鎖操作。
共用陣列緩衝區讓 emscripten 能夠將 pthreads 編譯成 asm.js。引用 emscripten 文件頁面
[共用陣列緩衝區允許] Emscripten 應用程式在網頁工作執行緒之間共用主記憶體堆積。這連同低階原子和 futex 支援的原語,讓 Emscripten 能夠實作對 Pthreads (POSIX 執行緒) API 的支援。
也就是說,您可以將多執行緒 C 和 C++ 程式碼編譯成 asm.js。
關於如何將多執行緒最佳化導入 WebAssembly 的討論 正在進行。由於網頁工作執行緒相對較重,因此 WebAssembly 可能會引入輕量級執行緒。您也可以看到執行緒 在 WebAssembly 未來的藍圖中。
目前,只有整數陣列(長度最多 32 位元)可以分享。這表示分享其他種類資料的唯一方式是將它們編碼為整數。可能有所幫助的工具包括
TextEncoder
和 TextDecoder
:前者將字串轉換成 Uint8Array
的執行個體。後者則相反。ArrayBuffer
和 SharedArrayBuffer
)中的複雜資料結構(結構、類別和陣列)的方式來增強 JavaScript。JavaScript+FlatJS 編譯成純 JavaScript。支援 JavaScript 方言(TypeScript 等)。最終,可能會出現更多用於分享資料的高階機制。而實驗將持續找出這些機制應有的樣貌。
Lars T. Hansen 撰寫了 Mandelbrot 演算法的兩個實作(如其文章「A Taste of JavaScript’s New Parallel Primitives」所述,您可以在其中線上試用):一個序列版本和一個使用多個網頁工作者的平行版本。對於最多 4 個網頁工作者(因此是處理器核心),速度提升幾乎呈線性改善,從每秒 6.9 個畫面(1 個網頁工作者)到每秒 25.4 個畫面(4 個網頁工作者)。更多網頁工作者會帶來額外的效能改善,但較為溫和。
Hansen 指出,速度提升令人印象深刻,但平行處理的代價是程式碼變得更複雜。
我們來看一個更全面的範例。其程式碼可以在 GitHub 的儲存庫 shared-array-buffer-demo
中取得。 您也可以線上執行。
在主執行緒中,我們設定共用記憶體,以便它編碼一個關閉的鎖,並將其傳送給工作者(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)
});
在工作者中,我們設定鎖的本地版本(其狀態透過共用陣列緩衝區與主執行緒共用)。在 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 行等待鎖會停止整個工作者。這是真正的封鎖,這在 JavaScript 中以前不存在(非同步函式中的 await
是一種近似值)。
接下來,我們將探討 Lars T. Hansen 的 Lock
實作 的 ES6 版本,其基於 SharedArrayBuffer
。
在此節中,我們將需要(其中包括)下列 Atomics
函式
Atomics.compareExchange(ta : TypedArray<T>, index, expectedValue, replacementValue) : T
ta
在 index
處的目前元素為 expectedValue
,則以 replacementValue
取代它。傳回 index
處的先前(或未變更)元素。實作從幾個常數和建構函式開始
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
。只有在鎖定已鎖定的情況下,我們才會進入 then 程式區塊(在這種情況下,compareExchange()
沒有變更任何內容)。
在 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()
僅會執行什麼都不做的動作。
這讓您大致了解基於 SharedArrayBuffer
的鎖定運作方式。請記住,多執行緒程式碼出了名的難寫,因為事情隨時可能改變。重點是:lock.js
是根據記錄 Linux 核心 futex 實作的論文為基礎。而該論文的標題是「Futexes are tricky」(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
處的先前(或未變更)元素。下列每個函式都會變更給定索引處的 Typed Array 元素:它將運算子套用至元素和參數,並將結果寫回元素。它傳回元素的原始值。
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
個工作執行緒。Atomics.isLockFree(size)
size
(以位元組為單位)的運算元。這可以告知演算法是否要依賴內建原語(compareExchange()
等),或使用自己的鎖定。Atomics.isLockFree(4)
永遠傳回 true
,因為這是所有目前相關支援的內容。目前,我知道
about:config
並將 javascript.options.shared_memory
設為 true
chrome://flags/
(「JavaScript 中啟用實驗性共用陣列緩衝區支援」)--js-flags=--harmony-sharedarraybuffer --enable-blink-feature=SharedArrayBuffer
有關共用陣列緩衝區和支援技術的更多資訊
其他與平行處理相關的 JavaScript 技術
平行處理背景
致謝:非常感謝 Lars T. Hansen 審閱本章,並回答我與 SharedArrayBuffer
相關的問題。