WebAssembly から非同期ウェブ API を使用する

ウェブの I/O API は非同期ですが、ほとんどのシステム言語で同期しています。WebAssembly にコードをコンパイルする場合は、ある種類の API を別の API にブリッジする必要があります。このブリッジが Asyncify です。この投稿では、Asyncify をいつ、どのように使用するか、内部でどのように機能するかについて説明します。

システム言語での I/O

まず、C の簡単な例から説明します。ファイルからユーザー名を読み取り、「Hello, (username)!」というメッセージで挨拶するとします。

#include <stdio.h>

int main() {
    FILE *stream = fopen("name.txt", "r");
    char name[20+1];
    size_t len = fread(&name, 1, 20, stream);
    name[len] = '\0';
    fclose(stream);
    printf("Hello, %s!\n", name);
    return 0;
}

この例はそれほど大きなことではありませんが、あらゆる規模のアプリケーションで目にする可能性のある動作をすでに示しています。つまり、外部から一部の入力を読み取り、それらを内部で処理し、出力を外部に書き戻します。外部の世界とのこのようなやり取りはすべて、入出力関数(I/O も短縮)と呼ばれるいくつかの関数を介して行われます。

C から名前を読み取るには、少なくとも 2 つの重要な I/O 呼び出し(ファイルを開くための fopen と、ファイルからデータを読み取るための fread)が必要です。データを取得したら、別の I/O 関数 printf を使用して、結果をコンソールに出力できます。

これらの関数は一見非常にシンプルに見え、データの読み取りと書き込みに関連する機械についてよく考える必要はありません。ただし、環境によっては、内部でさまざまな処理が行われます。

  • 入力ファイルがローカル ドライブにある場合、アプリケーションは一連のメモリ アクセスとディスク アクセスを実行してファイルを特定し、権限を確認し、読み取り用にファイルを開いてから、リクエストされたバイト数を取得するまでブロックごとに読み取りを行う必要があります。ディスクの速度とリクエストされたサイズによっては、かなり遅くなる可能性があります。
  • または、入力ファイルがマウントされたネットワークの場所にある場合は、ネットワーク スタックも関与するため、各オペレーションの複雑さ、レイテンシ、再試行の回数が増加します。
  • 最後に、printf でも、コンソールに出力される保証はなく、ファイルやネットワークの場所にリダイレクトされる可能性があります。その場合は、上記と同じ手順を実行する必要があります。

端的に言うと、I/O は遅くなる可能性があり、コードを一目では特定の呼び出しにかかる時間を予測できません。このオペレーションの実行中、アプリ全体がフリーズしたように見え、ユーザーに応答しません。

これは、C や C++ に限定されません。ほとんどのシステム言語では、すべての I/O が同期 API の形式で提示されます。たとえば、例を Rust に変換すると、API は単純に見えるかもしれませんが、同じ原則が適用されます。呼び出しを行い、結果が返されるまで同期的に待ちます。同時に、コストの高いオペレーションをすべて実行し、最終的に 1 回の呼び出しで結果を返します。

fn main() {
    let s = std::fs::read_to_string("name.txt");
    println!("Hello, {}!", s);
}

しかし、これらのサンプルを WebAssembly にコンパイルしてウェブに変換しようとすると、どうなるでしょうか。具体的な例として、「ファイル読み取り」オペレーションは何を意味しますか。なんらかのストレージからデータを読み取る必要があります。

ウェブの非同期モデル

ウェブには、インメモリ ストレージ(JS オブジェクト)、localStorageIndexedDB、サーバーサイド ストレージ、新しい File System Access API など、マッピング可能なさまざまなストレージ オプションが用意されています。

ただし、同期的に使用できるのは、これらの API のうちの 2 つ(メモリ内ストレージと localStorage)のみで、どちらも保存できるデータと保存期間の制限が最も厳しいオプションです。他のオプションはすべて非同期 API のみを提供します。

これは、ウェブ上でコードを実行するうえで重要な特性の一つです。つまり、I/O を含む時間のかかる処理はすべて非同期にする必要があります。

