C、C++、Rust の WebAssembly スレッドを使用する

他の言語で記述されたマルチスレッド アプリケーションを WebAssembly に移植する方法について学びます。

WebAssembly スレッドのサポートは、WebAssembly に追加されたパフォーマンス向上機能の中で最も重要なものの 1 つです。これにより、コードの一部を個別のコアで並列に実行するか、入力データの独立した部分で同じコードを実行し、ユーザーが使用しているコア数にスケーリングして、全体的な実行時間を大幅に短縮できます。

この記事では、WebAssembly スレッドを使用して、C、C++、Rust などの言語で記述されたマルチスレッド アプリケーションをウェブに提供する方法について説明します。

WebAssembly スレッドは、個別の機能ではなく、WebAssembly アプリがウェブで従来のマルチスレッド パラダイムを使用できるようにする複数のコンポーネントの組み合わせです。

ウェブワーカー

最初のコンポーネントは、JavaScript でよく使用される通常の Worker です。WebAssembly スレッドは、new Worker コンストラクタを使用して、新しい基盤となるスレッドを作成します。各スレッドが JavaScript グルーロードし、メインスレッドが Worker#postMessage メソッドを使用して、コンパイル済みの WebAssembly.Module と共有 WebAssembly.Memory(以下を参照)を他のスレッドと共有します。これにより通信が確立され、すべてのスレッドが JavaScript を再度使用することなく、同じ共有メモリで同じ WebAssembly コードを実行できるようになります。

Web Worker は 10 年以上前から存在し、広くサポートされており、特別なフラグは必要ありません。

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

通常、メインスレッドとウェブワーカー間の通信に使用される 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

有効にすると、SharedArrayBufferSharedArrayBuffer を基盤とする WebAssembly.Memory を含む)、正確なタイマー、メモリ測定、セキュリティ上の理由から分離されたオリジンを必要とするその他の API にアクセスできるようになります。詳しくは、COOP と COEP を使用してウェブサイトを「クロスオリジン分離」するをご覧ください。

WebAssembly アトミック

SharedArrayBuffer では各スレッドが同じメモリに対して読み取りと書き込みを行うことができますが、正しい通信を行うには、競合するオペレーションが同時に実行されないようにする必要があります。たとえば、1 つのスレッドが共有アドレスからデータの読み取りを開始するときに、別のスレッドがそのアドレスに書き込みを開始すると、最初のスレッドは破損した結果を取得する可能性があります。このカテゴリのバグを競合状態といいます。競合状態を防ぐには、これらのアクセスを何らかの方法で同期する必要があります。ここでアトミック オペレーションが役立ちます。

WebAssembly アトミックは、WebAssembly 命令セットの拡張機能であり、小さなデータセル(通常は 32 ビットおよび 64 ビットの整数)を「アトミック」に読み書きできるようにします。つまり、2 つのスレッドが同じセルに対して同時に読み取りまたは書き込みを行うことがないようにし、このような競合を低レベルで防ぐ方法です。さらに、WebAssembly アトミックには、別のスレッドが「通知」を介してスレッドを起動するまで、1 つのスレッドが共有メモリ内の特定のアドレスでスリープ(「待機」)できるようにする「待機」と「通知」の 2 つの命令があります。

チャンネル、ミューテックス、読み取り / 書き込みロックなど、上位レベルの同期プリミティブはすべて、これらの命令に基づいて構築されます。

WebAssembly スレッドの使用方法

特徴検出

WebAssembly アトミックと SharedArrayBuffer は比較的新しい機能であり、WebAssembly をサポートするすべてのブラウザではまだ利用できません。新しい WebAssembly 機能をサポートしているブラウザについては、webassembly.org のロードマップをご覧ください。

すべてのユーザーがアプリを読み込めるようにするには、2 つの異なるバージョンの 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 は、Web ワーカー、共有メモリ、アトミックスの上に構築された pthread ライブラリのAPI 互換の実装を提供しているため、同じコードを変更せずにウェブで動作させることができます。

