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

他の言語で記述されたマルチスレッド アプリケーションを WebAssembly に取り込む方法について説明します。

WebAssembly スレッドのサポートは、WebAssembly のパフォーマンスを向上させるうえで最も重要な要素の一つです。コードの一部を別々のコアで並列に実行することも、同じコードを入力データの独立した部分で並列に実行することもできます。この場合、入力データのコア数に制限はないので、全体の実行時間を大幅に短縮できます。

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

WebAssembly スレッドの仕組み

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

ウェブワーカー

1 つ目のコンポーネントは、馴染みのある JavaScript でよく使われる通常の Worker です。WebAssembly スレッドは、new Worker コンストラクタを使用して、基盤となる新しいスレッドを作成します。各スレッドは JavaScript の接着剤を読み込み、メインスレッドは Worker#postMessage メソッドを使用してコンパイル済みの WebAssembly.Module と共有 WebAssembly.Memory(以下を参照)を他のスレッドと共有します。これにより通信が確立され、JavaScript を経由することなく、すべてのスレッドが同じ共有メモリ上で同じ WebAssembly コードを実行できるようになります。

ウェブワーカーは 10 年以上前から存在しており、広くサポートされているため、特別なフラグは必要ありません。

SharedArrayBuffer

WebAssembly のメモリは、JavaScript API で WebAssembly.Memory オブジェクトで表されます。デフォルトでは、WebAssembly.MemoryArrayBuffer(単一スレッドからのみアクセスできる RAW バイトバッファ)のラッパーです。

> new WebAssembly.Memory({ initial:1, maximum:10 }).buffer
ArrayBuffer { … }

マルチスレッドをサポートするために、WebAssembly.Memory にも共有バリアントが追加されました。JavaScript API を介して shared フラグを指定して、または WebAssembly バイナリ自体によって作成された場合は、代わりに 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 を使用すると、各スレッドが同じメモリに対して読み取りと書き込みを行うことができますが、正しい通信を行うには、競合するオペレーションが同時に実行されないようにする必要があります。たとえば、あるスレッドが共有アドレスからのデータの読み取りを開始し、別のスレッドがそのデータへの書き込みを開始する可能性があるため、最初のスレッドでは破損した結果が返されます。このカテゴリのバグは競合状態と呼ばれます。競合状態を防ぐには、なんらかの方法でこれらのアクセスを同期する必要があります。ここでアトミック操作の出番となります。

WebAssembly アトミックは WebAssembly 命令セットの拡張であり、データのスモールセル(通常は 32 ビットと 64 ビットの整数)を「アトミック」に読み書きできます。つまり、2 つのスレッドが同じセルに対して同時に読み書きを行わないことを保証し、低レベルでの競合を防止します。また、WebAssembly アトミックには、さらに 2 種類の命令「wait」と「notify」が含まれています。これらの命令により、1 つのスレッドが共有メモリ内の特定のアドレスで「notify」によってスリープ状態から復帰するまで、スレッドがスリープ(「wait」)できます。

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

WebAssembly スレッドの使用方法

機能検出

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

すべてのユーザーがアプリケーションを読み込めるようにするには、Wasm の 2 つの異なるバージョン(1 つはマルチスレッドをサポートするバージョン、もう 1 つはサポートしないバージョン)をビルドして、プログレッシブ エンハンスメントを実装する必要があります。次に、機能検出結果に応じて、サポートされているバージョンを読み込みます。実行時に 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 は、ウェブ ワーカー、共有メモリ、アトミック上に構築された 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.h を介して pthread ライブラリのヘッダーが含まれています。また スレッドの処理に重要な 関数もいくつかあります

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 でコンパイルされるときにバックグラウンドで使用されるウェブ ワーカーは非同期です。したがって、pthread_create は、次回のイベントループ実行時に新しい Worker スレッドが作成されるようにのみスケジューリングしますが、pthread_join は直ちにイベントループをブロックしてその Worker を待機し、それによりスレッドが作成されないようにします。これはデッドロックの古典的な例です。

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

これは、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.

ただし、もう 1 つ問題があります。コードサンプルの 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

次に、ライブラリではなく C アプリケーションをコンパイルする場合は、-s PROXY_TO_PTHREAD オプションを使用できます。これにより、アプリケーション自体によって作成されたネストされたスレッドに加えて、メイン アプリケーション コードを別のスレッドにオフロードします。これにより、メインコードは UI をフリーズすることなくいつでも安全にブロックできます。 なお、このオプションを使用する場合、スレッドプールを事前に作成する必要はありません。代わりに、Emscripten はメインスレッドを利用して基盤となる新しい Worker を作成し、デッドロックなしで 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 などのツールに委ねられます。つまり、標準ライブラリはウェブ ワーカーを認識せず、std::thread などの標準 API は WebAssembly にコンパイルすると機能しません。

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

特に、Rayon は Rust でのデータ並列処理の最も一般的な選択肢です。これにより、通常のイテレータでメソッド チェーンを取得し、通常は 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 として生成します。使用するには、これを依存関係として追加し、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 関数がエクスポートされます。この関数は 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 で生成されたコードをインポートし、その API を Comlink などのライブラリでメインスレッドに公開する必要があります。

Wasm-bindgen-rayon の例のエンドツーエンドのデモをご覧ください。

実際のユースケース

Squoosh.app で WebAssembly スレッドを積極的に使用し、クライアント側の画像圧縮、特に AVIF(C++)、JPEG-XL(C++)、OxiPNG(Rust)、WebP v2(C++)などの形式の WebAssembly スレッドを積極的に使用しています。マルチスレッド処理のみのおかげで、SIMAssembly と Squoosh の正確な比率(コードの比率)も 1.5 倍から 3 倍まで向上しました。

Google Earth も、ウェブ版に WebAssembly スレッドを使用している注目すべきサービスです。

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

WebAssembly スレッドを使用した興味深い例は、他にもたくさんあります。デモを確認し、独自のマルチスレッド アプリケーションやライブラリをウェブでご利用ください。