その理由は、ウェブは従来からシングルスレッドであり、UI に触れるユーザーコードは UI と同じスレッドで実行されなければならないためです。CPU 時間については、レイアウト、レンダリング、イベント処理などの他の重要なタスクと競合する必要があります。JavaScript や WebAssembly がファイル読み取りオペレーションを開始して、それ以外(タブ全体、または以前はブラウザ全体)をミリ秒から数秒の範囲でブロックできないようにする必要があります。

代わりに、I/O オペレーションのスケジュール設定と、完了後に実行されるコールバックのみが許可されます。このようなコールバックは、ブラウザのイベントループの一部として実行されます。ここでは詳しく説明しませんが、イベントループが内部でどのように機能するかを知りたい場合は、このトピックについて詳しく説明しているタスク、マイクロタスク、キュー、スケジュールをご覧ください。

簡潔に言うと、ブラウザはすべてのコードをキューから 1 つずつ処理して、無限ループのように実行します。イベントがトリガーされると、ブラウザは対応するハンドラをキューに入れ、次のループの反復処理でキューから取り出して実行します。このメカニズムにより、1 つのスレッドのみを使用して同時実行をシミュレートし、多くの並列オペレーションを実行できます。

このメカニズムについて覚えておくべき重要な点は、カスタム JavaScript(または WebAssembly)コードが実行されるとイベントループはブロックされ、しかも外部ハンドラ、イベント、I/O などに反応する方法がないことです。I/O 結果を返す唯一の方法は、コールバックを登録し、コードの実行を終了して、保留中のタスクの処理を続けられるように制御をブラウザに返すことです。I/O が終了すると、ハンドラはこれらのタスクの一つになり、実行されます。

たとえば、最新の JavaScript で上記のサンプルを書き換え、リモート URL から名前を読み取る場合は、Fetch API と async-await 構文を使用します。

async function main() {
  let response = await fetch("name.txt");
  let name = await response.text();
  console.log("Hello, %s!", name);
}

同期的に見えても、内部では各 await は基本的にコールバックの構文シュガーです。

function main() {
  return fetch("name.txt")
    .then(response => response.text())
    .then(name => console.log("Hello, %s!", name));
}

この脱糖の例では、少し明確です。リクエストが開始され、レスポンスは最初のコールバックでサブスクライブされます。ブラウザは、HTTP ヘッダーのみで最初のレスポンスを受信すると、このコールバックを非同期的に呼び出します。コールバックは、response.text() を使用して本文をテキストとして読み取りを開始し、別のコールバックでその結果をサブスクライブします。最後に、fetch がすべてのコンテンツを取得すると、最後のコールバックが呼び出され、コンソールに「Hello, (username)!」が出力されます。

これらのステップの非同期的な性質のおかげで、I/O がスケジュールされるとすぐに元の関数がブラウザに制御を返し、I/O がバックグラウンドで実行されている間、UI 全体をレスポンシブにし、レンダリングやスクロールなどの他のタスクに使用できるようになります。

最後の例として、アプリケーションを指定した秒数待機させる「sleep」のような単純な API でも、I/O オペレーションの形態になります。

#include <stdio.h>
#include <unistd.h>
// ...
printf("A\n");
sleep(1);
printf("B\n");

現在のスレッドをブロックする非常にシンプルな方法で変換できます。

console.log("A");
for (let start = Date.now(); Date.now() - start < 1000;);
console.log("B");

実際、これは Emscripten が「スリープ」のデフォルト実装で行うこととまったく同じですが、これは非常に非効率であり、UI 全体をブロックし、その間は他のイベントを処理できなくなります。通常は、本番環境のコードでは行わないでください。

代わりに、JavaScript での「sleep」の慣用的なバージョンでは、setTimeout() を呼び出し、ハンドラでサブスクライブします。

console.log("A");
setTimeout(() => {
    console.log("B");
}, 1000);

これらすべての例と API に共通するのは、いずれの場合も、元のシステム言語の慣用的なコードは I/O にブロッキング API を使用していますが、ウェブの同等の例では代わりに非同期 API を使用しています。ウェブにコンパイルする際には、この 2 つの実行モデル間でなんらかの方法で変換を行う必要がありますが、WebAssembly には今のところそのような変換機能が組み込まれていません。

Asyncify でギャップを埋める

ここで役立つのが Asyncify です。Asyncify は Emscripten でサポートされているコンパイル時の機能で、プログラム全体を一時停止し、後で非同期で再開できます。

