使用 C、C++ 和 Rust 的 WebAssembly 執行緒

瞭解如何將以其他語言編寫的多執行緒應用程式導入 WebAssembly。

Ingvar Stepanyan
Ingvar Stepanyan

WebAssembly 執行緒支援是 WebAssembly 最重要的效能提升之一。您可以利用這項功能,在個別的核心上同時執行某些程式碼,或針對輸入資料的獨立部分執行相同的程式碼,根據使用者擁有的核心數量擴充程式碼,並大幅縮短整體執行時間。

在本文中,您將瞭解如何使用 WebAssembly 執行緒,將以 C、C++ 和 Rust 等語言編寫的多執行緒應用程式導入網路。

WebAssembly 執行緒的運作方式

WebAssembly 執行緒並非獨立功能,而是結合多種元件,讓 WebAssembly 應用程式可以在網路上使用傳統的多執行緒模式。

網路工作處理序

第一個元件是您熟悉且熟悉 JavaScript 的一般工作站。WebAssembly 執行緒會使用 new Worker 建構函式建立新的基礎執行緒。每個執行緒都會載入 JavaScript 膠帶,然後主要執行緒會使用 Worker#postMessage 方法,與其他執行緒共用已編譯的 WebAssembly.Module,以及共用的 WebAssembly.Memory (請參閱下方說明)。這樣做可以建立通訊,並允許所有執行緒在同一個共用記憶體上執行相同的 WebAssembly 程式碼,而不必再次存取 JavaScript。

網路工作站已推出超過十年,目前已受到廣泛支援,而且不需要任何特殊標記。

SharedArrayBuffer

WebAssembly 記憶體是以 JavaScript API 中的 WebAssembly.Memory 物件表示。根據預設,WebAssembly.MemoryArrayBuffer 的包裝函式,也就是只能由單一執行緒存取的原始位元組緩衝區。

> 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 { … }

有別於主要執行緒和網路工作站之間的 postMessageSharedArrayBuffer 不需要複製資料,甚至不需要等待事件迴圈傳送和接收訊息。相反地,所有執行緒幾乎都會立即看到任何變更,因此是傳統同步基元的編譯目標更佳。

SharedArrayBuffer的歷史很複雜,此軟體最初在 2017 年中隨附在數個瀏覽器中推出,但由於發現 Spectre 的漏洞,於 2018 年年初停用。具體來說,Spectre 中的資料擷取取決於時間攻擊,也就是測量特定程式碼的執行時間。為了提高這類攻擊的強度,瀏覽器降低了標準時間 API (例如 Date.nowperformance.now) 的精確度。不過,共用記憶體,搭配以獨立執行緒執行的簡易計數器迴圈也是非常可靠的方式,而且在不大幅縮減執行階段效能的情況下,也非常難以緩解。

相反地,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 atomics

雖然 SharedArrayBuffer 可讓每個執行緒讀取及寫入相同的記憶體,但您想要確保通訊不會同時執行衝突,因此需要確保通訊不會同時執行衝突。例如,一個執行緒可能從共用位址開始讀取資料,而另一個執行緒正在寫入資料,因此第一個執行緒現在會產生損毀的結果。這類錯誤稱為競爭狀況。為了防止競爭狀況,您需要透過某種方式同步處理這些存取。這就是原子作業的處境。

WebAssembly atomics 是 WebAssembly 指令集的擴充功能,允許以「不可分割」的方式讀取及寫入資料的小型儲存格 (通常是 32 和 64 位元整數)。也就是說,我們可保證不會有兩個執行緒同時讀取或寫入同一個儲存格,這樣就能在低層級防止這類衝突。此外,WebAssembly Atomics 還包含其他兩種指令類型 (「wait」和「notify」),讓一個執行緒在共用記憶體中的指定位址進入休眠 (「等待」),直到另一個執行緒透過「notify」喚醒它。

所有較高層級的同步處理基本功能 (包括管道、互斥鎖和讀取/寫入鎖定) 都是根據這些操作說明建構。

如何使用 WebAssembly 執行緒

功能偵測

WebAssembly atomics 和 SharedArrayBuffer 是相對新功能,目前並非所有支援 WebAssembly 的瀏覽器都支援這項功能。您可以參閱 webassembly.org 藍圖,瞭解哪些瀏覽器支援新版 WebAssembly 功能。

為確保所有使用者都能載入應用程式,您必須建構兩個不同的 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 Worker、共用記憶體和原子的 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 程式庫的標頭透過 pthread.h 包含。也可以看到處理執行緒的重要函式

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…]

