昨天介紹了 SharedArrayBuffer
,使用 SharedArrayBuffer
可以在不同的線程中共享記憶體,達到高效的運算功能,但隨之而來的缺點就是不同線程間操作同一記憶體帶來的 競爭衝突 (race condition)
假設有兩個線程各自都會將同一個變數 +1,在理想的狀態下,他們會這樣執行,最後的變數值為 2
但如果他們在執行的時間上交錯時,可能會發生以下狀況,最後導致變數的值為 1
Atomics 用來在 Javascript
中解決 race condition 的問題,使用 Atomics
代表每一次操作都是單一原子化,無法分割的,什麼是無法分割的意思呢?以上述例子來看,指的就是 線程1 執行時的讀取、增加、寫回,三個步驟是會綁在一起執行的,而不會出現以上線程1 與 線程2 執行交錯的狀況
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
目的
SharedArrayBuffer
時可能發生的 race condition 狀況Atomics
原子操作避免不同線程間造成的 race condition
說明
SharedArrayBuffer
創建的同一塊記憶體建立 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
的狀況另外大家也可以打開 devtool
中的 console
查看每輪計算的數值,會發現當出現 race condition
時,同一輪中 主線程(main) 跟 worker線程 會讀取到 同樣的數值(467),這就像是這篇開頭提到的狀況,兩個線程執行的時間交錯,所以一開始同時讀取到的都是 0,導致最後相加結果為 1 而不是 2,這裡也是類似的狀況,其中某輪的運算兩個線程同時讀取到的值都是 467,所以寫回的時候只更新成 468 而不是 469,因此最後累加出來的值就會比 2000 還少了
使用 Atomics
可以避免不同線程操作同一塊記憶體造成的 race condition
問題,但實際上使用多線程開發還需要考慮許多同步的問題,而這些同步的問題實際上是很困難且繁瑣的,因此正確使用 SharedArrayBuffer
的方式應該是依賴專業開發人員提供的套件來處理多線程操作,但或許是網頁上極少有用到多線程的狀況,看來還沒有專業的開發人員編寫相關套件專門處理這種同步的問題。
Avoiding race conditions in SharedArrayBuffers with Atomics
漫畫方式學 Atomic 的親切文章
Using JavaScript SharedArrayBuffers and Atomics
並行程式的潛在問題(一)