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

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

Ingvar Stepanyan
Ingvar Stepanyan

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.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 中的資料擷取功能仰賴時間攻擊,也就是測量特定程式碼的執行時間。為了讓這類攻擊更難執行,瀏覽器已降低 Date.nowperformance.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 中封鎖輔助執行緒,而不會發生死結。

第三,如果您正在處理程式庫,但仍需要封鎖,可以建立自己的 Worker、匯入 Emscripten 產生的程式碼,並透過 Comlink 將其公開至主執行緒。主執行緒可將任何匯出的做法叫用為非同步函式,這樣一來也能避免阻斷 UI。

在簡單的應用程式中,例如前述範例,-s PROXY_TO_PTHREAD 是最佳選項:

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

C++

所有相同的警告和邏輯都以相同方式套用至 C++。您獲得的唯一新功能是,可以存取 std::threadstd::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-bindgenwasm-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 範例

應用實例

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