瞭解如何將以其他語言編寫的多執行緒應用程式導入 WebAssembly。
WebAssembly 執行緒支援功能是 WebAssembly 最重要的效能增強功能之一。您可以使用此功能在不同的核心上並行執行程式碼的部分內容,或是在輸入資料的獨立部分上執行相同的程式碼,並將其擴充至使用者擁有的核心數量,大幅縮短整體執行時間。
本文將說明如何使用 WebAssembly 執行緒,將以 C、C++ 和 Rust 等語言編寫的多執行緒應用程式帶入網路。
WebAssembly 執行緒的運作方式
WebAssembly 執行緒並非獨立功能,而是多個元件的組合,可讓 WebAssembly 應用程式在網頁上使用傳統的多執行緒模式。
Web Workers
第一個元件是您熟悉且喜愛的 JavaScript 一般Worker。WebAssembly 執行緒會使用 new Worker
建構函式建立新的基礎執行緒。每個執行緒都會載入 JavaScript 黏合劑,然後主執行緒會使用 Worker#postMessage
方法,與其他執行緒共用編譯的 WebAssembly.Module
和共用 WebAssembly.Memory
(請參閱下方說明)。這麼做可建立通訊,讓所有執行緒都能在相同的共用記憶體上執行相同的 WebAssembly 程式碼,而無須再次透過 JavaScript。
Web Workers 已經推出十多年,且廣受支援,不需要任何特殊標記。
SharedArrayBuffer
WebAssembly 記憶體由 JavaScript API 中的 WebAssembly.Memory
物件表示。根據預設,WebAssembly.Memory
是 ArrayBuffer
的包裝函式,也就是只有單一執行緒可存取的原始位元組緩衝區。
> new WebAssembly.Memory({ initial:1, maximum:10 }).buffer
ArrayBuffer { … }
為了支援多執行緒,WebAssembly.Memory
也獲得了共用變化版本。透過 JavaScript API 或 WebAssembly 二進位檔本身,使用 shared
標記建立時,它會改為 SharedArrayBuffer
的包裝函式。這是 ArrayBuffer
的變化版本,可與其他執行緒共用,並從任一端同時讀取或修改。
> new WebAssembly.Memory({ initial:1, maximum:10, shared:true }).buffer
SharedArrayBuffer { … }
與通常用於主要執行緒和 Web Workers 之間通訊的 postMessage
不同,SharedArrayBuffer
不需要複製資料,甚至不需要等待事件迴圈傳送及接收訊息。相反地,所有執行緒幾乎都能立即看到任何變更,因此對於傳統同步化基本元素來說,這會是更理想的編譯目標。
SharedArrayBuffer
的歷史相當複雜。這項功能最初是在 2017 年中旬於多個瀏覽器中推出,但由於發現 Spectre 安全漏洞,因此在 2018 年初必須停用。具體原因是 Spectre 中的資料擷取功能仰賴時間攻擊,也就是測量特定程式碼的執行時間。為了讓這類攻擊更難執行,瀏覽器已降低 Date.now
和 performance.now
等標準時間 API 的精確度。不過,共用記憶體搭配在個別執行緒中執行的簡單計數器迴圈,也是取得高精確度時間安排的非常可靠方法,如果不大幅降低執行階段效能,要減輕這類問題就更加困難。
相反地,Chrome 68 (2018 年中旬) 則是利用網站隔離功能再次啟用 SharedArrayBuffer
,這項功能會將不同網站分成不同的程序,讓 Spectre 等側通道攻擊變得更加困難。不過,這項緩解措施仍僅限於 Chrome 桌面版,因為網站隔離功能相當耗費資源,且無法預設為在低記憶體行動裝置上的所有網站上啟用,其他供應商也尚未導入這項功能。
快轉至 2020 年,Chrome 和 Firefox 都已實作網站隔離功能,並提供標準方式,讓網站透過 COOP 和 COEP 標頭選擇加入這項功能。透過選擇加入機制,即使在低耗電裝置上,也能使用網站隔離功能,即使為所有網站啟用這項功能也無妨。如要選擇採用,請在伺服器設定中的主文件中新增下列標頭:
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
選擇加入後,您就能存取 SharedArrayBuffer
(包括由 SharedArrayBuffer
支援的 WebAssembly.Memory
)、精確的計時器、記憶體評估,以及其他基於安全性考量需要隔離來源的 API。詳情請參閱「使用 COOP 和 COEP 讓網站『跨來源隔離』」。
WebAssembly 原子
雖然 SharedArrayBuffer
允許每個執行緒讀取及寫入相同記憶體,但為了確保正確的通訊,您必須確保這些執行緒不會同時執行衝突的作業。舉例來說,當一個執行緒開始從共用位址讀取資料時,另一個執行緒可能會寫入該位址,因此第一個執行緒會取得損毀的結果。這類錯誤稱為競爭狀況。為避免競爭狀態,您必須以某種方式同步處理這些存取作業。這時就需要原子作業。
WebAssembly 原子操作是 WebAssembly 指令集的擴充功能,可讀取及寫入「原子」的資料小型儲存格 (通常為 32 位元和 64 位元整數)。也就是說,確保兩個執行緒不會同時讀取或寫入相同的儲存格,以便在低層級避免這類衝突。此外,WebAssembly 原子值還包含兩種指令類型:「wait」和「notify」,可讓一個執行緒在共用記憶體中的特定位址處進入休眠狀態 (「wait」),直到另一個執行緒透過「notify」喚醒為止。
所有較高層級的同步化基本元素 (包括管道、互斥鎖和讀寫鎖) 都是以這些指令為基礎。
如何使用 WebAssembly 執行緒
特徵偵測
WebAssembly 原子和 SharedArrayBuffer
是相對較新的功能,目前尚未在所有支援 WebAssembly 的瀏覽器中提供。如要瞭解哪些瀏覽器支援新的 WebAssembly 功能,請參閱 webassembly.org 路線圖。
為確保所有使用者都能載入應用程式,您必須建構兩個不同的 Wasm 版本,一個支援多執行緒,另一個則不支援,藉此實作漸進式增強功能。然後根據功能偵測結果載入支援的版本。如要在執行階段偵測 WebAssembly 執行緒支援功能,請使用 wasm-feature-detect 程式庫,並載入模組,如下所示:
import { threads } from 'wasm-feature-detect';
const hasThreads = await threads();
const module = await (
hasThreads
? import('./module-with-threads.js')
: import('./module-without-threads.js')
);
// …now use `module` as you normally would
接下來,我們來看看如何建構 WebAssembly 模組的多執行緒版本。
C
在 C 中,尤其是在類 Unix 系統上,使用執行緒的常用方式是透過 pthread
程式庫提供的 POSIX 執行緒。Emscripten 提供與 API 相容的實作,以便在 Web Workers、共用記憶體和原子上建構的 pthread
程式庫,讓相同程式碼可在網路上運作,而無須變更。
以下面這個例子為例:
example.c:
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
void *thread_callback(void *arg)
{
sleep(1);
printf("Inside the thread: %d\n", *(int *)arg);
return NULL;
}
int main()
{
puts("Before the thread");
pthread_t thread_id;
int arg = 42;
pthread_create(&thread_id, NULL, thread_callback, &arg);
pthread_join(thread_id, NULL);
puts("After the thread");
return 0;
}
這裡是透過 pthread.h
納入 pthread
程式庫的標頭。您也可以看到幾個處理執行緒的重要函式。
pthread_create
會建立背景執行緒。它會使用目的地來儲存執行緒句柄、一些執行緒建立屬性 (此處未傳遞任何屬性,因此只會傳遞 NULL
)、要在新執行緒中執行的回呼 (此處為 thread_callback
),以及可選的引數指標,以便您傳遞至該回呼,以便您分享來自主執行緒的部分資料。在本例中,我們會分享指向變數 arg
的指標。
您可以稍後隨時呼叫 pthread_join
,等待執行緒完成執行作業,並取得回呼傳回的結果。它會接受先前指派的執行緒句柄,以及用於儲存結果的指標。在這種情況下,沒有任何結果,因此函式會將 NULL
做為引數。
如要使用 Emscripten 使用執行緒編譯程式碼,您需要叫用 emcc
並傳遞 -pthread
參數,就像在其他平台上使用 Clang 或 GCC 編譯相同程式碼一樣:
emcc -pthread example.c -o example.js
不過,如果您嘗試在瀏覽器或 Node.js 中執行,系統會顯示警告,然後程式會停止運作:
Before the thread
Tried to spawn a new thread, but the thread pool is exhausted.
This might result in a deadlock unless some threads eventually exit or the code
explicitly breaks out to the event loop.
If you want to increase the pool size, use setting `-s PTHREAD_POOL_SIZE=...`.
If you want to throw an explicit error instead of the risk of deadlocking in those
cases, use setting `-s PTHREAD_POOL_SIZE_STRICT=2`.
[…hangs here…]
發生什麼事?問題是,網路上大多數耗時的 API 都是非同步的,且需要事件迴圈才能執行。與傳統環境相比,這項限制是一項重要的差異,因為應用程式通常會以同步、封鎖的方式執行 I/O。如要進一步瞭解,請參閱「使用 WebAssembly 的非同步網頁 API」這篇網誌文章。
在這種情況下,程式碼會同步叫用 pthread_create
來建立背景執行緒,並接著以同步方式呼叫 pthread_join
,等待背景執行緒完成執行。不過,在使用 Emscripten 編譯此程式碼時,幕後使用的 Web Workers 是屬於非同步的。因此,pthread_create
只會排定在下一個事件迴圈執行時建立新的 Worker 執行緒,但 pthread_join
會立即封鎖事件迴圈,等待該 Worker,藉此防止該 Worker 建立。這是死結的經典例子。
解決這個問題的方法之一,就是在程式啟動前先建立 Worker 集區。呼叫 pthread_create
時,它可以從集區取得可立即使用的 Worker,在其背景執行緒上執行提供的回呼,並將 Worker 傳回至集區。所有這些作業都能同步執行,因此只要集區足夠大,就不會發生任何死結。
這正是 Emscripten 透過 -s
PTHREAD_POOL_SIZE=...
選項允許的行為。您可以使用此方法指定執行緒數量,可以是固定數字,也可以是 navigator.hardwareConcurrency
這類 JavaScript 運算式,以便建立與 CPU 核心數量相同的執行緒。如果程式碼可擴充至任意數量的執行緒,後者就會很有用。
在上例中,系統只會建立一個執行緒,因此不必保留所有核心,只要使用 -s PTHREAD_POOL_SIZE=1
即可:
emcc -pthread -s PTHREAD_POOL_SIZE=1 example.c -o example.js
這次執行時,一切都會順利進行:
Before the thread
Inside the thread: 42
After the thread
Pthread 0x701510 exited.
不過,還有另一個問題:您有看到程式碼範例中的 sleep(1)
嗎?它會在執行緒回呼中執行,也就是在主執行緒之外,所以應該沒問題,對吧?不,不是。
呼叫 pthread_join
時,必須等待執行緒執行作業完成,也就是說,如果建立的執行緒正在執行長時間執行的作業 (在本例中為休眠 1 秒),則主執行緒也必須等待相同的時間,直到結果傳回為止。在瀏覽器中執行這段 JS 時,系統會阻斷 UI 執行緒 1 秒,直到執行緒回呼傳回為止。這會導致使用者體驗不佳。
解決方法如下:
pthread_detach
-s PROXY_TO_PTHREAD
- 自訂工作站和 Comlink
pthread_detach
首先,如果您只需要在主執行緒外執行部分工作,但不需要等待結果,可以使用 pthread_detach
而非 pthread_join
。這會讓執行緒回呼在背景執行。如果您使用這個選項,可以使用 -s
PTHREAD_POOL_SIZE_STRICT=0
關閉警告。
PROXY_TO_PTHREAD
其次,如果您要編譯 C 應用程式而非程式庫,可以使用 -s
PROXY_TO_PTHREAD
選項,除了應用程式本身建立的任何巢狀執行緒外,還會將主要應用程式程式碼卸載至單獨的執行緒。這樣一來,主程式碼隨時都能安全地封鎖,而不會凍結 UI。順帶一提,使用這個選項時,您也不必預先建立執行緒集區。Emscripten 可以利用主執行緒建立新的基礎 Worker,然後在 pthread_join
中封鎖輔助執行緒,而不會發生死結。
Comlink
第三,如果您正在處理程式庫,但仍需要封鎖,可以建立自己的 Worker、匯入 Emscripten 產生的程式碼,並透過 Comlink 將其公開至主執行緒。主執行緒可將任何匯出的做法叫用為非同步函式,這樣一來也能避免阻斷 UI。
在簡單的應用程式中,例如前述範例,-s PROXY_TO_PTHREAD
是最佳選項:
emcc -pthread -s PROXY_TO_PTHREAD example.c -o example.js
C++
所有相同的警告和邏輯都以相同方式套用至 C++。您獲得的唯一新功能是,可以存取 std::thread
和 std::async
等較高層級的 API,這些 API 會在幕後使用先前討論的 pthread
程式庫。
因此,上述範例可改寫成更符合 C++ 慣例的程式碼,如下所示:
example.cpp:
#include <iostream>
#include <thread>
#include <chrono>
int main()
{
puts("Before the thread");
int arg = 42;
std::thread thread([&]() {
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "Inside the thread: " << arg << std::endl;
});
thread.join();
std::cout << "After the thread" << std::endl;
return 0;
}
當您使用類似參數編譯及執行時,其行為會與 C 範例相同:
emcc -std=c++11 -pthread -s PROXY_TO_PTHREAD example.cpp -o example.js
輸出:
Before the thread
Inside the thread: 42
Pthread 0xc06190 exited.
After the thread
Proxied main thread 0xa05c18 finished with return code 0. EXIT_RUNTIME=0 set, so
keeping main thread alive for asynchronous event operations.
Pthread 0xa05c18 exited.
Rust
與 Emscripten 不同,Rust 沒有專屬的端對端網頁目標,而是為通用 WebAssembly 輸出內容提供通用 wasm32-unknown-unknown
目標。
如果 Wasm 是用於網頁環境,則與 JavaScript API 的任何互動都會交由外部程式庫和工具處理,例如 wasm-bindgen 和 wasm-pack。很遺憾,這表示標準程式庫無法偵測 Web Workers,而標準 API (例如 std::thread
) 在編譯為 WebAssembly 時將無法運作。
幸運的是,大多數的系統都會依賴較高層級的程式庫來處理多執行緒。在這個層級,您可以更輕鬆地抽象化所有平台差異。
特別是,Rayon 是 Rust 中資料並行處理的熱門選擇。這可讓您在一般疊代器上使用方法鏈結,並且通常只需變更一行程式碼,即可將這些方法鏈結轉換為在所有可用執行緒上並行執行,而非依序執行。例如:
pub fn sum_of_squares(numbers: &[i32]) -> i32 {
numbers
.iter()
.par_iter()
.map(|x| x * x)
.sum()
}
只要稍微修改一下,程式碼就會分割輸入資料,在平行執行緒中計算 x * x
和部分和,最後將這些部分結果加總起來。
為了因應沒有運作 std::thread
的平台,Rayon 提供鉤子,可定義產生及結束執行緒的自訂邏輯。
wasm-bindgen-rayon 會利用這些鉤子,將 WebAssembly 執行緒產生為 Web Worker。如要使用此功能,您必須將其新增為依附元件,並按照說明文件中所述的設定步驟進行。上方的範例最終會變成如下所示:
pub use wasm_bindgen_rayon::init_thread_pool;
#[wasm_bindgen]
pub fn sum_of_squares(numbers: &[i32]) -> i32 {
numbers
.par_iter()
.map(|x| x * x)
.sum()
}
完成後,系統會產生額外的 initThreadPool
函式。這個函式會建立 Worker 集區,並在程式生命週期內重複使用,以便處理 Rayon 執行的任何多執行緒作業。
這個集區機制與先前所述 Emscripten 中的 -s PTHREAD_POOL_SIZE=...
選項類似,也需要在主程式碼之前初始化,以免發生死結:
import init, { initThreadPool, sum_of_squares } from './pkg/index.js';
// Regular wasm-bindgen initialization.
await init();
// Thread pool initialization with the given number of threads
// (pass `navigator.hardwareConcurrency` if you want to use all cores).
await initThreadPool(navigator.hardwareConcurrency);
// ...now you can invoke any exported functions as you normally would
console.log(sum_of_squares(new Int32Array([1, 2, 3]))); // 14
請注意,封鎖主執行緒的警告也適用於此處。即使是 sum_of_squares
範例,仍需要封鎖主執行緒,等待其他執行緒的部分結果。
等待時間可能很短,也可能很長,這取決於迭代器的複雜度和可用執行緒的數量,但為了安全起見,瀏覽器引擎會主動避免完全阻斷主執行緒,而這類程式碼會擲回錯誤。您應該建立 Worker,在其中匯入 wasm-bindgen
產生的程式碼,然後透過 Comlink 之類的程式庫,將其 API 公開至主執行緒。
如需端對端示範,請參閱 wasm-bindgen-rayon 範例:
- 偵測執行緒的功能。
- 為相同的 Rust 應用程式建構單執行緒和多執行緒版本。
- 在 worker 中載入由 wasm-bindgen 產生的 JS+Wasm。
- 使用 wasm-bindgen-rayon 初始化執行緒集區。
- 使用 Comlink 將Worker 的 API 公開給主執行緒。
應用實例
我們積極在 Squoosh.app 中使用 WebAssembly 執行緒,用於用戶端圖片壓縮,特別是 AVIF (C++)、JPEG-XL (C++)、OxiPNG (Rust) 和 WebP v2 (C++) 等格式。單靠多執行緒,我們就看到 1.5 到 3 倍的速度提升 (具體比率因編解碼而異),而將 WebAssembly 執行緒與 WebAssembly SIMD 結合後,這些數字還能再往上推進!
Google 地球是另一項值得注意的服務,其網路版本使用 WebAssembly 執行緒。
FFMPEG.WASM 是熱門 FFmpeg 多媒體工具鍊的 WebAssembly 版本,可利用 WebAssembly 執行緒直接在瀏覽器中高效率地編碼影片。
還有許多使用 WebAssembly 執行緒的令人興奮的範例。請務必查看示範,並將您自己的多執行緒應用程式和程式庫帶入網頁!