JavaScript -> WebAssembly -> ウェブ API -> 非同期タスクの呼び出しを記述したコールグラフ。Asyncify は非同期タスクの結果を WebAssembly に接続し直します。

Emscripten を使用した C / C++ での使用

前の例で Asyncify を使用して非同期スリープを実装する場合は、次のようにします。

#include <stdio.h>
#include <emscripten.h>

EM_JS(void, async_sleep, (int seconds), {
    Asyncify.handleSleep(wakeUp => {
        setTimeout(wakeUp, seconds * 1000);
    });
});
…
puts("A");
async_sleep(1);
puts("B");

EM_JS は、JavaScript スニペットを C 関数のように定義できるマクロです。その中で関数 Asyncify.handleSleep() を使用します。この関数は、プログラムを一時停止するように Emscripten に指示し、非同期処理が完了したら呼び出す必要がある wakeUp() ハンドラを提供します。上記の例では、ハンドラが setTimeout() に渡されていますが、コールバックを受け入れる他のコンテキストでも使用できます。最後に、async_sleep() は、通常の sleep() や他の同期 API と同様に、任意の場所で呼び出すことができます。

このようなコードをコンパイルする際、Emscripten に Asyncify 機能を有効にするよう指示する必要があります。これを行うには、-s ASYNCIFY-s ASYNCIFY_IMPORTS=[func1, func2] を、非同期の関数の配列のようなリストとともに渡します。

emcc -O2 \
    -s ASYNCIFY \
    -s ASYNCIFY_IMPORTS=[async_sleep] \
    ...

これにより、それらの関数を呼び出す際に状態の保存と復元が必要になる可能性があることを Emscripten に通知できます。そのため、コンパイラはそのような呼び出しに関連するサポートコードを挿入します。

ブラウザでこのコードを実行すると、想定どおりシームレスな出力ログが表示されます。B は A の後に少し遅れて出力されます。

A
B

Asyncify 関数から値を返すこともできます。必要なのは、handleSleep() の結果を返し、結果を wakeUp() コールバックに渡すことです。たとえば、ファイルから読み取る代わりにリモート リソースから番号をフェッチする場合は、以下のようなスニペットを使用してリクエストを送信し、C コードを一時停止し、レスポンス本文を取得したら再開できます。これらはすべて、呼び出しが同期的であるかのようにシームレスに行われます。

EM_JS(int, get_answer, (), {
     return Asyncify.handleSleep(wakeUp => {
        fetch("answer.txt")
            .then(response => response.text())
            .then(text => wakeUp(Number(text)));
    });
});
puts("Getting answer...");
int answer = get_answer();
printf("Answer is %d\n", answer);

実際、fetch() などの Promise ベースの API では、コールバック ベースの API を使用する代わりに、Asyncify を JavaScript の async-await 機能と組み合わせることもできます。そのためには、Asyncify.handleSleep() ではなく Asyncify.handleAsync() を呼び出します。この場合、wakeUp() コールバックをスケジュールする代わりに、async JavaScript 関数を渡し、内部で awaitreturn を使用することで、非同期 I/O のメリットを損なうことなく、コードをさらに自然かつ同期的にすることができます。

EM_JS(int, get_answer, (), {
     return Asyncify.handleAsync(async () => {
        let response = await fetch("answer.txt");
        let text = await response.text();
        return Number(text);
    });
});

int answer = get_answer();

複雑な値を待機

ただし、この例では適用できるのは数字のみです。ファイルからユーザー名を文字列として取得しようとした元の例を実装する場合は、どうすればよいでしょうか。あなたもできます!

Emscripten には、JavaScript 値と C++ 値の間の変換を処理できる Embind と呼ばれる機能があります。これは Asyncify もサポートしているため、外部の Promiseawait() を呼び出すことができ、async-await JavaScript コードの await と同じように動作します。

val fetch = val::global("fetch");
val response = fetch(std::string("answer.txt")).await();
val text = response.call<val>("text").await();
auto answer = text.as<std::string>();

このメソッドを使用する場合は、デフォルトですでに含まれているため、ASYNCIFY_IMPORTS をコンパイル フラグとして渡す必要はありません。

Emscripten ではうまくいきました他のツールチェーンや言語についてはどうですか?

