使用 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。

Web Workers 已經推出十多年,且廣受支援,不需要任何特殊標記。

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

與通常用於主要執行緒和 Web Workers 之間通訊的 postMessage 不同,SharedArrayBuffer 不需要複製資料,甚至不需要等待事件迴圈傳送及接收訊息。相反地,所有執行緒幾乎都能立即看到任何變更,因此對於傳統同步化基本元素來說,這會是更理想的編譯目標。

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 原子

雖然 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 建立。這是死結的經典例子。

解決這個問題的一種方法是在程式開始之前,提早建立工作站集區。呼叫 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
  • 自訂 Worker 和 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 中封鎖輔助執行緒,而不會發生死結。

第三,如果您的程式庫需要封鎖,您可以建立自己的工作站,匯入 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。遺憾的是,這表示標準程式庫無法得知網路工作站,而 std::thread 等標準 API 在編譯至 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()
}

完成後,產生的 JavaScript 會匯出額外的 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 範例

應用實例

我們積極在 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 執行緒的令人興奮的範例。請務必查看示範,並將自己的多執行緒應用程式和程式庫發布到網路上!