不過隨著 YouTube 發展,問題在於,網路上大多數耗時的 API 都是非同步的,而且仰賴事件迴圈來執行。與傳統環境相比,這項限制是重要的差異;傳統環境通常是以同步、封鎖方式執行 I/O。如要瞭解詳情,請參閱透過 WebAssembly 使用非同步網路 API 的網誌文章。

在此情況下,程式碼會同步叫用 pthread_create 來建立背景執行緒,接著又進行另一項對 pthread_join 的同步呼叫,負責等待背景執行緒完成執行作業。不過,使用 Emscripten 編譯程式碼時,會在背景使用的網路工作站是非同步的。因此,pthread_create 只會「排定」在下一個事件迴圈執行時建立新工作站執行緒,但 pthread_join 會立即封鎖事件迴圈來等待該 worker,這會導致系統無法建立該工作站。這是死結的典型範例。

解決這個問題的一種方法是,在程式尚未啟動前預先建立工作站集區。叫用 pthread_create 時,它可以從集區取用現成的工作站,在背景執行緒上執行提供的回呼,然後將工作站傳回集區。以上所有操作都可同步完成,因此只要集區夠大,就不會有死結。

這正是 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
  • 自訂 worker 和 Comlink

pthread_detach

首先,如果只需要在主執行緒中執行部分工作,但不需要等待結果,您可以使用 pthread_detach,而非 pthread_join。這麼做會讓執行緒回呼在背景執行。如果您使用這個選項,則可使用 -s PTHREAD_POOL_SIZE_STRICT=0 關閉警告。

PROXY_TO_PTHREAD

其次,如果要編譯 C 應用程式 (而非程式庫),可以使用 -s PROXY_TO_PTHREAD 選項,除了應用程式本身建立的任何巢狀執行緒之外,還會將主要應用程式程式碼卸載至獨立的執行緒。如此一來,主要程式碼就可以隨時安全封鎖,而不會凍結 UI。順帶一提,使用這個選項時,您也不需要預先建立執行緒集區,而是能利用主執行緒建立新的基礎工作站,然後在不上鎖的情況下封鎖 pthread_join 中的輔助執行緒。

第三,如果您使用的是程式庫,且仍需封鎖,可以建立自己的工作站、匯入 Emscripten 產生的程式碼,並透過 Comlink 將其公開至主執行緒。主執行緒將能叫用任何匯出的方法做為非同步函式,這樣也能避免封鎖 UI。

在簡易應用程式 (例如先前的範例 -s PROXY_TO_PTHREAD) 中,最佳選擇如下:

emcc -pthread -s PROXY_TO_PTHREAD example.c -o example.js

C++

同樣的注意事項和邏輯與 C++ 相同,唯一差別在於您可以存取更高階的 API,例如 std::threadstd::async,這些 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-bindgenwasm-pack。遺憾的是,這表示標準程式庫無法得知 Web Worker,且標準 API (例如 std::thread) 編譯為 WebAssembly 時將無法運作。

幸好,大部分的生態系統都仰賴高階程式庫來處理多執行緒。在這個層級中,您可以更輕鬆地排除所有平台差異。

尤其是 Rust 中最常採用的資料平行處理選項 Rayon。這可讓您對一般疊代器採用方法鏈結,而且通常只有單行變更,而是以在所有可用執行緒上平行執行的方法鏈轉換,而非依序進行轉換。例如:

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 執行緒。如要使用,您必須將其新增為依附元件,然後按照docs中說明的設定步驟操作。以上範例最終看起來會像這樣:

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()
}

完成後,產生的 JavaScript 會匯出額外的 initThreadPool 函式。這個函式會建立工作站集區,並在計畫的整個生命週期中重複使用 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。

如需完整示範,請參閱 hasm-bindgen-rayon 範例

應用實例

我們積極使用 Squoosh.app 中的 WebAssembly 執行緒進行用戶端圖片壓縮,特別是 AVIF (C++)、JPEG-XL (C++)、OxiPNG (Rust) 和 WebP v2 (C++) 等格式。有了多執行緒程式碼,WebA 的 1.5 倍變速就能達到 1.5x-c 的倍速,甚至能將兩者的

Google 地球是另一項知名服務將 WebAssembly 執行緒用於網頁版

FFMPEG.WASM 是熱門 FFmpeg 多媒體工具鍊的 WebAssembly 版本,可利用 WebAssembly 執行緒,直接在瀏覽器中以有效率的方式對影片進行編碼。

還有許多使用 WebAssembly 執行緒的精彩範例。請務必查看示範,並將您自己的多執行緒應用程式和程式庫上線!