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 のうち、インメモリ ストレージと localStorage の 2 つのみを同期的に使用できます。また、どちらも保存できるものと保存期間の点で最も制限の厳しいオプションです。他のオプションはすべて、非同期 API のみを提供します。

これは、ウェブ上でコードを実行する際の重要な特性の 1 つです。I/O を含む時間のかかるオペレーションはすべて非同期でなければなりません。

これは、ウェブが歴史的にシングル スレッドであり、UI に触れるユーザーコードは UI と同じスレッドで実行する必要があるためです。レイアウト、レンダリング、イベント処理などの他の重要なタスクと CPU 時間を競合させる必要があります。JavaScript や WebAssembly の一部が「ファイル読み取り」オペレーションを開始し、それが終了するまで、他のすべて(タブ全体、以前はブラウザ全体)を数ミリ秒から数秒間ブロックすることは望ましくありません。

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

簡単に言うと、ブラウザはキューからコードを 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 は「sleep」のデフォルト実装でまさにそれを行っていますが、これは非常に非効率的で、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 関数のように定義できるマクロです。内部では、Emscripten にプログラムを一時停止するよう指示し、非同期オペレーションが完了したら呼び出す wakeUp() ハンドラを提供する Asyncify.handleSleep() 関数を使用します。上記の例では、ハンドラは setTimeout() に渡されますが、コールバックを受け入れる他のコンテキストで使用することもできます。最後に、通常の sleep() や他の同期 API と同様に、任意の場所で async_sleep() を呼び出すことができます。

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

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

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

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

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 をシミュレートしますが、独自の Namespace で行います。唯一の違いは、通常の 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();

WebAssembly 側からこのような非同期関数(上記の例の get_answer() など)を呼び出そうとすると、ライブラリは返された 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 回出力したくないためです。代わりに、「巻き戻し」ブランチに直接移動します。この場所に到達すると、保存されたローカル変数がすべて復元され、モードが「通常」に戻り、コードが最初から停止していなかったかのように実行が続行されます。

変換費用

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

さまざまなベンチマークのコードサイズ オーバーヘッドを示すグラフ。ファインチューニングされた条件では 0% に近い値から、最悪のケースでは 100% を超える値まであります。

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

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

実際のデモ

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

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

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

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

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

https://wasi.rreverser.com/ でライブで確認できます。

Asyncify のユースケースは、タイマーやファイル システムに限定されません。さらに、ウェブ上でよりニッチな API を使用することもできます。

たとえば、Asyncify を使用して、USB デバイスを操作するための最も一般的なネイティブ ライブラリである libusbWebUSB API にマッピングすることもできます。これにより、ウェブ上でこれらのデバイスに非同期でアクセスできます。マッピングとコンパイルが完了すると、選択したデバイスに対して実行する標準の libusb テストとサンプルが、ウェブページのサンドボックスに表示されました。

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

これは別のブログ投稿で取り上げることにします。

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