他の言語からの使用

Rust コードのどこかに同様の同期呼び出しがあり、それをウェブ上の非同期 API にマッピングするとします。実は、あなたもできることがわかりました。

まず、extern ブロック(または選択した言語の外部関数用の構文)を使用して、そのような関数を通常のインポートとして定義する必要があります。

extern {
    fn get_answer() -> i32;
}

println!("Getting answer...");
let answer = get_answer();
println!("Answer is {}", answer);

コードを WebAssembly にコンパイルします。

cargo build --target wasm32-unknown-unknown

次に、スタックを保存/復元するためのコードを WebAssembly ファイルをインストルメント化する必要があります。C / C++ の場合、Emscripten がこの処理を行いますが、ここでは使用されないため、手動での作業になります。

幸いなことに、Asyncify 変換自体はツールチェーンに依存しません。生成元のコンパイラに関係なく、任意の WebAssembly ファイルを変換できます。この変換は、Binaryen ツールチェーンから wasm-opt オプティマイザの一部として個別に提供され、次のように呼び出すことができます。

wasm-opt -O2 --asyncify \
      --pass-arg=asyncify-imports@env.get_answer \
      [...]

--asyncify を渡して変換を有効にしてから、--pass-arg=… を使用して非同期関数のカンマ区切りのリストを指定します。ここで、プログラム状態を一時停止してから再開する必要があります。

あとは、実際にこれを行うランタイム コードを提供するだけです。つまり、WebAssembly コードの一時停止と再開を行います。繰り返しになりますが、C / C++ の場合、これは Emscripten によってインクルードされますが、任意の WebAssembly ファイルを処理するカスタムの JavaScript グルーコードが必要になります。そのためのライブラリが 作成されました

