TL;DR;
- Aysncify を使うことで、同期的な C/C++ 関数を、非同期なものにできます
-
-s ASYNCIFY=1
をコンパイラオプションに指定した上で、-s 'ASYNCIFY_IMPORTS=["関数1", "関数2", …]'
のように非同期化する関数名を列挙してコンパイルします - 出力される JS ファイルのサイズと、WASM ファイルサイズが増えます
Aysncify とは
Asyncifyとは、Emscripten の提供する機能の 1 つです。これを使うと次のことが可能になります。まさにマジック。
- 同期的なC/C++のコードを、非同期化します
- 非同期なJS APIの結果を、C/C++ 側では(擬似的な)同期的に受け取ることができます
何ができるのかを、試した結果をまとめました。まずは無限ループがあるコードを例にみてゆきましょう。
無限ループのあるコードが、メインスレッドで動く
次のようなプログラムがあるとします。タイマーの状態を確認して、その値が true
になったら無限ループから抜けて終了する、というものです。
#include <stdio.h>
#include <unistd.h>
extern "C" void start_timer();
extern "C" bool check_timer();
int main(int argc, char** argv){
start_timer(); // タイマーの起動
while(true){
if(check_timer()){ // タイマーの値を確認
printtf("Timer happened.\n");
break;
}
printf("Sleeping\n");
usleep(100000); // 100ms スリープする
}
return 0;
}
典型的なビジーループのコードで、C/C++ では珍しくないと思います。ただ JavaScript 開発者にとっては驚きだと思います。同様のコードを JavaScript でも記述できますがスレッドを占有してしまうので、ブラウザの UI もブロックしてしまいます。そのため、次のように setInterval
や requestAnimationFrame
を使って、メッセージループから起動されるように記述するのが常でした。
import {start_timer, check_timer} from "some-module";
function main(){
function update(){
if(check_timer()){
console.log("Timer happened");
}else{
console.log("Sleeping");
requestAnimationFrame(update);
}
}
start_timer();
update();
}
Ayncifyは上記のような C/C++ のコードを、大きな書き換えなしに非同期化します。
Asyncify を使った非同期化
先ほど例に使ったコードを Asyncify を使って非同期化していきます。
#include <stdio.h>
#include <unistd.h>
extern "C" void start_timer();
extern "C" bool check_timer();
int main(int argc, char** argv){
start_timer(); // タイマーの起動
while(true){
if(check_timer()){ // タイマーの値を確認
printtf("Timer happened.\n");
break;
}
printf("Sleeping\n");
usleep(100000); // 100ms スリープする
}
return 0;
}
まずはコードを次のように書き換えます。次の 3 点が差分です:
-
unistd.h
の代わりにemscripten.h
をインクルード -
EM_JS
マクロを使ってstart_timer
とcheck_timer
の実装を定義 -
usleep
をemscripten_sleep
で置き換え
EM_JS
については、こちらのエントリーを参照してください。また Module
オブジェクトについては、公式ドキュメントに説明があります。
#include <stdio.h>
#include <emscripten.h>
EM_JS(void, start_timer, (), {
Module.timer = false;
setTimeout(function(){
Module.timer = true;
}, 500);
})
EM_JS(bool, check_timer, (), {
return Module.timer;
})
int main(int argc, char** argv){
start_timer(); // タイマーの起動
while(true){
if(check_timer()){ // タイマーの値を確認
printtf("Timer happened.\n");
break;
}
printf("Sleeping\n");
emscripten_sleep(100); // 100ms スリープする
}
return 0;
}
このコードを次のようにコンパイルします。ポイントは -s ASYNCIFY=1
をオプションに加えることです。
% emcc -s ASYNCIFY=1 -o timer.js timer.c
出力された JS ファイルを Node で実行すると、次のようになります:
% node timer.js
Sleeping
Sleeping
Sleeping
Sleeping
Timer happened.
もちろんブラウザーでも動きます。次のような HTML ファイルを用意して、テスト用の Web サーバーに配置して、アクセスすると、コンソールに上記のように出力されると思います。
この時注目したいのは、ブラウザの UI が固まっていない点です。例えば新しいタブを開こうと思えば、ひらけます。これはメインスレッドが JS / WASM の実行で占有されていないことを示しています。
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Timer with Asyncify</title>
<script defer src="timer.js"></script>
</head>
<body></body>
</html>
emscripten_sleep
の自作
先ほどのコードがメインスレッドを占有しないのは、emscripten_sleep
が Asyncify に対応していて、sleep 中にメインスレッドを明け渡していました。関数呼び出し時にメインスレッドを明け渡すことと、関数の処理が終了した際にCのコードの実行部分に戻ることが Asyncify の特徴のようです。
これを利用して、自分で emscripten_sleep
を作ってみます。まず準備として、先ほどのコードで実装した start_timer
と check_timer
を JS ファイルに切り出します。
function start_timer(){
Module.timer = false;
setTimeout(function(){
Module.timer = true;
}, 500);
}
function check_timer(){
return Module.timer;
}
切り出した JS ファイルは、lib.js
としておきます。この lib.js
をリンクするように、元のコードも変更します。
一緒に自作版 emscripten_sleep
のシグネチャも追加し、それを呼び出すように変更します。
#include <stdio.h>
extern "C" void start_timer();
extern "C" bool check_timer();
extern "C" void mysleep(int);
int main(int argc, char** argv){
start_timer(); // タイマーの起動
while(true){
if(check_timer()){ // タイマーの値を確認
printtf("Timer happened.\n");
break;
}
printf("Sleeping\n");
mysleep(100); // 100ms スリープする
}
return 0;
}
lib.js
に mysleep
を実装します。Asyncify.handleSleep
を呼び出すのがポイントです。この中に非同期処理を記述します。JSのPromiseと似た記法です。似ているといえば、非同期処理の最後に wakeUp
を呼び出す点も、resolve
/reject
を呼ぶのと似ています。wakeUp
を呼び出すと、処理は呼び出し元へ戻ります。
function mysleep(interval){
Asyncify.handleSleep(function(wakeUp) {
setTimeout(function(){
wakeUp();
}, interval);
});
}
lib.js
をリンクできるように、コンパイルオプションに --js-library
を追加します。また ASYNCIFY_IMPORTS
というリストに、Asyncify で非同期化する関数を列挙します(ドキュメント):
% emcc -s ASYNCIFY=1 -s 'ASYNCIFY_IMPORTS=["mysleep"]' --js-library lib.js -o timer.js timer.cpp
なお ASYNCIFY_IMPORTS
を指定しない場合でも、コンパイルエラーは起きません。ただ実行した際にエラーが起きます。戻り先で unreachable
が実行されてしまうためです。
簡易的なイベントループを自作する
ここまでで自分で定義した関数を、非同期化できるようになりました。これを踏まえて、次のようなイベントループを持つプログラムを Asyncify を使ってメインスレッドで動くようにしていきます。
#include <stdio.h>
extern "C" int get_key();
extern "C" void update(int keycode);
int main(int argc, char **argv) {
while (true) {
int key = -1;
key = get_key();
if (key >= 0) {
update(key);
}
if (key == 27) {
printf("breaks from the main loop");
break;
}
}
return 0;
}
これはキーコードを取得し、取得したキーコードを引数に update
関数を呼ぶというプログラムです。些末なことですが、ESC
キーを押すと、ループから抜け、プログラムが終了します。このプログラムは main.cpp
に記述されていることとします。
get_key
がキーコードを取得する関数です。これを非同期処理にすることで、メインスレッドがブロックされるのを防ぎます。先の例にならって、lib.js
に実装します。
function get_key() {
Asyncify.handleSleep(function(wakeup) {
const handler = function() {
const key = keys.shift();
const code = key == null ? -1 : key;
wakeup(code);
};
requestAnimationFrame(handler);
});
}
このように適当に実装しました。キーイベント自体は、別の場所で取得しておきキーイベントキューに入れておきます。keys
が、そのイベントキューになります。実体は別のJSファイル(pre.js
としておきます)に定義されています:
const keys = [];
function setKeyEventHandler() {
document.body.addEventListener('keydown', e => {
keys.push(e.keyCode);
});
}
setKeyEventHandler();
あとは update
関数を適切に実装します。実装は lib.js
内に記述します。
これを次のようにコンパイルすることで、メインスレッドを占有しないイベントループが実現できます。
% emcc --pre-js pre.js --js-library lib.js -s ASYNCIFY=1 -s 'ASYNCIFY_IMPORTS=["get_key"]' -o eventloop.js main.cpp
JS / WASM のサイズは増加します
便利な Asyncify ですが、ドキュメントによるとトレードオフは存在します。それはコードサイズの増大と、実行時のオーバーヘッドです。
詳しく内部はみていませんが、Asyncify は次のことを行うようです。これの実装を行うための処理が JS と WASM に追加されるためコードサイズが増え、また処理時間も増大するということのようです。
- C/C++ のコールスタックの保存と復元
- ASYNCIFY_IMPORTS に列挙された関数の分割
これを確かめてみようと思いました。非同期化しなくても動くプログラム、ということでフィボナッチ数を計算する関数を Asyncify を使って非同期化したビルドと、しなかったビルドを作り、比較しました。
フィボナッチ数の計算
次のようなナイーブな実装を行いました:
function doFib(n) {
if (n < 3) {
return 1;
}
return doFib(n - 1) + doFib(n - 2);
}
Asyncify に対応した関数を次のように定義し、これを C++ のコードから呼んで実行時間を計測しました。
function calc(n) {
return Asyncify.handleSleep(function(wakeup) {
const result = doFib(n);
wakeup(result);
});
}
コントロール群のデータは、次のように Asyncify を使わないものを作り、同様に C++ のコードから呼び出し計測しました。
function calc(n) {
return doFib(n);
}
速度の比較
実験計画
-
n
の値を 20, 25, 30, 35 と 4 段階に変化させ、それぞれのn
に対し、フィボナッチ数を計算します - 計算を 100 回行い、その実行時間を nano sec. 単位で計測します
- 2 を、それぞれの
n
に対して 5 回ずつ実行し、得られた平均値の平均を算出します - 3 を比較します
回数は適当に決めました。なんとなくの傾向が出ればいいかな、くらいのつもりでした。
結果
群 | n = 20 | n = 25 | n = 30 | n = 35 |
---|---|---|---|---|
Asyncifyなし | 43193.64 | 421036.328 | 4794282.62 | 53285812.46 |
Asyncifyあり | 45401.08 | 464750.99 | 4933509.36 | 56418746.00 |
5% から 10% ほど、Asyncify ありの方が遅くなっています(上記の表の単位は nano sec.で、小数点3桁以下は切り捨てています)。
検定などしていませんし、実験計画も自信がないので意味ある結果とはいえませんし、requestAnimationFrame
で次のtickを待っている分遅くなっているのかな?とも思えます。
コードサイズの比較
単純に ls
で比較しました。どちらも -O3
で最適化しています。
|群|JS ファイル|WASM ファイル|
|:-:|----:|-----:|-----:|
|Asyncifyなし| 16K| 9.2K|
|Asyncifyあり| 42K| 22K|
顕著にファイルサイズが増大していることがわかります。
まとめと雑感
- 良い点:C/C++ としてシンプルなコードを、そのまま Web に持っていける可能性が高まる
- 悪い点:ファイルサイズが増える
Asyncify を使えば C/C++ でよくあるメッセージループも、 C/C++ 側の変更がほとどんどない形でブラウザー上で動作させられる可能性が出てきました。
これまでも vim.wasm のように Worker と SharedArrayBuffer を使って実現するという方法もありました。ツールが非同期処理を仮想的に同期化してくれるため開発者の負荷、特にメンタル面の負荷を減らせるように感じています。
コードがシンプルになる一方で、ファイルサイズは増大します。モバイル Web では影響を慎重に計らなければならないでしょう。
パフォーマンスオーバーヘッドも、もしかしたらあるかもしれません。私の実験では、5% 程度のオーバーヘッドがありましたが、適用例が適切だったかは自信がありません。またメインスレッドが占有され、UI が処理を受け付けないという状態を避けられることに比べれば、多少のパフォーマンスオーバーヘッドは許容できる場合もおおいのでは?という気もしています。