前言

前些日子我投稿的「不小心做出了世界最強的 Wasm 編譯器這件事」,不知不覺已經突破 2 萬 PV,也收到了很多迴響與星標。感謝每一位閱讀的人。

上一篇文章中,我提到「在維持 Go 風格語法的前提下,做出免 libc、僅 2.56KB 的 Wasm」。

但這還沒結束。

「既然都能在瀏覽器跑了,我也想把用 Web Worker 實作的多執行緒並行處理(類似 Go 的 go 語句與 channel 的並行計算)乾脆俐落地跑起來。」

抱著這個想法,我自行實作了並行處理 runtime,並公開了一個可在瀏覽器上實際運作的技術展示頁。

通常一提到要在 WebAssembly 中處理多執行緒,二進位大小很容易就膨脹到 MB 等級。

然而,當我從根本上重新審視設計理念、反推式地組建執行緒控制後——
包含同步等待在內的多執行緒並行計算之 Wasm 二進位大小,竟然比前一篇的 2.56KB 還小,只有「1.46KB」。

小到令人懷疑是不是搞錯了,但它確實能在瀏覽器的 Worker 執行緒上,讓真正的並行計算在不到 1 毫秒內跑完。這次就來公開,為什麼能以如此極小的大小實現 Wasm 多執行緒並行處理,以及背後的架構。


WASM 多執行緒的「高不可攀門檻」

過去想在 WebAssembly 中實現多執行緒,主流大致有以下兩種方式。

  1. 把帶有 pthread 的 C runtime 整包編譯進 WASM(如 Emscripten 等)
    為了模擬 POSIX 執行緒,必須把龐大的 C 標準函式庫(如 musl libc)、虛擬檔案系統,以及厚重的 JS glue code 一起帶進來,哪怕是最小化程式碼,體積也會膨脹到數百 KB 至數 MB。
  2. 採用 WASI-threads 規格
    這是仍在標準化進行中的規格,但 runtime 端的實作依賴很大,要帶進瀏覽器環境往往需要厚重的 polyfill 與複雜的建置流程。

這兩種方法的共通點在於:
「把 OS 原生的執行緒模型與排程器,硬生生在 WASM 這個小世界裡重新發明一次。」

正因為想把 M:N 排程器與複雜同步原語全都包進 WASM 二進位,導致體積暴增,也迫使開發者承擔沉重的取捨。


哥白尼式轉向:「JS Hook 被呼叫的瞬間,已經是完全同步」

於是我把思路整個反過來了。

瀏覽器天生就具備世界頂尖工程師花了數十年打磨出的「事件迴圈」「Web Worker」「訊息佇列」這套極其成熟的非同步與並行基礎設施。

那麼,真的有必要在 WASM 內部再造一個模擬 OS 排程器的虛擬世界嗎?

我注意到一個事實:
WASM 執行緒從 JavaScript 端提供的 host API 發出呼叫的那一刻,WASM 執行緒之間其實就是完全同步的。

  • 不再讓 WASM 端自己跑複雜的同步迴圈。
  • 執行緒啟動(hike_thread_spawn)與等待通知的觸發,交給輕量的 JavaScript hook。
  • WASM 端的職責只保留為「接收 thunk function pointer、執行、把回傳值寫入 buffer」這種純計算與記憶體配置。

透過這種「利用 host(JS)自然產生的同步壓力」的反推式執行緒控制,WASM 二進位中厚重的排程程式碼被整個消滅了。


支撐 1.46KB 的三大架構

1. 主機端主導的記憶體握手

我廢除了傳統的靜態記憶體配置,改為在 WASM 啟動時向主機查詢 heap 大小的協議。

; WASM 側的啟動流程(LLVM IR)
declare i32 @requestHeapSizeKB()
...
%v4 = call i32 @requestHeapSizeKB() ; 向 JS host 查詢允許的大小

WASM 內部不再攜帶固定長度的 heap,而是由 JavaScript 端指定「允許 64KB」「允許 256KB」等大小,按需求擴充 linear memory(memory.grow)後再交付。如此一來,不僅減少初始記憶體浪費,也能從瀏覽器 UI 動態調整記憶體上限。

2. 整合為單一 runtime.js 的多型 runtime

使用 Web Worker 的設計通常需要 main.jsworker.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());
            ...
        }
    }
}

透過自動判定執行環境並切換行為,可以避免檔案拿錯或快取不一致的問題,編譯器輸出的檔案也會非常乾淨。

