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

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

たとえば、上のサンプルを最新の 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() を呼び出すと、JavaScript コードの async-await の 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 を使用すると、まさにそれが可能です。

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

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

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

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

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

それはまた別のブログ投稿で取り上げられる話かもしれません。

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