iT邦幫忙

2023 iThome 鐵人賽

DAY 21
1

昨天介紹了 SharedArrayBuffer,使用 SharedArrayBuffer 可以在不同的線程中共享記憶體,達到高效的運算功能,但隨之而來的缺點就是不同線程間操作同一記憶體帶來的 競爭衝突 (race condition)

Race condition

假設有兩個線程各自都會將同一個變數 +1,在理想的狀態下,他們會這樣執行,最後的變數值為 2
https://ithelp.ithome.com.tw/upload/images/20231004/20162687bPl3iTehbp.png

但如果他們在執行的時間上交錯時,可能會發生以下狀況,最後導致變數的值為 1
https://ithelp.ithome.com.tw/upload/images/20231004/20162687l3sie6a1Wo.png

什麼是 Atomics?

Atomics 用來在 Javascript 中解決 race condition 的問題,使用 Atomics 代表每一次操作都是單一原子化,無法分割的,什麼是無法分割的意思呢?以上述例子來看,指的就是 線程1 執行時的讀取、增加、寫回,三個步驟是會綁在一起執行的,而不會出現以上線程1線程2 執行交錯的狀況

使用 Atomics

Atomics 底下提供了數個靜態方法,這裡我們只先簡短介紹以下範例會用到的 Atomics.add()Atomics.load(),使用這兩個方法分別可以以原子操作的方式更改值及讀取值:

const buffer = new SharedArrayBuffer(16);
const uint8 = new Uint8Array(buffer);
uint8[0] = 7;

// 將 uint8[0] 以原子操作的方式 +2
console.log(Atomics.add(uint8, 0, 2));

// 將 uint8[0] 以原子操作的方式讀取
console.log(Atomics.load(uint8, 0)); // 9

另外這些原子操作的方法,第一個參數傳入的都是 TypeArray 型別,像上面使用的是 Uint8Array

範例

目的

  1. 藉由範例展示單獨使用 SharedArrayBuffer 時可能發生的 race condition 狀況
  2. 使用 Atomics 原子操作避免不同線程間造成的 race condition

說明

  1. 範例中會使用兩個線程同時操作 SharedArrayBuffer 創建的同一塊記憶體
  2. 兩個線程中分別會運行一定次數 (ex. 1000 次),而每次運行都會將某塊記憶體中的數值 +1
  3. 最後檢查總次數與記憶體中的數值是否一致,如果不一致的話代表在加法操作中有發生 race condition

範例 Demo

https://ithelp.ithome.com.tw/upload/images/20231005/20162687rDcx7Q2TEa.png

建立 SharedArrayBuffer 並傳送訊息
首先建立 SharedArrayBuffer,並將 max: 每個線程計算的次數(預設 1000 次)isAtomicEnabled: 是否啟用 Atomic 也一起傳到 worker 線程

// 主線程
const worker = new Worker('public/worker.js');

document.querySelector('button').onclick = (e) => {
  const sab = new SharedArrayBuffer(4);
  const arr = new Uint32Array(sab);

  const max = document.querySelector('.max').value;
  const isAtomicEnabled = document.querySelector('.enable').checked;
  
  worker.postMessage({ arr, max, isAtomicEnabled });
}

主線程改變記憶體中的數值
主線程在送出訊息給 worker 後,馬上改變 arr[0] 這塊記憶體中的數值,這裡利用了 requestAnimationFrame,要求瀏覽器在每幀中不斷對 arr[0] 這塊記憶體一直執行累加操作,直到達到了 max 次後才結束,並把累加後的結果顯示在畫面上

這裡每輪的累加操作會根據是否啟用 Atomics 而執行不同的方法,有開啟的話就使用 Atomics.add,沒有的話就使用一般矩陣的加法 arr[0] += 1