例を見てみましょう。

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 でスレッドを使用してコードをコンパイルするには、他のプラットフォームで Clang または GCC を使用して同じコードをコンパイルする場合と同様に、emcc を呼び出して -pthread パラメータを渡す必要があります。

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 Worker は非同期です。つまり、pthread_create は次のイベントループの実行時に作成される新しいワーカー スレッドのみをスケジュールしますが、pthread_join はすぐにイベントループをブロックしてそのワーカーを待機し、ワーカーが作成されないようにします。これはデッドロックの典型的な例です。

この問題を解決する方法の 1 つは、プログラムの開始前にワーカーのプールを事前に作成することです。pthread_create が呼び出されると、プールからすぐに使用できるワーカーを取得し、指定されたコールバックをバックグラウンド スレッドで実行して、ワーカーをプールに戻すことができます。これらはすべて同期的に実行できるため、プールが十分に大きい限り、デッドロックは発生しません。

これは、Emscripten の -s PTHREAD_POOL_SIZE=... オプションで許可されているものです。スレッド数を指定できます。固定の数値を指定することも、navigator.hardwareConcurrency などの JavaScript 式を指定して CPU のコア数と同じ数のスレッドを作成することもできます。後者のオプションは、コードを任意の数のスレッドにスケーリングできる場合に便利です。

上記の例では、作成されるスレッドは 1 つだけであるため、すべてのコアを予約する代わりに -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_join ではなく pthread_detach を使用できます。これにより、スレッド コールバックはバックグラウンドで実行されたままになります。このオプションを使用している場合は、-s PTHREAD_POOL_SIZE_STRICT=0 で警告をオフにできます。

PROXY_TO_PTHREAD

2 つ目は、ライブラリではなく C アプリケーションをコンパイルする場合に -s PROXY_TO_PTHREAD オプションを使用できます。このオプションを使用すると、アプリケーション自体によって作成されたネストされたスレッドに加えて、メイン アプリケーション コードが別のスレッドにオフロードされます。これにより、メインコードは UI をフリーズすることなく、いつでも安全にブロックできます。なお、このオプションを使用する場合、スレッドプールを事前に作成する必要もありません。代わりに、Emscripten はメインスレッドを利用して新しい基盤となるワーカーを作成し、デッドロックが発生しないように pthread_join でヘルパー スレッドをブロックできます。

3 つ目は、ライブラリで作業していて、ブロックする必要がある場合です。独自の 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 Worker を認識しておらず、std::thread などの標準 API は WebAssembly にコンパイルすると機能しません。

幸い、エコシステムの大部分は、マルチスレッド処理をハイレベル ライブラリに依存しています。このレベルでは、すべてのプラットフォームの違いを抽象化するのがはるかに簡単です。

特に、Rust のデータ並列処理では Rayon が最もよく使用されます。これにより、通常のイテレータでメソッドチェーンを取り、通常は 1 行の変更で、利用可能なすべてのスレッドで順番ではなく並行して実行されるように変換できます。次に例を示します。

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 関数がエクスポートされます。この関数は、ワーカーのプールを作成し、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 の例で、次のエンドツーエンドのデモを確認してください。

実際のユースケース

Google では、Squoosh.app で WebAssembly スレッドを積極的に使用して、クライアントサイドの画像圧縮を行っています。特に、AVIF(C++)、JPEG-XL(C++)、OxiPNG(Rust)、WebP v2(C++)などの形式で使用しています。マルチスレッド化のみで、1.5 ~ 3 倍の速度向上が得られました(正確な比率はコーデックによって異なります)。さらに、WebAssembly スレッドと WebAssembly SIMD を組み合わせることで、この数値をさらに押し上げました。

Google Earth は、ウェブ バージョンで WebAssembly スレッドを使用している注目のサービスです。

FFMPEG.WASM は、人気のある FFmpeg マルチメディア ツールチェーンの WebAssembly バージョンです。WebAssembly スレッドを使用して、ブラウザ内で直接動画を効率的にエンコードします。

WebAssembly スレッドを使用する例は他にもたくさんあります。デモをぜひお試しください。独自のマルチスレッド アプリケーションとライブラリをウェブに移行しましょう。