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

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

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

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

WebAssembly スレッドの仕組み

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

この問題を解決する方法の 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.

ただし、別の問題があります。コード例の 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 はメインスレッドを活用して新しい基盤となる 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 などの外部ライブラリとツールに任せます。残念ながら、標準ライブラリは 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 スレッドを使用する興味深いサンプルは他にも多数あります。デモをぜひお試しください。独自のマルチスレッド アプリケーションとライブラリをウェブに移行しましょう。