非同期コード

Emscriptenは、**同期**的なCまたはC++コードと**非同期**的なJavaScriptを連携させる2つの方法(AsyncifyとJSPI)をサポートしています。これにより、以下のようなことが可能になります。

  • イベントループに処理を譲渡するCの同期呼び出しにより、ブラウザイベントを処理できます。

  • JSでの非同期操作の完了を待つCの同期呼び出し。

一般的に、これら2つのオプションは非常に似ていますが、動作する基盤となるメカニズムが異なります。

  • Asyncify - Asyncifyは、コンパイルされたコードを自動的に一時停止および再開可能な形式に変換し、同期的に記述したコードであっても非同期的に動作するように一時停止と再開を処理します(そのため、「Asyncify」という名前が付けられています)。これはほとんどの環境で機能しますが、Wasmの出力がはるかに大きくなる可能性があります。

  • JSPI (実験的) - 非同期JavaScriptとの連携に、VMのJavaScript Promise Integration (JSPI) のサポートを使用します。コードサイズは変わりませんが、この機能のサポートはまだ実験段階です。

Asyncifyの詳細については、Asyncifyの紹介ブログ記事で、一般的な背景と内部的な動作の詳細を参照してください(Asyncifyに関するこの講演も参照できます)。以下では、その投稿からのEmscriptenの例について詳しく説明します。

スリープ/イベントループへの処理の譲渡

ブログ投稿からの例から始めましょう。

// example.cpp
#include <emscripten.h>
#include <stdio.h>

// start_timer(): call JS to set an async timer for 500ms
EM_JS(void, start_timer, (), {
  Module.timer = false;
  setTimeout(function() {
    Module.timer = true;
  }, 500);
});

// check_timer(): check if that timer occurred
EM_JS(bool, check_timer, (), {
  return Module.timer;
});

int main() {
  start_timer();
  // Continuously loop while synchronously polling for the timer.
  while (1) {
    if (check_timer()) {
      printf("timer happened!\n");
      return 0;
    }
    printf("sleeping...\n");
    emscripten_sleep(100);
  }
}

これは-sASYNCIFYまたは-sJSPIを使用してコンパイルできます。

emcc -O3 example.cpp -s<ASYNCIFY or JSPI>

注記

Asyncifyを使用する場合は、最適化(ここでは-O3)が非常に重要です。最適化されていないビルドは非常に大きくなります。

そして、次のように実行できます。

nodejs a.out.js

またはJSPIを使用します。

nodejs --experimental-wasm-stack-switching a.out.js

すると、次のような出力が見られるはずです。

sleeping...
sleeping...
sleeping...
sleeping...
sleeping...
timer happened!

コードは単純なループで記述されており、実行中は終了しません。通常、これはブラウザによって非同期イベントが処理されるのを妨げます。Asyncify/JSPIを使用すると、これらのスリープは実際にはブラウザのメインイベントループに処理を譲渡するため、タイマーが機能します!

非同期Web APIを同期的に動作させる

emscripten_sleepおよびその他の標準的な同期APIに加えて、Asyncifyでは独自の関数も追加できます。そのためには、Wasmから呼び出されるJS関数を作成する必要があります(EmscriptenはJSランタイムからWasmの一時停止と再開を制御するためです)。

その1つの方法は、JSライブラリ関数を使用することです。もう1つの方法は、EM_ASYNC_JSを使用することです。次の例ではこれを使用します。

// example.c
#include <emscripten.h>
#include <stdio.h>

EM_ASYNC_JS(int, do_fetch, (), {
  out("waiting for a fetch");
  const response = await fetch("a.html");
  out("got the fetch response");
  // (normally you would do something with the fetch here)
  return 42;
});

int main() {
  puts("before");
  do_fetch();
  puts("after");
}

この例では、非同期操作はfetchであり、Promiseを待機する必要があることを意味します。この操作は非同期ですが、main()内のCコードは完全に同期していることに注意してください!

この例を実行するには、まず次のようにコンパイルします。

emcc example.c -O3 -o a.html -s<ASYNCIFY or JSPI>

これを実行するには、ローカルWebサーバーを実行し、http://localhost:8000/a.htmlにアクセスする必要があります。次のような出力が見られるはずです。

before
waiting for a fetch
got the fetch response
after

これは、Cコードが非同期JSが完了した後にのみ実行を続けたことを示しています。

古いエンジンでのAsyncify APIの使用

