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を使用すると、これらのスリープは実際にはブラウザのメインイベントループに処理を譲渡するため、タイマーが機能します!
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が完了した後にのみ実行を続けたことを示しています。
ターゲットのJSエンジンが最新のasync/await
JS構文をサポートしていない場合、上記のdo_fetch
の実装を、EM_JS
とAsyncify.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_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_seconds
をASYNCIFY_IMPORTS
リストに追加する必要があります。
emcc main.cpp libsleep.wasm -O3 -sASYNCIFY -sASYNCIFY_IMPORTS=sleep_for_seconds -sMAIN_MODULE
JavaScriptとの連携にEmbindを使用しており、動的に取得したPromise
をawait
したい場合は、val
インスタンスでawait()
メソッドを直接呼び出すことができます。
val my_object = /* ... */;
val result = my_object.call<val>("someAsyncMethod").await();
この場合、ASYNCIFY_IMPORTS
やJSPI_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は、非同期インポート(ASYNCIFY_IMPORTS
)を呼び出す可能性のあるエクスポートを自動的に決定します。しかし、JSPIでは、JSPI_IMPORTS
とJSPI_EXPORTS
設定を使用して、非同期インポートとエクスポートを明示的に設定する必要があります。
注記
<JSPI/ASYNCIFY>_IMPORTS
とJSPI_EXPORTS
は、EM_ASYNC_JS
、Embindの非同期サポート、ccall
など、上記で述べたさまざまなヘルパーを使用する場合は必要ありません。
注記
このセクションは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_*
APIから例外がスローされた場合、スタックオーバーフローが発生している可能性があります。ASYNCIFY_STACK_SIZE
オプションを使用してスタックサイズを増やすことができます。
非同期操作の待機中に、ブラウザイベントが発生する可能性があります。それは多くの場合、Asyncifyを使用する目的ですが、予期しないイベントが発生する可能性もあります。たとえば、100ミリ秒間一時停止するだけの場合、emscripten_sleep(100)
を呼び出すことができますが、キー入力などのイベントリスナーがある場合、キーが押されるとハンドラーが起動します。そのハンドラーがコンパイル済みコードを呼び出す場合、コルーチンまたはマルチスレッドのように見えるため、複数の実行がインターリーブされているように見えるため、混乱する可能性があります。
別の非同期操作が実行されている間に非同期操作を開始することは**安全ではありません**。最初の操作が完了するまで、2番目の操作を開始することはできません。
このようなインターリーブは、コードベースの想定を破る可能性もあります。たとえば、関数がグローバル変数を使用し、関数が戻るまで他の何もそれを変更できないと想定しているが、その関数がスリープし、イベントによって他のコードがそのグローバル変数を変更すると、予期しない問題が発生する可能性があります。
上記の例では、wakeUp()がJSから(通常はコールバックの後で)呼び出され、スタック上にコンパイル済みコードがないことを示しています。スタック上にコンパイル済みコードがあれば、適切なリワインドと実行の再開を混乱させる方法で妨げる可能性があるため、ASSERTIONS
のあるビルドではアサーションがスローされます。
(具体的には、リワインドは適切に機能しますが、後で再びアンワインドする場合、そのアンワインドはスタックにあった追加のコンパイル済みコードもアンワインドします。これにより、後のリワインドの動作が悪くなります。)
役立つ簡単な回避策は、wakeUp()
をsetTimeout(wakeUp, 0);
に置き換えて、0のsetTimeout
を実行することです。これにより、スタック上に何もない後続のコールバックでwakeUp
が実行されます。
古い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
でさらに例を参照してください。