3. 直覺的 Go 風格語法

雖然已經削到極致,但實際撰寫的程式碼仍然是跟 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 的內部行為。

1. Async(func() ...):匿名函式的 thunk 化與工作發送

當把匿名函式傳給 Async 內建函式的瞬間,編譯器與極小 runtime 會協同完成以下處理。

  1. 註冊到間接函式表(thunk 化)
    傳入的匿名函式會被編譯成獨立函式,並以函式指標(索引)的形式註冊到 WASM 的函式表(__indirect_function_table)。
  2. 配置工作描述子
    runtime 會在共享 linear memory 上配置「工作描述子(Task Descriptor)」。其中包含「寫入結果的記憶體 buffer 位址」與「完成通知用的事件 ID」。
  3. 非同步委派給 host(JS)
    從 WASM 側呼叫 host API hike_thread_spawn(funcIndex, paramPtr)。瀏覽器的 Web Worker thread pool 會接收到這個工作,而 WASM 端的 Async 呼叫則會立刻回傳「工作 handle(描述子指標)」並繼續往下執行。

2. sum, mul := <-...:同步回收與堆疊上的多值解包

左箭頭運算子 <- 是 Hike 語言中「等待工作完成並取出結果」的同步運算子(Await)。

  • 直覺的多值指定
    當非同步函式回傳多個值(這裡是 (int, int))時,會直接從與工作描述子綁定的結果 buffer 中低階讀出值,並一次性解包指派給呼叫端的區域變數 summul
  • 開發者完全不需要寫「callback 地獄」或「Promise chain」,可以用跟同步程式完全相同的心智模型接收並行計算結果。

3. 在並行 WASM 中「等待」的物理機制

在 Web 瀏覽器上執行 WASM 時,最大的難題之一是「瀏覽器主執行緒(UI thread)依規範不允許同步阻塞等待」。一旦做同步阻塞,瀏覽器畫面繪製與按鈕操作就會完全卡死。

Hike 透過以下設計突破這個限制。

  • main() 本身在 Main Worker 上執行的架構
    不直接在負責繪製畫面的主執行緒上跑 WASM,而是把 Hike 的 main() 本身移到背景的「Main Worker」執行。
  • Atomics.wait 實現真正的同步睡眠
    只要在 Worker 執行緒上,就允許透過共享記憶體(SharedArrayBuffer)進行同步阻塞。
    在用 <- 等待結果時,主程序不會用 CPU 100% 的 busy loop(spin lock)硬轉,而是直接呼叫 Atomics.wait 讓執行緒完全進入睡眠(休眠)狀態。
  • 靠通知喚醒與繼續執行
    背後負責計算的子執行緒一完成,並把結果(18 與 45)寫入共享記憶體的瞬間,就會發出 Atomics.notify。睡眠中的主程序會在不到 1 毫秒內醒來,脫離原本等待的 <-,立刻執行後續處理。

「既不讓 UI 有任何卡頓,又能在 WASM 執行緒內實現完全同步阻塞。」正因為有這套架構,Web 上才能成立 <-Async(...) 這種 Go 風格的同步程式設計。


為什麼能運作?瀏覽器的安全門檻(COOP / COEP)

要在本機或伺服器上跑這個技術展示,無法避開的就是瀏覽器安全規範。

WASM 多執行緒仰賴在 Worker 之間共享記憶體的 SharedArrayBufferWebAssembly.Memory({ shared: true }))以及 Atomics 指令。但現代瀏覽器為了防止 Spectre 之類的側通道攻擊,只有在伺服器輸出以下 HTTP 標頭、完成「跨來源隔離」後,才會允許建立共享記憶體。

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

在這個展示環境中,透過加上這些標頭,既能維持瀏覽器安全性,也能釋放 WASM 多執行緒的真正潛力。


實際可運行的示範與儲存庫

這不是理論而已,你可以直接在瀏覽器上體驗計算於不到 1 毫秒內結束的樣子。

不必等龐大的 C runtime 或厚重規格成熟,只要熟悉瀏覽器本來就有的原語,並把邊界設計正確,就能用僅 1.46KB 做出實用的多執行緒語言環境。

把「小就是正義」發揮到極致的自製語言世界,歡迎到示範頁按按按鈕親自體驗看看。


原文出處:https://qiita.com/kanryu/items/a031d4928ddb7caa0784


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝30   💬1  
446
🥈
我愛JS
3
🥉
NewsData
2
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登