ターゲットのJSエンジンが最新のasync/await JS構文をサポートしていない場合、上記のdo_fetchの実装を、EM_JSAsyncify.handleAsyncを使用してPromiseを直接使用するように変換できます。

EM_JS(int, do_fetch, (), {
  return Asyncify.handleAsync(function () {
    out("waiting for a fetch");
    return fetch("a.html").then(function (response) {
      out("got the fetch response");
      // (normally you would do something with the fetch here)
      return 42;
    });
  });
});

この形式を使用する場合、コンパイラはdo_fetchが非同期であることを静的に認識しません。代わりに、ASYNCIFY_IMPORTSを使用して、do_fetch()が非同期操作を実行できることをコンパイラに伝える必要があります。そうしないと、一時停止と再開を許可するようにコードをインストルメントしません(詳細は後述)。

emcc example.c -O3 -o a.html -sASYNCIFY -sASYNCIFY_IMPORTS=do_fetch

最後に、Promiseも使用できない場合は、Asyncify.handleSleepを使用するように例を変換できます。これにより、wakeUpコールバックが関数の実装に渡されます。wakeUpコールバックが呼び出されると、C/C++コードが再開されます。

EM_JS(int, do_fetch, (), {
  return Asyncify.handleSleep((wakeUp) => {
    out("waiting for a fetch");
    fetch("a.html").then(function (response) {
      out("got the fetch response");
      // (normally you would do something with the fetch here)
      wakeUp(42);
    });
  });
});

この形式を使用する場合、関数自体から値を返すことはできません。代わりに、wakeUpコールバックに引数として渡す必要があり、do_fetch自体でAsyncify.handleSleepの結果を返すことで伝播する必要があります。

ASYNCIFY_IMPORTSの詳細

上記の例のように、非同期操作を実行するが、Cの観点からは同期的に見えるJS関数を追加できます。EM_ASYNC_JSを使用しない場合、そのようなメソッドをASYNCIFY_IMPORTSに追加することが不可欠です。このインポートのリストは、Asyncifyインストルメンテーションが認識する必要があるWasmモジュールへのインポートのリストです。このリストを与えることで、他のすべてのJS呼び出しは**非同期操作を実行しない**ことをコンパイラに伝え、不要なオーバーヘッドを追加しないようにします。

注記

インポートがenv内にない場合は、完全なパスを指定する必要があります。たとえば、ASYNCIFY_IMPORTS=wasi_snapshot_preview1.fd_write

動的リンクを使用したAsyncify

動的ライブラリでAsyncifyを使用する場合は、他のリンクされたモジュールからインポートされたメソッド(非同期操作でスタック上にあるメソッド)をASYNCIFY_IMPORTSにリストする必要があります。

// sleep.cpp
#include <emscripten.h>

extern "C" void sleep_for_seconds() {
  emscripten_sleep(100);
}

サイドモジュールでは、通常のEmscripten動的リンクの方法でsleep.cppをコンパイルできます。

emcc sleep.cpp -O3 -o libsleep.wasm -sASYNCIFY -sSIDE_MODULE
// main.cpp
#include <emscripten.h>

extern "C" void sleep_for_seconds();

int main() {
  sleep_for_seconds();
  return 0;
}

メインモジュールでは、コンパイラはsleep_for_secondsが非同期であることを静的に認識しません。したがって、sleep_for_secondsASYNCIFY_IMPORTSリストに追加する必要があります。

emcc main.cpp libsleep.wasm -O3 -sASYNCIFY -sASYNCIFY_IMPORTS=sleep_for_seconds -sMAIN_MODULE

Embindを使用した使用方法

JavaScriptとの連携にEmbindを使用しており、動的に取得したPromiseawaitしたい場合は、valインスタンスでawait()メソッドを直接呼び出すことができます。

val my_object = /* ... */;
val result = my_object.call<val>("someAsyncMethod").await();

この場合、ASYNCIFY_IMPORTSJSPI_IMPORTSを気にする必要はありません。これはval::awaitの内部実装の詳細であり、Emscriptenが自動的に処理するためです。

Embindエクスポートを使用する場合、AsyncifyとJSPIの動作は異なります。AsyncifyをEmbindで使用し、コードがJavaScriptから呼び出された場合、エクスポートがサスペンド関数を呼び出すと関数はPromiseを返し、そうでない場合は結果が同期的に返されます。しかし、JSPIでは、emscripten::async()パラメータを使用して関数を非同期としてマークする必要があり、エクスポートはエクスポートがサスペンドされたかどうかに関係なく常にPromiseを返します。

