SharedArrayBuffer and Atomics - Web 的多線程並發編程

之前在關於 OffscreenCanvas 的一篇文章中,提到目前 JavaScript 缺少一個真正可用的多線程並發編程模型。當然 SharedArrayBuffer 和 Atomics 標準的制定也有一段時間了,當時這麼說的主要原因是:

  1. SAB 和 Atomics 一直還是一個實驗特性,最近還因為會被「熔斷」和「幽靈」這樣的時間攻擊漏洞所利用而在各大瀏覽器裡面被「下架」了;
  2. 因為 JavaScript 本身沒有類型系統,無法定義數據結構的內存布局,在只能共享 Typed Arrays 內存緩衝區的情況下,要在 JavaScipt 中使用多線程並發還是相當困難的;

但是從長遠來看 SAB 和 Atomics 還是為 Web 的多線程並發編程提供了可能,雖然主要會在 WASM 裡面使用。不過即使主要用於 WASM,但是使用 WASM 的應用代碼通常也會同時包含 WASM 和 JS 的部分,JS 可能也需要訪問 SAB;並且在特定應用場景下,單獨使用 JS 也能使用 SAB 來實現並發。

這篇文章會介紹 SAB 和 Atomics 的基本概念,如何基於 SAB 和 Atomics 實現鎖和條件量這樣的同步原語,最後會展示一個使用 SAB 來實現並發的例子。

目前 SAB 在最新版本的 Chrome 需要通過實驗室開關打開,Chrome 的開關是 chome://flags -> Experimental enabled SharedArrayBuffer support in JavaScript,本文的常式是在 Chrome 67 Canary 上進行驗證。

SharedArrayBuffer and Atomics

這一節的內容實際上是 Shared memory - a brief tutorial 這篇文章的一個精簡版本,建議感興趣的讀者去閱讀全文。

SharedArrayBuffer 顧名思義就是為線程間共享內存提供了一塊內存緩衝區,你可以通過 postMessage 將線程 A 分配的 SAB 發送給線程 B,然後兩個線程就可以共同訪問這塊內存。

下面的代碼通過創建 SharedArrayBuffer 來分配一塊共享內存:

var sab = new SharedArrayBuffer(1024); // 1KiB shared memory

通過 postMessage 發送給另外一個 Worker 線程:

w.postMessage(sab)

Worker 接收 SharedArrayBuffer 對象:

