WebAssembly からの非同期ウェブ API の使用

ウェブ上の I/O API は非同期ですが、ほとんどのシステム言語では同期です。コードを WebAssembly にコンパイルする場合は、ある種類の API を別の API にブリッジする必要があります。このブリッジが Asyncify です。この記事では、Asyncify を使用するタイミングと方法、およびその仕組みについて説明します。

システム言語での I/O

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

#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 で「スリープ」をより慣用的な方法で行うには、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 をシミュレートします。唯一の違いは、通常の 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 回印刷したくないため、「通常の実行」ブランチはスキップされ、代わりに「巻き戻し」ブランチに直接移動します。到達すると、保存されているローカル変数をすべて復元し、モードを「通常」に戻して、コードが最初から停止していなかったかのように実行を続行します。

変換費用

残念ながら、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 を使用して、USB デバイスの操作に最も一般的なネイティブ ライブラリである libusbWebUSB API にマッピングすることもできます。これにより、ウェブ上のこのようなデバイスに非同期でアクセスできます。マッピングとコンパイルが完了すると、選択したデバイスに対してウェブページのサンドボックス内で実行できる標準の libusb テストとサンプルが得られました。

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

ただし、これは別のブログ投稿で説明することになるでしょう。

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