#include <emscripten/bind.h>
#include <emscripten.h>

static int delayAndReturn(bool sleep) {
  if (sleep) {
    emscripten_sleep(0);
  }
  return 42;
}

EMSCRIPTEN_BINDINGS(example) {
  // Asyncify
  emscripten::function("delayAndReturn", &delayAndReturn);
  // JSPI
  emscripten::function("delayAndReturn", &delayAndReturn, emscripten::async());
}

以下のコマンドでビルドします。

emcc -O3 example.cpp -lembind -s<ASYNCIFY or JSPI>

次に、JavaScriptから呼び出します(Asyncifyを使用)

let syncResult = Module.delayAndReturn(false);
console.log(syncResult); // 42
console.log(await syncResult); // also 42 because `await` is no-op

let asyncResult = Module.delayAndReturn(true);
console.log(asyncResult); // Promise { <pending> }
console.log(await asyncResult); // 42

常にPromiseを返すJavaScriptのasync関数とは対照的に、戻り値は実行時に決定され、PromiseはAsyncifyの呼び出し(emscripten_sleep()val::await()など)が発生した場合にのみ返されます。

コードパスが不定の場合は、呼び出し元は戻り値がinstanceof Promiseであるかどうかを確認するか、単に返された値をawaitできます。

JSPIを使用する場合、下記のように戻り値は常にPromiseになります。

let syncResult = Module.delayAndReturn(false);
console.log(syncResult); // Promise { <pending> }
console.log(await syncResult); // 42

let asyncResult = Module.delayAndReturn(true);
console.log(asyncResult); // Promise { <pending> }
console.log(await asyncResult); // 42

ccallを使用した使用方法

JavascriptからAsyncifyを使用するWasmエクスポートを使用するには、Module.ccall関数を使用し、その呼び出しオプションオブジェクトにasync: trueを渡します。ccallはPromiseを返し、計算が完了すると関数の結果で解決されます。

この例では、数値を返す関数「func」が呼び出されます。

Module.ccall("func", "number", [], [], {async: true}).then(result => {
  console.log("js_func: " + result);
});

AsyncifyとJSPIの違い

異なる基礎となるメカニズムを使用する以外にも、AsyncifyとJSPIは非同期インポートとエクスポートを異なる方法で処理します。Asyncifyは、非同期インポート(ASYNCIFY_IMPORTS)を呼び出す可能性のあるエクスポートを自動的に決定します。しかし、JSPIでは、JSPI_IMPORTSJSPI_EXPORTS設定を使用して、非同期インポートとエクスポートを明示的に設定する必要があります。

注記

<JSPI/ASYNCIFY>_IMPORTSJSPI_EXPORTSは、EM_ASYNC_JS、Embindの非同期サポート、ccallなど、上記で述べたさまざまなヘルパーを使用する場合は必要ありません。

Asyncifyの最適化

注記

このセクションはJSPIには適用されません。

前述のように、Asyncifyを使用した最適化されていないビルドは、サイズが大きく、速度が遅くなる可能性があります。最適化(例えば-O3)を使用してビルドすることで、良好な結果を得ることができます。

Asyncifyは、コードをアンワインドおよびリワインドできるようにするため、コードサイズと速度の両方のオーバーヘッドを追加します。そのオーバーヘッドは通常極端ではなく、約50%程度です。Asyncifyは、インストルメント化する必要がある関数と、そうでない関数(基本的に、ASYNCIFY_IMPORTSのいずれかに到達するものを呼び出す可能性のある関数)を見つけるための全プログラム解析を実行することで、それを実現します。その解析により、多くの不要なオーバーヘッドを回避できますが、**間接呼び出し**によって制限されます。これは、間接呼び出しの行き先がわからないためです(関数テーブル内の任意の場所にある可能性があります(同じ型を持つ))。

アンワインド時に間接呼び出しがスタック上にないことがわかっている場合は、ASYNCIFY_IGNORE_INDIRECTを使用してAsyncifyに間接呼び出しを無視するように指示できます。

