前些日子我投稿的「不小心做出了世界最強的 Wasm 編譯器這件事」,不知不覺已經突破 2 萬 PV,也收到了很多迴響與星標。感謝每一位閱讀的人。
上一篇文章中,我提到「在維持 Go 風格語法的前提下,做出免 libc、僅 2.56KB 的 Wasm」。
但這還沒結束。
「既然都能在瀏覽器跑了,我也想把用 Web Worker 實作的多執行緒並行處理(類似 Go 的 go 語句與 channel 的並行計算)乾脆俐落地跑起來。」
抱著這個想法,我自行實作了並行處理 runtime,並公開了一個可在瀏覽器上實際運作的技術展示頁。
通常一提到要在 WebAssembly 中處理多執行緒,二進位大小很容易就膨脹到 MB 等級。
然而,當我從根本上重新審視設計理念、反推式地組建執行緒控制後——
包含同步等待在內的多執行緒並行計算之 Wasm 二進位大小,竟然比前一篇的 2.56KB 還小,只有「1.46KB」。
小到令人懷疑是不是搞錯了,但它確實能在瀏覽器的 Worker 執行緒上,讓真正的並行計算在不到 1 毫秒內跑完。這次就來公開,為什麼能以如此極小的大小實現 Wasm 多執行緒並行處理,以及背後的架構。
過去想在 WebAssembly 中實現多執行緒,主流大致有以下兩種方式。
這兩種方法的共通點在於:
「把 OS 原生的執行緒模型與排程器,硬生生在 WASM 這個小世界裡重新發明一次。」
正因為想把 M:N 排程器與複雜同步原語全都包進 WASM 二進位,導致體積暴增,也迫使開發者承擔沉重的取捨。
於是我把思路整個反過來了。
瀏覽器天生就具備世界頂尖工程師花了數十年打磨出的「事件迴圈」「Web Worker」「訊息佇列」這套極其成熟的非同步與並行基礎設施。
那麼,真的有必要在 WASM 內部再造一個模擬 OS 排程器的虛擬世界嗎?
我注意到一個事實:
WASM 執行緒從 JavaScript 端提供的 host API 發出呼叫的那一刻,WASM 執行緒之間其實就是完全同步的。
hike_thread_spawn)與等待通知的觸發,交給輕量的 JavaScript hook。透過這種「利用 host(JS)自然產生的同步壓力」的反推式執行緒控制,WASM 二進位中厚重的排程程式碼被整個消滅了。
我廢除了傳統的靜態記憶體配置,改為在 WASM 啟動時向主機查詢 heap 大小的協議。
; WASM 側的啟動流程(LLVM IR)
declare i32 @requestHeapSizeKB()
...
%v4 = call i32 @requestHeapSizeKB() ; 向 JS host 查詢允許的大小
WASM 內部不再攜帶固定長度的 heap,而是由 JavaScript 端指定「允許 64KB」「允許 256KB」等大小,按需求擴充 linear memory(memory.grow)後再交付。如此一來,不僅減少初始記憶體浪費,也能從瀏覽器 UI 動態調整記憶體上限。
runtime.js 的多型 runtime使用 Web Worker 的設計通常需要 main.js 與 worker.js 兩個檔案,但 Hike 只整合成一個 runtime.js。
// 自動偵測執行環境
const isWorker = typeof importScripts === 'function' ||
(typeof window === 'undefined' && typeof self !== 'undefined');
if (isWorker) {
// Web Worker 環境:WASM 實例化與並行任務的執行容器
self.onmessage = async (e) => { ... };
} else {
// 主執行緒環境:把自己(runtime.js)作為 Worker 啟動
class HikeConcurrentRuntime {
async start() {
this.worker = new Worker('runtime.js?t=' + Date.now());
...
}
}
}
透過自動判定執行環境並切換行為,可以避免檔案拿錯或快取不一致的問題,編譯器輸出的檔案也會非常乾淨。
雖然已經削到極致,但實際撰寫的程式碼仍然是跟 Go 語言一樣直覺的並行語法。
package main
// 向 JavaScript host 查詢可用的 heap 大小(KB)
extern func requestHeapSizeKB() int
// 記憶體配置函式(runtime 提供)
extern func malloc(size int) *byte
// 通知 JavaScript 端結果的 callback
extern func displayResult(sum int, mul int)
func compute(a int, b int) (int, int) {
return a + b, a * b
}
func main() int {
// 1. 向 JavaScript host 查詢可用大小(KB)
heapKB := requestHeapSizeKB()
// 2. 配置 JavaScript 指定大小的記憶體
heapBuffer := malloc(heapKB * 1024)
// 3. 將任務卸載到 worker thread,並用 <- 同步回收
sum, mul := <-Async(func() (int, int) {
return compute(15, 3)
})
// 4. 將計算結果反映到畫面 UI
displayResult(sum, mul)
return 0
}
低階同步原語與 Worker 訊息處理全都由編譯器與極小 runtime 在背後解決,開發者只需要撰寫並行計算邏輯即可。
sum, mul := <-Async(func() (int, int) {
return compute(15, 3)
})
乍看之下只是像 Go 語言的 goroutine 與 channel 接收一樣直覺的程式碼,但這一行背後,其實是「函式 thunk 化」「分派到 thread pool」「透過共享記憶體進行真正的阻塞等待」「多值解包」這一整套高階管線在幾奈秒內連鎖運作。
下面分成三個步驟說明 runtime 與 WASM 的內部行為。
Async(func() ...):匿名函式的 thunk 化與工作發送當把匿名函式傳給 Async 內建函式的瞬間,編譯器與極小 runtime 會協同完成以下處理。
__indirect_function_table)。hike_thread_spawn(funcIndex, paramPtr)。瀏覽器的 Web Worker thread pool 會接收到這個工作,而 WASM 端的 Async 呼叫則會立刻回傳「工作 handle(描述子指標)」並繼續往下執行。sum, mul := <-...:同步回收與堆疊上的多值解包左箭頭運算子 <- 是 Hike 語言中「等待工作完成並取出結果」的同步運算子(Await)。
(int, int))時,會直接從與工作描述子綁定的結果 buffer 中低階讀出值,並一次性解包指派給呼叫端的區域變數 sum 與 mul。在 Web 瀏覽器上執行 WASM 時,最大的難題之一是「瀏覽器主執行緒(UI thread)依規範不允許同步阻塞等待」。一旦做同步阻塞,瀏覽器畫面繪製與按鈕操作就會完全卡死。
Hike 透過以下設計突破這個限制。
main() 本身在 Main Worker 上執行的架構:main() 本身移到背景的「Main Worker」執行。Atomics.wait 實現真正的同步睡眠:SharedArrayBuffer)進行同步阻塞。<- 等待結果時,主程序不會用 CPU 100% 的 busy loop(spin lock)硬轉,而是直接呼叫 Atomics.wait 讓執行緒完全進入睡眠(休眠)狀態。Atomics.notify。睡眠中的主程序會在不到 1 毫秒內醒來,脫離原本等待的 <-,立刻執行後續處理。「既不讓 UI 有任何卡頓,又能在 WASM 執行緒內實現完全同步阻塞。」正因為有這套架構,Web 上才能成立 <-Async(...) 這種 Go 風格的同步程式設計。
要在本機或伺服器上跑這個技術展示,無法避開的就是瀏覽器安全規範。
WASM 多執行緒仰賴在 Worker 之間共享記憶體的 SharedArrayBuffer(WebAssembly.Memory({ shared: true }))以及 Atomics 指令。但現代瀏覽器為了防止 Spectre 之類的側通道攻擊,只有在伺服器輸出以下 HTTP 標頭、完成「跨來源隔離」後,才會允許建立共享記憶體。
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
在這個展示環境中,透過加上這些標頭,既能維持瀏覽器安全性,也能釋放 WASM 多執行緒的真正潛力。
這不是理論而已,你可以直接在瀏覽器上體驗計算於不到 1 毫秒內結束的樣子。
不必等龐大的 C runtime 或厚重規格成熟,只要熟悉瀏覽器本來就有的原語,並把邊界設計正確,就能用僅 1.46KB 做出實用的多執行緒語言環境。
把「小就是正義」發揮到極致的自製語言世界,歡迎到示範頁按按按鈕親自體驗看看。