// 主線程
let count = 0;
const add = () => {
  if (count >= max) {
    // 主線程累加計算結束
    // 顯示 arr[0] 這塊記憶體的數值
    console.log('main result:', arr[0])
    document.querySelector('.result').textContent = arr[0];
    return;
  }
  count++;
  
  if (isAtomicEnabled) {
    Atomics.add(arr, 0, 1);
  } else {
    arr[0] += 1;
  }

  const value = isAtomicEnabled ? Atomics.load(arr, 0) : arr[0];
  console.log('main:', count, value);
  requestAnimationFrame(add);
}

requestAnimationFrame(add);

worker 線程改變記憶體中的數值
worker 線程在收到訊息後,也跟主線程執行同樣的邏輯,對同一塊記憶體 arr[0] 執行累加的操作,累加完畢後將 arr[0] 這塊記憶體的數值傳遞回主線程,並由主線程顯示在畫面上

// worker 線程
self.onmessage = (e) => {
  const { arr, max, isAtomicEnabled } = e.data;

  let count = 0;
  const add = () => {
    if (count >= max) {
      // worker 線程累加計算結束
      // 將 arr[0] 這塊記憶體的數值傳遞給主線程知道
      console.log('worker result:', arr[0]);
      self.postMessage(arr[0]);
      return;
    }
    count++;

    if (isAtomicEnabled) {
      Atomics.add(arr, 0, 1);
    } else {
      arr[0] += 1;
    }

    const value = isAtomicEnabled ? Atomics.load(arr, 0) : arr[0];
    console.log('worker:', count, value);
    requestAnimationFrame(add);
  };

  requestAnimationFrame(add);
};

主線程接收 worker 線程傳來的資料
worker 線程計算完畢後,會將最新的 arr[0] 這塊記憶體的數值丟回給主線程,並由主線程顯示這塊記憶體的值

worker.onmessage = (e) => {
    const maxCount = e.data;
    document.querySelector('.result').textContent = maxCount;
}

結果

  • 未啟用原子操作
    未啟用原子操作,使用一般矩陣的加法 arr[0] +=1 時,會發現有時候結果的值不等於 2000,這代表出現了 race condition 的狀況

https://ithelp.ithome.com.tw/upload/images/20231005/20162687p7Sf56ev4W.png

另外大家也可以打開 devtool 中的 console 查看每輪計算的數值,會發現當出現 race condition 時,同一輪中 主線程(main)worker線程 會讀取到 同樣的數值(467),這就像是這篇開頭提到的狀況,兩個線程執行的時間交錯,所以一開始同時讀取到的都是 0,導致最後相加結果為 1 而不是 2,這裡也是類似的狀況,其中某輪的運算兩個線程同時讀取到的值都是 467,所以寫回的時候只更新成 468 而不是 469,因此最後累加出來的值就會比 2000 還少了

https://ithelp.ithome.com.tw/upload/images/20231005/20162687bSm0zsbR4b.png

  • 啟用原子操作
    啟用原子操作,可以保證對同一塊記憶體操作時,都是基於當下記憶體的值去做相加,所以最後的值都會等於 2000

https://ithelp.ithome.com.tw/upload/images/20231005/20162687UzcIgQf5Tj.png

小結

使用 Atomics 可以避免不同線程操作同一塊記憶體造成的 race condition 問題,但實際上使用多線程開發還需要考慮許多同步的問題,而這些同步的問題實際上是很困難且繁瑣的,因此正確使用 SharedArrayBuffer 的方式應該是依賴專業開發人員提供的套件來處理多線程操作,但或許是網頁上極少有用到多線程的狀況,看來還沒有專業的開發人員編寫相關套件專門處理這種同步的問題。

Reference

Avoiding race conditions in SharedArrayBuffers with Atomics
漫畫方式學 Atomic 的親切文章

Using JavaScript SharedArrayBuffers and Atomics
並行程式的潛在問題(一)


上一篇
在 Javascript 中共享記憶體 - SharedArrayBuffer
下一篇
在 Web worker 中處理音效 - AudioWorklet
系列文
網頁的另一個大腦:從基礎到進階掌握 Web Worker 技術30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言