一部の間接呼び出しが重要で、その他は重要ではないことがわかっている場合は、Asyncifyに手動で関数のリストを提供できます。

  • ASYNCIFY_REMOVEは、スタックをアンワインドしない関数のリストです。Asyncifyがコールツリーを処理すると、このリスト内の関数は削除され、それらとその呼び出し元は(呼び出し元が他の理由でインストルメント化する必要がある場合を除き)インストルメントされません。

  • ASYNCIFY_ADDは、スタックをアンワインドする関数のリストであり、インポートのように処理されます。これは、ASYNCIFY_IGNORE_INDIRECTを使用しているが、アンワインドする必要がある追加の関数をマークしたい場合に主に役立ちます。ASYNCIFY_PROPAGATE_ADD設定が無効になっている場合、このリストは全プログラム解析の後で追加されるだけです。ASYNCIFY_PROPAGATE_ADDが無効になっている場合は、その呼び出し元、その呼び出し元の呼び出し元などを追加する必要があります。

  • ASYNCIFY_ONLYは、スタックをアンワインドできる**唯一の**関数のリストです。Asyncifyは、それらの関数のみを正確にインストルメントします。

ASYNCIFY_ADVISE設定を有効にすると、コンパイラは現在インストルメントしている関数とその理由を出力します。次に、ASYNCIFY_REMOVEに関数を追加する必要があるかどうか、またはASYNCIFY_IGNORE_INDIRECTを有効にしても安全かどうかを判断できます。コンパイラのこのフェーズは多くの最適化フェーズの後に行われ、いくつかの関数はすでにインライン化されている可能性があることに注意してください。安全のために、-O0で実行してください。

詳細については、settings.jsを参照してください。ここで説明する手動設定はエラーが発生しやすいことに注意してください。設定が正しくない場合、アプリケーションが破損する可能性があります。最大限のパフォーマンスが絶対に必要なわけではない場合は、通常、デフォルトを使用しても問題ありません。

潜在的な問題

スタックオーバーフロー(Asyncify)

asyncify_* APIから例外がスローされた場合、スタックオーバーフローが発生している可能性があります。ASYNCIFY_STACK_SIZEオプションを使用してスタックサイズを増やすことができます。

リエントランシー

非同期操作の待機中に、ブラウザイベントが発生する可能性があります。それは多くの場合、Asyncifyを使用する目的ですが、予期しないイベントが発生する可能性もあります。たとえば、100ミリ秒間一時停止するだけの場合、emscripten_sleep(100)を呼び出すことができますが、キー入力などのイベントリスナーがある場合、キーが押されるとハンドラーが起動します。そのハンドラーがコンパイル済みコードを呼び出す場合、コルーチンまたはマルチスレッドのように見えるため、複数の実行がインターリーブされているように見えるため、混乱する可能性があります。

別の非同期操作が実行されている間に非同期操作を開始することは**安全ではありません**。最初の操作が完了するまで、2番目の操作を開始することはできません。

このようなインターリーブは、コードベースの想定を破る可能性もあります。たとえば、関数がグローバル変数を使用し、関数が戻るまで他の何もそれを変更できないと想定しているが、その関数がスリープし、イベントによって他のコードがそのグローバル変数を変更すると、予期しない問題が発生する可能性があります。

スタック上にコンパイル済みコードがある状態でリワインドを開始する(Asyncify)

上記の例では、wakeUp()がJSから(通常はコールバックの後で)呼び出され、スタック上にコンパイル済みコードがないことを示しています。スタック上にコンパイル済みコードがあれば、適切なリワインドと実行の再開を混乱させる方法で妨げる可能性があるため、ASSERTIONSのあるビルドではアサーションがスローされます。

(具体的には、リワインドは適切に機能しますが、後で再びアンワインドする場合、そのアンワインドはスタックにあった追加のコンパイル済みコードもアンワインドします。これにより、後のリワインドの動作が悪くなります。)

役立つ簡単な回避策は、wakeUp()setTimeout(wakeUp, 0);に置き換えて、0のsetTimeoutを実行することです。これにより、スタック上に何もない後続のコールバックでwakeUpが実行されます。

古いAsyncify APIからの移行

古いEmterpreter-Async APIまたは古いAsyncifyを使用するコードがある場合、-sEMTERPRETIFYの使用を-sASYNCIFYに置き換えると、ほとんどすべてが機能するはずです。特にemscripten_wgetのようなものは、以前と同じように動作します。

いくつかの小さな違いは次のとおりです。

  • Emterpreterには「yielding」という概念がありましたが、Asyncifyでは必要ありません。emscripten_sleep_with_yield()呼び出しをemscripten_sleep()に置き換えることができます。

  • 内部JS APIは異なります。Asyncify.handleSleep()に関する上記のメモを参照し、src/library_async.jsでさらに例を参照してください。