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 を 使用できます。たとえば、この例を Rust に変換すると API はシンプルに見えるかもしれませんが、 同じ原則が適用されます呼び出しを行い、結果が返されるのを待ちます。 コストのかかるオペレーションはすべて実行され、最終的には結果が単一の 呼び出し:

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

しかし、これらのサンプルを WebAssembly にコンパイルして変換しようとするとどうなるでしょうか。 どうすればよいでしょうか。具体的には、「file read」とはどのように変換すればよいのでしょうか。この場合、 ストレージからデータを読み取る必要はありません。

ウェブの非同期モデル

ウェブでは、メモリ内ストレージ(JS)など、さまざまなストレージ オプションにマッピングできます。 オブジェクト)、localStorageIndexedDB、サーバーサイド ストレージ、 と、新しい File System Access API について説明します。

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

これは、ウェブ上でコードを実行する際の重要な特性の 1 つです。つまり、 非同期であることが必要です

その理由は、これまでウェブはシングルスレッドで、UI を操作するユーザーコードは UI と同じスレッドで実行する必要があります。データ アナリストは、他の重要なタスク(たとえば、 CPU 時間のレイアウト、レンダリング イベント処理ですJavaScript や SDK などの 「ファイルの読み取り」を開始できるようにする WebAssemblyその他すべてのものをブロックできます。タブ全体、 あるいは過去にはブラウザ全体を対象に ブラウザが閉じられるまで続きます

代わりに、I/O オペレーションを実行するコールバックのスケジュールを設定することしかできません。 クリックします。このようなコールバックは、ブラウザのイベントループの一部として実行されます。参加しない イベントループの仕組みを知りたい方は チェックアウト タスク、マイクロタスク、キュー、スケジュール この動画で詳しく説明しています。

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

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

たとえば、上記のサンプルを最新の JavaScript で書き換えたい場合、 呼び出すには、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 がスケジュールされるとすぐにブラウザを呼び出せます。また、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 では、setTimeout() を呼び出します。 ハンドラを使用してサブスクライブする:

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

これらすべての例と API に共通するのは、いずれの場合も、元の言語の慣用的コードが システム言語では I/O にブロッキング API を使用しますが、ウェブの同等のサンプルでは 非同期 API を使用してください。ウェブにコンパイルするときは、これら 2 つの間で変換を行う必要がある WebAssembly には、現時点ではそのような機能が組み込まれていません。

Asyncify でギャップを埋める

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

コールグラフ
記述する ->WebAssembly ->ウェブ API →非同期タスク呼び出し。非同期タスクの呼び出しでは、
非同期タスクの結果を 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() ですが、コールバックを受け入れる他のコンテキストで使用できます。最後に、 通常の sleep() や他の同期 API と同様に、任意の場所で async_sleep() を呼び出します。

このようなコードをコンパイルする際は、Asyncify 機能を有効にするように Emscripten に指示する必要があります。そのために -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 では、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 の機能は Embind を使用すると、 JavaScript 値と C++ 値間の変換を処理できます。Asyncify もサポートしているため 外部の Promiseawait() を呼び出すと、async-await の await のように動作します。 JavaScript コード:

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 ファイル。生成元のコンパイラに関係なく、変換は別途提供される wasm-opt オプティマイザーの一部として Binaryen ツールチェーンです。次のように呼び出すことができます。

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 or 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 の完了をサブスクライブし、後で解決されても、 コールスタックと状態をシームレスに復元し、何も起こらなかったかのように実行を続行します。

モジュール内のどの関数も非同期呼び出しを行う可能性があるため、すべてのエクスポートが 同様に非同期でラップするためです。上記の例では、3 つの nginx Pod が 実行が実際に行われたかどうかを判断するには、instance.exports.main() の結果を await する必要がある 終了です。

仕組み

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

これは先ほど示した JavaScript の async-await 機能とよく似ていますが、 1 つ目は、言語からの特別な構文やランタイム サポートを必要とせず、 コンパイル時に単純な同期関数を変換することで機能します。

先ほど示した非同期スリープの例をコンパイルする場合:

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% 超まで、
ケース

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

パフォーマンスがさらに上がることのないよう、最終ビルドの最適化は必ず有効にしてください。Google Chat では Asyncify 固有の最適化 オプションを使用して、オーバーヘッドを 変換は、指定された関数のみ、または直接関数呼び出しのみに制限する。また、 ランタイム パフォーマンスはわずかに低下しますが、これは非同期呼び出し自体に限定されます。ただし、 通常は無視できるほどです

実際のデモ

簡単な例を確認したところで、より複雑なシナリオに移ります。

冒頭で述べたように、ウェブ上のストレージ オプションの一つに、 非同期 File System Access API。Kubernetes では、 ファイル システムをウェブ アプリケーションからコピーします。

一方、WASI と呼ばれる事実上の標準もあります。 使用して WebAssembly I/O に接続できます。これは、Python コードをコンパイルする際の 行われ、あらゆる種類のファイル システムや他の操作を従来の コードで公開できます。 あります。

相互にマッピングできるとしたらどうでしょうか。これにより、任意のソース言語で任意のアプリケーションをコンパイルできます。 WASI ターゲットをサポートするツールチェーンが 必要であり 実際のユーザーのファイルでも操作できます。Asyncify を使用すれば、それを実現できます。

このデモでは、Rust の coreutils クレートを WASI にいくつかのマイナー パッチを適用し、Asyncify 変換を介して渡して非同期で実装 WASI からのバインディング File System Access API に送信します。統合後は Xterm.js ターミナル コンポーネントを使用することで、 実際のターミナルのように、実際のユーザーのファイルで操作できます。

https://wasi.rreverser.com/ でライブをご覧いただけます。

Asyncify のユースケースは、タイマーやファイル システムに限定されません。さらに進めて、 ウェブでニッチな API を使用する傾向があります。

たとえば、Asyncify を使用すれば、 libusb - おそらく最も人気のあるネイティブ ライブラリ USB デバイス - WebUSB API。USB デバイスへの非同期アクセスを可能にします。 できます。マッピングとコンパイルが完了すると、選択したアプリケーションに対して実行できる標準の libusb テストとサンプルを取得できる ウェブページのサンドボックス内で

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

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

これまでの例からわかるように、Asyncify がいかにパワフルで、ギャップを埋めて、 ウェブへの公開により、クロス プラットフォーム アクセスやサンドボックス化など、さまざまなメリットを享受できます。 セキュリティを維持できます。