var sab;onmessage = function (ev) { sab = ev.data; // 1KiB shared memory, the same memory as in the parent}

Atomics 一則是提供了對 SAB 進行讀寫,加減,與或,交換等原子操作,二則是提供了等待和喚醒這兩個最基礎的同步原語。

  1. 上面的 array 實際上需要是在 SharedArrayBuffer 上構建的 Int32Array 視圖;
  2. 瀏覽器的主線程不允許使用 wait,wait 容易導致主線程阻塞甚至死鎖,在主線程調用會拋出異常;

鎖和條件量

使用 Atomics 提供的基礎 API,可以實現鎖和信號量的同步原語。GitHub 上已經有相應的代碼,這裡簡單解析一下具體的實現。

Lock.prototype.lock = function () { const iab = this._iab; const stateIdx = this._ibase; let c; if ((c = Atomics.compareExchange(iab, stateIdx, 0, 1)) != 0) { do { if (c == 2 || Atomics.compareExchange(iab, stateIdx, 1, 2) != 0) Atomics.wait(iab, stateIdx, 2); } while ((c = Atomics.compareExchange(iab, stateIdx, 0, 2)) != 0); }}Lock.prototype.unlock = function () { const iab = this._iab; const stateIdx = this._ibase; let v0 = Atomics.sub(iab, stateIdx, 1); // Wake up a waiter if there are any if (v0 != 1) { Atomics.store(iab, stateIdx, 0); Atomics.wake(iab, stateIdx, 1); }}

對於線程 A 加鎖,然後線程 B 加鎖,然後線程 A 解鎖,然後線程 B 解鎖這樣的執行序列:

  1. 線程 A lock 時,鎖的初始值為 0 代表還沒有線程擁有鎖,線程 A 將鎖其標記為 1,擁有了鎖並退出 lock;
  2. 線程 B lock 時,因為鎖的值不為 0,代表已經被其它線程佔用,線程 B 將鎖的值改為 2,然後進入等待;
  3. 線程 A unlock 時獲取鎖的原值並將其改成 1,因為原值 v0 為 2 代表有線程在上面等待,需要恢復鎖的值為初始值 0 表示釋放了鎖的所有權,然後喚醒其它線程;
  4. 線程 B 被喚醒,獲得了鎖,並將鎖的值改成 2,然後退出 lock;
  5. 線程 B unlock 時重複 3,如果沒有其它線程需要被喚醒則 wake 只是一個空操作;

Cond.prototype.wait = function () { const iab = this._iab; const seqIndex = this._ibase; const seq = Atomics.load(iab, seqIndex); const lock = this.lock; lock.unlock(); Atomics.wait(iab, seqIndex, seq); lock.lock();}Cond.prototype.wakeOne = function () { const iab = this._iab; const seqIndex = this._ibase; Atomics.add(iab, seqIndex, 1); Atomics.wake(iab, seqIndex, 1);}

  1. 在調用條件量的 wait 或者 wake 之前,都需要先獲得相應鎖的擁有權;
  2. 條件量的 wait 調用,需要先獲取條件量的當前值,然後釋放鎖的擁有權,如果在解鎖後和調用 Atomics.wait 之間沒有其它線程調用條件量的 wake ,條件量的值沒有被改變,則進入等待狀態,等待其它線程的喚醒;
  3. 條件量的 wake 調用,先會改變條件量的值(加 1),然後再喚醒在條件量上等待的其它線程,如果沒有則只是一個空操作;
  4. 在條件量 wait 上被喚醒的線程,需要重新獲得鎖的擁有權,然後退出 wait;

一般實際使用條件量的代碼會如下面所示:

lock.lock();while (needWait()) cond.wait();lock.unlock();

我們需要通過 while 循環來不斷檢查是否需要進入等待狀態,然後再調用條件量的 wait:

  1. 避免在不需要的情況下進入等待狀態然後永遠不會被喚醒導致線程死鎖;
  2. 或者意外被喚醒,但是又不滿足繼續執行的條件,需要繼續等待;

並發運行的常式

常式是之前 OffscreenCanvas 文章裡面使用過的常式的修改版本,代碼可以通過 GitHub 下載。

我們將 WebGL Compute 的代碼進行拆解,Compute 的部分運行在 Compute Worker 線程,Render 的部分運行在 Render Worker 線程,Computer 計算鳥群里每隻鳥的運動位置,然後將數據保存在一個 SharedArrayBuffer 裡面,Renderer 從 SAB 讀取相應的數據,並上傳到 GL 的 Vertex Buffer 裡面,然後進行繪製。

兩個線程同步的代碼並不複雜,Compute Worker 的部分:

function addComputedData() { Atomics.store(compute_data, 0, 1); }function useComputedData() { Atomics.store(compute_data, 0, 0); }function hasComputedData() { return Atomics.load(compute_data, 0) != 0; }function computeloop() { lock_compute.lock(); // Wait renderer use computed data while (hasComputedData()) cond_compute.wait(); // Compute data compute(); addComputedData(); // Wake renderer to upload data cond_compute.wakeOne(); lock_compute.unlock();}

Render Worker 的部分:

function renderloop() { lock_compute.lock(); // Wait computer to compute new data while (!hasComputedData()) cond_compute.wait(); // Upload data uploadVertexBuffers(gl, firstDraw); useComputedData(); // Wake computer to compute next cond_compute.wakeOne(); lock_compute.unlock(); // Render buffer render(); // Wait next begin frame to loop gl.commit().then(renderloop);}

上圖演示了兩個 Worker 並發運行的調用圖

原來的常式只需要一個 DrawCall 就可以繪製所有的點,Render 的部分開銷很小,看不出並發的效果,如果我們改成每個 DrawCall 只繪製幾個點,調用幾十次 DrawCall,Render 部分的開銷就變得比較大。修改後的代碼在一台 2015版 MBP 上面測試不使用多線程跑不動 40 幀,使用多線程可以跑到 55 幀左右,性能大概提升了 30% 多。當然這個測試並不嚴謹,只是為了說明通過多線程並發的確可以在特定場景下起到明顯提升性能的作用。

結語

Web 技術繼續向前演進的其中一個命題就是 —— 讓大型遊戲引擎和 3A 遊戲在 Web 上運行,在技術上成為可能。我們看到 WebAssembly,OffscreenCanvas,SharedArrayBuffer 這些技術雖然都是獨立的,自身也有很高的價值,但是它們組合在一起對這個命題的實現能起到決定性的作用,每一個都是不可或缺的:

  1. 大型遊戲引擎一般使用 C/C++ 編寫,遊戲的業務和控制邏輯使用腳本語言或者動態語言,WebAssembly 可以將引擎或者引擎核心的代碼編譯成可以在 Web 上運行的 Binary Code,引擎包裹層和遊戲業務邏輯可以使用 JavaScript 編寫;
  2. SharedArrayBuffer 和 Atomics 為 WASM 提供了線程間共享內存和同步原語,為需要使用多線程並發的遊戲引擎運行提供了相應的機制;
  3. OffscreenCanvas 使得遊戲引擎可以運行在 Worker 線程,不會阻塞主線程,也不會被主線程阻塞,可以使用線程同步原語實現多線程並發(主線程是不允許使用 wait 的),同時 OffscreenCanvas 使得 Canvas 的更新不會被 DOM 更新所阻塞,提供了更高性能的 Canvas 2D/3D 渲染流水線;

推薦閱讀:

TAG:WebAssembly | Canvas | Web開發 |