幾年前WebAssembly剛剛發佈時還是一個MVP(最小可行產品),只有很少的一組功能來提供基本的可用性和實用性。彼時這個MVP缺少一個重要特性,就是多線程支持。而如今WebAssembly的多線程支持已經非常成熟了,可在工具和Chrome中使用。這篇博文探討了此功能的內部機制與新的指令集,並介紹了這一功能如何爲多線程應用程序提供支持。
多線程和併發
在深入研究WebAssembly多線程規範的細節之前,我們先來簡單瞭解一下併發和多線程技術,看看它們的涵義、我們使用這些技術的原因以及它們帶來的挑戰。如果你是這方面的老手,大可直接跳過這部分!
現代計算機有多個CPU(而且每顆CPU有多個內核——但爲了簡單起見,我將它們都簡稱爲CPU),每個CPU都能夠獨立執行程序。我們可以通過多種方式利用這一優勢。例如,如果你要處理一個計算密集型任務(如圖像處理),則可以將任務拆分到多個CPU上以更快地完成工作;或者如果你的任務需要花費的時間不算短(比如說一兩秒),那麼最好不要把這個任務放到負責刷新應用UI的CPU上執行,而是把它放到另一個CPU上運行以保持60fps的UI幀速率。
線程是編程結構的底層,使你可以在這些CPU之間分配工作。線程和CPU之間沒有直接映射關係,但實踐中你需要創建多個線程來實現併發處理。
創建充分利用併發能力的應用程序是很有挑戰性的,你必須推斷每個執行的“線程”,每個線程都具有本地和共享狀態的組合。你還需要其他一些工具,例如“(線程)鎖”,用來防止多個線程執行同一段代碼。但是這些工具又會引入進一步的挑戰,例如過度鎖定影響併發性或死鎖等問題。
現代工具、框架和架構方法一般都會隱藏併發性,這很容易理解。NodeJS、Lambda函數和主流瀏覽器都表現爲單線程環境。個人來說,我必須承認自己上一次創建一個線程池是好幾年以前的事情了!
雖然現在使用多線程的情況不太常見,但也有時候你還是要用它才行。
Web Worker和瀏覽器中的併發性
有許多API支持JavaScript開發人員在瀏覽器中使用併發能力。我們簡單看一下這些API和它們的現狀。
Web瀏覽器本質上是單線程的(從開發人員的角度來看),你的應用程序邏輯與UI呈現引擎共享相同的線程。因此長時間運行的計算將導致UI鎖定或掛起。這種方法以較少的代價換來了極大的便利,所以衆多UI框架(例如WPF、WinForms、Cocoa/iOS)都採用了這種方法。
Web Worker API可以讓你生成“Worker”線程,這種API已經流行多年。你可以用它創建在給定文件中執行JavaScript的Worker。儘管Worker可以發出XHR請求並使用WebSockets,但它們沒有DOM訪問權限。Worker不能與主(UI)線程共享可變數據,而是依賴於異步消息傳遞:
// ## main.js
const worker = new Worker("worker.js");
// pass a message to the worker
worker.postMessage("Hello World!");
// ## worker.js
// The onmessage property uses an event handler to retrieve information sent to a worker.
onmessage = event => {
console.log("Received message " + event.data);
};
Web Worker提供了一個非常簡單的API,可以避免許多與併發相關的棘手問題。但實際上只有粗粒度的併發方法能這麼簡單地處理——所謂粗粒度是指傳遞給worker的都是相對較大的任務。
最近,新引入的SharedArrayBuffer和原子操作使開發人員能跨多個線程使用共享的內存了。這樣以來就能實現更細粒度的併發算法。Lin Clark寫了一份精彩的卡通指南介紹了原子操作的重要性。
遺憾的是,雖然共享數組緩存之前已得到了廣泛支持,但Spectre和Meltdown漏洞(使數據在進程之間泄漏的時序攻擊)的威脅迫使所有瀏覽器廠商迅速停止了支持,以減少這些漏洞帶來的風險。目前只有Chrome瀏覽器通過站點隔離和跨源讀取屏蔽技術控制了相關風險,進而重新啓用了這些功能(2018年末)。如果有合適的風險控制措施的話,我認爲其他瀏覽器也會恢復支持的。
WebAssembly多線程提案
那麼WebAssembly中應該在哪裏應用多線程支持呢?最早的MVP版本沒有任何與多線程相關的結構,因爲人們認爲這並不是MVP的必要功能——這一選擇顯然很正確,因爲已有的功能集合已經足夠人們創造出各種有趣和有用的事物了。
我個人認爲WebAssembly的多線程支持很重要,原因有二: 首先,WebAssembly是處理計算密集型任務的理想技術,若能將這些任務分配到多個CPU上會有很大好處;其次,大多數將WebAssembly作爲編譯目標的語言都有自己的多線程結構,因此通過這一功能可以充分發揮這些語言的潛力。
WebAssembly與JavaScript有類似的提案/規範制定過程(後者是通過TC39)。多線程支持目前是第2階段提案,意味着規範草案已完成,實現也是可用狀態。當前的規範草案可以訪問GitHub來獲取。基本上來說,它已經準備好讓開發者使用了!
WebAssembly多線程規範包含以下內容:
- 共享線性內存
- 原子操作
- wait/notify操作符
第一項是共享線性內存,它很像JavaScript的SharedArrayBuffer,允許多個WebAssembly模塊(和JavaScript主機)直接訪問相同的“內存塊”。
併發內存訪問可能會導致內存損壞(memory corruption),具體來說就是一個線程讀取一個值的時候,另一個線程卻在寫入這個值。這裏就需要用到原子操作了。原子操作指的是一組確保原子化的簡單指令(讀、寫、增量…),也就是說其他線程只能在原子操作完成時看到它們的結果。這是一個基本但至關重要的構建塊,它爲更高級別的併發概念(如鎖和屏障)鋪平了道路。
最後,wait/notify操作符提供了一種掛起線程的機制(不需要乾等),還能從另一個線程重新喚醒它。
你可能意識到這份多線程規範沒有提供產生新線程的機制。聽起來是很不可思議,但這個設計是故意的。實際上託管方(例如執行WebAssembly模塊的環境)需要自己來創建線程。這份規範提供了在多線程環境中安全有效地使用WebAssembly所需的各種工具。
深入探索
理論部分講得夠多了,該來點實戰了!
要試用WebAssembly的多線程功能,首先我需要一個適合併發的任務。我選擇的是渲染Mandelbrot分形這個經典問題,這是一個非常簡單但需要密集計算的任務,很容易分成多個可以並行處理的部分。
渲染Mandelbrot集的傳統方法是迭代圖像的每個像素,(基於定義該集合的基礎方程的迭代次數)計算這個像素的值,然後爲其上色。原理非常簡單:
for (let y = 0; y < 1200; y++) {
for (let x = 0; x < 800; x++) {
const value = mandelbrot(x, y);
setpixel(x, y, value);
}
}
每個像素的值都完全獨立於其他像素,所以我們可以很容易跨多個線程共享負載來加速上述操作。一種可能的方法是讓每個線程計算一部分像素的值,然後將各個部分的結果返回主線程以生成最終圖像。這不需要任何共享狀態,因此可以使用Web Worker實現,無需SharedArrayBuffer。但它不太適合用來測試WebAssembly的多線程功能。
另一種方法是使用單個索引來表示需要計算的下一個像素的位置,並使用一個while循環來連續獲取該值,然後計算並上色像素,以此類推——如下所示:
let index = 0;
while (index < 1200 * 800) {
index++;
const value = mandelbrot(x, y);
setpixel(index % 1200, Math.floor(index / 1200), value);
}
可以將多個線程設置爲執行上述while循環,這樣就能更快完成全部任務。但在這種情況下,index值的狀態會在多個線程之間共享,進而引發一些問題,我們之後會處理。不管怎樣,我們將採用這種方法並將其轉換爲WebAssembly實現。
手寫的併發程序
我喜歡手寫WebAssembly代碼(例如直接在原生指令集下用WebAssembly文本格式編寫代碼,而不是從更高級的語言編譯過來);本文的示例非常適合手寫代碼,因爲我想看看運行時到底提供了哪些多線程功能。
我沒有手寫整個應用程序,而是走了一些捷徑,使用WebAssembly Studio將基本算法從AssemblyScript編譯成wasm。
下面是用AssemblyScript編寫的mandelbrot算法:
function iterateEquation(x0: f64, y0: f64, maxiterations: u32): u32 {
let a = 0.0,
b = 0.0,
rx = 0.0,
ry = 0.0,
ab: f64;
let iterations: u32 = 0;
while (iterations < maxiterations && rx * rx + ry * ry <= 4) {
rx = a * a - b * b + x0;
ab = a * b;
ry = ab + ab + y0;
a = rx;
b = ry;
iterations++;
}
return iterations;
}
WebAssembly代碼如下:
(func $iterateEquation (param $p0 f64) (param $p1 f64) (param $p2 i32) (result i32)
(local $l0 i32)
(local $l1 f64)
(local $l2 f64)
(local $l3 f64)
(local $l4 f64)
loop $L0
get_local $l4
get_local $l4
f64.mul
get_local $l1
get_local $l1
f64.mul
f64.add
f64.const 0x1p+2 (;=4;)
f64.le
i32.const 0
get_local $l0
get_local $p2
i32.lt_u
select
if $I1
get_local $l2
get_local $l3
f64.mul
set_local $l1
get_local $l2
get_local $l2
f64.mul
get_local $l3
get_local $l3
f64.mul
f64.sub
get_local $p0
f64.add
tee_local $l4
set_local $l2
get_local $l1
get_local $l1
f64.add
get_local $p1
f64.add
tee_local $l1
set_local $l3
get_local $l0
i32.const 1
i32.add
set_local $l0
br $L0
end
end
get_local $l0
)
使用上面的函數和一些類似的實用程序(如顏色擴展、座標轉換等)後,我就能構建一個簡單的循環使用上述策略渲染Mandelbrot了,具體來說就是獲取下一個索引、遞增、計算並設置像素值這樣的循環。
這個函數有三個參數,$cx、$cy和$diameter,它們只用來指示mandelbrot集合中的位置:
(func $run (param $cx f64) (param $cy f64) (param $diameter f64)
(local $x i32)
(local $y i32)
(local $loc i32)
(block $myblock
(loop
;; load the next location that is being computed
(set_local $loc
(i32.load
(i32.const 0)
)
)
;; store the incremented version
(i32.store
(i32.const 0)
(i32.add
(get_local $loc)
(i32.const 1)
)
)
;; loop for 1200 * 800
(br_if $myblock
(i32.ge_u
(get_local $loc)
(i32.const 960000)
)
)
;; convert to coordinates
(set_local $y
(i32.div_u
(get_local $loc)
(i32.const 1200)
)
)
(set_local $x
(i32.rem_u
(get_local $loc)
(i32.const 1200)
)
)
;; compute the next mandelbrot step and store
(i32.store
(call $offsetFromCoordinate
(get_local $x)
(get_local $y)
)
(call $colour
(call $mandelbrot
(get_local $cx)
(get_local $cy)
(get_local $diameter)
(get_local $x)
(get_local $y)
)
)
)
;; repeat the current loop
(br 0)
)
)
)
我不打算深入探討上面的代碼示例中各種指令的細節,希望大家能一看就懂。值得一提的是一些應用程序狀態的存儲位置。
索引是用來指示下一個要計算的像素的,從加載索引並設置位置$loc變量的代碼可以看出,它是存儲在位置0的線性內存中的。Mandelbrot本身也會寫入線性內存,而$offsetFromCoordinate提供所需的偏移量(從位置4開始,以免覆蓋上述索引!)。
那麼該怎樣改動這些代碼來實現併發計算呢?
計算mandelbrot結果的函數和其他實用程序不是問題所在——它們是無狀態的,也就是說它們肯定是線程安全的。此外,存儲每個像素結果的寫操作也不是問題,因爲這些寫操作永遠不會寫到同一個位置上。實際上唯一的問題是讀取、遞增和寫入當前索引這部分內容,它使用內存地址0,所以會受到併發讀/寫的影響,並且爲了避免內存損壞需要原子化。
目前的多線程規範提供了原子加載、存儲、讀取-修改-寫入和比較-交換操作。本例中,加載、遞增和寫入索引的全部過程都需要原子化,正好可以用上原子讀取-修改-寫入操作。
執行此步驟的原有(非線程安全)代碼如下:
;; load the next location that is being computed
(set_local $loc
(i32.load
(i32.const 0)
)
)
;; store the incremented version
(i32.store
(i32.const 0)
(i32.add
(get_local $loc)
(i32.const 1)
)
)
它可以用原子化等效操作替換如下:
(set_local $loc
(i32.atomic.rmw.add
(i32.const 0)
(i32.const 1)
)
)
這裏i32.atomic.rmw.add執行的是原子讀取-修改-寫入的原子添加操作,上面示例中0和1兩個參數會使位於第0個存儲器地址的值加1。
很簡單嘛!那麼我們如何編譯和運行這段代碼呢?
WebAssembly二進制工具包有許多用於處理wasm模塊的相對底層的工具,其中就有wat2wasm;這是一種將WebAssembly文本格式轉換爲發送到瀏覽器的二進制模塊格式的工具。此工具有自己的功能標誌,可用來轉換使用post-MVP功能的模塊,如下所示:
wat2wasm --enable-threads mandelbrot.wat -o mandelbrot.wasm
Wasm線性內存既可以由模塊本身創建,也可以在創建模塊時導入。在本例中我們需要將內存標記爲“共享”並將其提供給多個模塊(每個模塊都位於不同的Web worker中)。線性內存內部使用了一個SharedArrayBuffer,這也就是爲什麼這段代碼目前只能跑在Chrome上的原因之一。
下面的代碼創建了一個共享內存實例,生成許多Web worker,然後等待它們全部通報完成狀態,最後將內存中的內容渲染到畫布上:
const memory = new WebAssembly.Memory({
initial: 80,
maximum: 80,
shared: true
});
// https://commons.wikimedia.org/wiki/File:Mandel_zoom_04_seehorse_tail.jpg
const config = {
x: -0.743644786,
y: 0.1318252536,
d: 0.00029336
};
const workerCount = 4;
let doneCount = 0;
// spawn the required number of workers
for (let i = 0; i < workerCount; i++) {
const worker = new Worker("worker.js");
worker.onmessage = e => {
doneCount++;
// have they all finished?
if (doneCount === workerCount) {
// copy the contents of wasm linear memory to a canvas element
const canvasData = new Uint8Array(memory.buffer, 4, 1200 * 800 * 4);
const context = document.querySelector("canvas").getContext("2d");
const imageData = context.createImageData(1200, 800);
imageData.data.set(canvasData);
context.putImageData(imageData, 0, 0);
}
};
worker.postMessage({ memory, config });
}
上面的代碼使用postMessage發送這段共享內存,還使用了config對象描述要渲染給每個worker的位置。
下面是worker.js腳本:
onmessage = ({ data }) => {
const {
memory,
config: { x, y, d }
} = data;
fetch("mandlbrot.wasm")
.then(response => response.arrayBuffer())
.then(bytes =>
WebAssembly.instantiate(bytes, {
env: {
memory
}
})
)
.then(({ instance }) => {
instance.exports.run(x, y, d, id);
postMessage("done");
});
};
它只用來實例化WebAssembly模塊,提供共享內存,然後讓它運行,最後將消息發送回主線程。讓人感到驚喜的是,只要寫這麼少的代碼就能把負載分配在多個線程上了。
回顧一下,每個線程不斷獲取要從共享內存計算的下一個像素位置,然後將結果寫入同一段共享內存中。所有像素都計算完畢後所有線程都會終止,最終生成一幅經典的mandelbrot分形:
那麼,這種方法有多快?
我的機器有4個核心,我測得的成績如下:
- 1個線程 - 11秒
- 2個線程 - 5.7秒
- 4個線程 - 4.2秒
速度顯著提升!
最後我想換種方法重新渲染一遍,這次每個像素根據計算它的線程來上色。
下面是完整的mandelbrot結果:
如果你不熟悉Mandelbrot集合的工作機制的話,注意這裏每個像素的顏色取決於基礎方程“逃逸”所需的迭代次數。暗區是等式永遠不會逃逸並無限迭代的地方(直至達到上界)。正因如此,黑暗區域需要花費更多的計算時間。
下面是由線程上色的過程:
我發現這是一幅引人入勝的畫面!
在圖像頂部,你可以看到第一個線程在分形的一些較簡單的部分上進展迅速。接下來你會看到有些區域中各個線程一個像素接一個像素地輪流工作。渲染到暗區時方程會無限迭代,所有四個線程都會輪流運算,而第一個算完的線程會一路跑下去畫完這條線——如右邊的條紋圖案所示。
結論
WebAssembly多線程支持是一個非常有趣的新功能,它爲WebAssembly帶來了共享內存、原子操作和wait/notify操作符。可惜這個功能與SharedArrayBuffer有一些關聯,因此我們要等到Chrome以外的瀏覽器解決相關漏洞,才能看到這一功能得到廣泛支持。等到那一天來臨時,我相信瀏覽器中會出現一些非常強大的應用程序!
如果你想深入研究相關代碼的話,所有內容都放到了GitHub上。你還可以在Chrome瀏覽器中運行代碼。
英文原文:
https://blog.scottlogic.com/2019/07/15/multithreaded-webassembly.html