目錄
請支持這本書:購買(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。遵循可擴充網路宣言的原則,「共享記憶體和原子」提案(又稱「共享陣列緩衝區」)透過提供可被用於實作高階建構的低階原語來達成此目的。

6.3 共享陣列緩衝區

共享陣列緩衝區是高階並行抽象的原始建構模組。它們允許您在多個工作執行緒和主執行緒之間共享 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 行)。與工作執行緒共用的是緩衝區。對於您自己的區域使用,您通常會將共用陣列緩衝區包裝在型化陣列中(B 行)。

警告:複製共用陣列緩衝區是共用的正確方式,但有些引擎仍實作較舊版本的 API,並要求您傳輸它

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

在 API 的最終版本中,傳輸共用陣列緩衝區表示您會失去對它的存取權。

6.3.2 接收共用陣列緩衝區

工作執行緒的實作如下。

// worker.js

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

    // ···
});

我們首先擷取傳送給我們的共用陣列緩衝區,然後將它包裝在型化陣列中(A 行),以便我們可以在區域使用它。

6.4 原子:安全存取共用資料

6.4.1 問題:最佳化會讓程式碼在工作執行緒間無法預測

在單一執行緒中,編譯器可以進行最佳化,而破壞多執行緒程式碼。

例如,以下程式碼

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

這些最佳化會讓在同一個共用陣列緩衝區上作業的多個工作執行緒的活動幾乎無法同步。

6.4.2 解法:原子操作

此提案提供全域變數 Atomics,其方法有三個主要用例。

6.4.2.1 用例:同步

Atomics 方法可用於與其他工作執行緒同步。例如,下列兩個操作讓您讀取和寫入資料,且絕不會被編譯器重新排列

其概念是使用一般操作來讀取和寫入大部分資料,而 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 應用程式在網頁工作執行緒之間共用主記憶體堆積。這連同低階原子和 futex 支援的原語,讓 Emscripten 能夠實作對 Pthreads (POSIX 執行緒) API 的支援。

也就是說,您可以將多執行緒 C 和 C++ 程式碼編譯成 asm.js。

關於如何將多執行緒最佳化導入 WebAssembly 的討論 正在進行。由於網頁工作執行緒相對較重,因此 WebAssembly 可能會引入輕量級執行緒。您也可以看到執行緒 在 WebAssembly 未來的藍圖中

6.5.3 分享整數以外的資料

目前,只有整數陣列(長度最多 32 位元)可以分享。這表示分享其他種類資料的唯一方式是將它們編碼為整數。可能有所幫助的工具包括

最終,可能會出現更多用於分享資料的高階機制。而實驗將持續找出這些機制應有的樣貌。

6.5.4 使用共用陣列緩衝區的程式碼快多少?

Lars T. Hansen 撰寫了 Mandelbrot 演算法的兩個實作(如其文章「A Taste of JavaScript’s New Parallel Primitives」所述,您可以在其中線上試用):一個序列版本和一個使用多個網頁工作者的平行版本。對於最多 4 個網頁工作者(因此是處理器核心),速度提升幾乎呈線性改善,從每秒 6.9 個畫面(1 個網頁工作者)到每秒 25.4 個畫面(4 個網頁工作者)。更多網頁工作者會帶來額外的效能改善,但較為溫和。

Hansen 指出,速度提升令人印象深刻,但平行處理的代價是程式碼變得更複雜。

6.6 範例

我們來看一個更全面的範例。其程式碼可以在 GitHub 的儲存庫 shared-array-buffer-demo 中取得。 您也可以線上執行。

6.6.1 使用共用鎖

在主執行緒中,我們設定共用記憶體,以便它編碼一個關閉的鎖,並將其傳送給工作者(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 是一種近似值)。

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。只有在鎖定已鎖定的情況下,我們才會進入 then 程式區塊(在這種情況下,compareExchange() 沒有變更任何內容)。

在 B 行中(在 do-while 迴圈內),我們檢查鎖定是否已鎖定且有等待者或未鎖定。由於我們即將等待,因此如果目前值為 LOCKED_NO_WAITERScompareExchange() 也會切換至 LOCKED_POSSIBLE_WAITERS

在 C 行中,如果鎖定值為 LOCKED_POSSIBLE_WAITERS,我們會等待。最後一個參數 Number.POSITIVE_INFINITY 表示等待永不逾時。

喚醒後,如果我們未解鎖,我們將繼續迴圈。如果鎖定為 UNLOCKEDcompareExchange() 也會切換至 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_WAITERSUNLOCKED。在前一種情況下,我們現在已解鎖,並且必須喚醒某人(通常會再次鎖定)。在後一種情況下,我們必須修正減法所建立的非法值,而 wake() 僅會執行什麼都不做的動作。

6.6.3 範例結論

這讓您大致了解基於 SharedArrayBuffer 的鎖定運作方式。請記住,多執行緒程式碼出了名的難寫,因為事情隨時可能改變。重點是:lock.js 是根據記錄 Linux 核心 futex 實作的論文為基礎。而該論文的標題是「Futexes are tricky」(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 Typed Array 元素的簡單修改

下列每個函式都會變更給定索引處的 Typed Array 元素:它將運算子套用至元素和參數,並將結果寫回元素。它傳回元素的原始值

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()