これは、GitHub(https://github.com/GoogleChromeLabs/asyncify)または npm(asyncify-wasm)にあります。

標準の WebAssembly インスタンス化 API をシミュレートしますが、独自の名前空間下にあります。唯一の違いは、通常の WebAssembly API では同期関数をインポートとしてのみ提供できるのに対し、Asyncify ラッパーでは非同期インポートも指定できることです。

const { instance } = await Asyncify.instantiateStreaming(fetch('app.wasm'), {
    env: {
        async get_answer() {
            let response = await fetch("answer.txt");
            let text = await response.text();
            return Number(text);
        }
    }
});
…
await instance.exports.main();

このような非同期関数(上記の例の get_answer() など)を WebAssembly 側から呼び出そうとすると、ライブラリは返された Promise を検出し、WebAssembly アプリケーションの状態を一時停止して保存し、Promise の完了にサブスクライブします。その後、解決されるとコールスタックと状態をシームレスに復元し、何も起こらなかったかのように実行を続行します。

モジュール内の関数が非同期呼び出しを行う可能性があるため、すべてのエクスポートが非同期になり、ラップも行われます。上記の例では、instance.exports.main() の結果を await して、実行が完了したことを知る必要があることに気付いたかもしれません。

仕組み

Asyncify は、ASYNCIFY_IMPORTS 関数のいずれかの呼び出しを検出すると、非同期オペレーションを開始し、コールスタックや一時ローカルを含むアプリケーションの状態全体を保存します。その後、そのオペレーションが完了すると、すべてのメモリとコールスタックが復元され、プログラムが停止していない場合と同じ場所から同じ状態で再開します。

これは、前に説明した JavaScript の async-await 機能とよく似ていますが、JavaScript のものとは異なり、言語による特別な構文やランタイム サポートを必要とせず、コンパイル時にプレーンな同期関数を変換することで機能します。

先ほど示した非同期スリープの例をコンパイルすると、次のようになります。

puts("A");
async_sleep(1);
puts("B");

Asyncify はこのコードを受け取り、次のようなコードに変換します(疑似コードの実際の変換はこれよりも複雑です)。

if (mode == NORMAL_EXECUTION) {
    puts("A");
    async_sleep(1);
    saveLocals();
    mode = UNWINDING;
    return;
}
if (mode == REWINDING) {
    restoreLocals();
    mode = NORMAL_EXECUTION;
}
puts("B");

初期設定で、modeNORMAL_EXECUTION に設定されています。それに応じて、そのような変換されたコードが初めて実行されるときは、async_sleep() の前の部分のみが評価されます。非同期オペレーションがスケジュールされるとすぐに、Asyncify はすべてのローカル関数を保存し、各関数から一番上まで戻ってスタックをアンワインドします。これにより、ブラウザのイベントループに制御が戻されます。

次に、async_sleep() が解決されると、Asyncify サポートコードは modeREWINDING に変更し、関数を再度呼び出します。前回はジョブがすでに実行されているため、「通常実行」ブランチはスキップされます。「A」を 2 回出力するのを避けたいので、代わりに「巻き戻し」ブランチに直接行きます。上限に達すると、保存されているすべてのローカルが復元され、モードが「normal」に戻り、コードがそもそも停止していないかのように実行を続行します。

変革費用

残念ながら、Asyncify 変換は、すべてのローカル変数の保存と復元、さまざまなモードでのコールスタックの移動などを行うためのサポートコードを大量に挿入する必要があるため、完全に無料ではありません。コマンドラインで非同期としてマークされている関数と潜在的な呼び出し元のみを変更しようとしますが、圧縮前にコードサイズのオーバーヘッドが約 50% 増える可能性があります。

さまざまなベンチマークのコードサイズ オーバーヘッドを示すグラフ(微調整された条件下ではほぼ 0%、最悪の場合は 100% 以上)

これは理想的ではありませんが、代替手段に機能がまったくない場合や、元のコードを大幅に書き換える必要がある場合には、多くの場合、許容されます。

さらなる最適化を避けるため、最終的なビルドでは最適化を常に有効にしてください。また、Asyncify 固有の最適化オプションをオンにして、変換を指定関数や直接関数呼び出しのみに制限することで、オーバーヘッドを削減することもできます。ランタイム パフォーマンスにはわずかなコストもありますが、非同期呼び出し自体に限定されます。ただし、実際の作業の費用に比べると、通常は無視できる程度です。

実際のデモ

簡単な例を確認できたので、より複雑なシナリオに進みます。

記事の冒頭で説明したように、ウェブ上のストレージ オプションの 1 つに非同期の File System Access API があります。ウェブ アプリケーションから実際のホスト ファイルシステムにアクセスできます。

一方、WebAssembly I/O については、コンソールとサーバー側で WASI という事実上の標準があります。システム言語のコンパイル ターゲットとして設計されており、あらゆるファイル システムやその他のオペレーションを従来の同期形式で公開します。

それらを互いにマッピングできるとしたらどうでしょうか。WASI ターゲットをサポートするツールチェーンを使用して任意のソース言語で任意のアプリケーションをコンパイルし、ウェブ上のサンドボックスで実行しながら、実際のユーザー ファイルで動作させることができます。Asyncify でそれが可能です。

このデモでは、いくつかのマイナーなパッチを付加した Rust coreutils クレートを WASI にコンパイルし、Asyncify 変換を介して渡し、WASI から JavaScript 側の File System Access API への非同期バインディングを実装しています。これを Xterm.js ターミナル コンポーネントと組み合わせると、ブラウザタブで実行され、実際のターミナルのように実際のユーザー ファイルを操作する、現実的なシェルが提供されます。

https://wasi.rreverser.com/ でライブをご覧ください。

非同期化のユースケースは、タイマーやファイル システムだけに限定されません。ウェブではさらにニッチな API を 使用できます

たとえば、Asyncify を使用して、libusb(USB デバイスを扱うための最も一般的なネイティブ ライブラリ)を WebUSB API にマッピングすることもできます。これにより、ウェブ上のそのようなデバイスへの非同期アクセスが可能になります。マッピングしてコンパイルすると、選択したデバイスに対して、ウェブページのサンドボックス内で直接実行できる標準の libusb テストとサンプルを取得できました。

ウェブページに表示された libusb のデバッグ出力のスクリーンショット、接続されたキヤノンカメラに関する情報が表示されている

でも、別のブログ投稿でしかないかもしれません。

これらの例は、あらゆる種類のアプリケーションをウェブに移植し、クロス プラットフォーム アクセス、サンドボックス化、セキュリティの強化を実現しつつ、機能を失うことなく実現するうえで Asyncify がいかにパワフルであるかを示しています。