DEV Community

N. Shimizu
N. Shimizu

Posted on

Asyncify を使ってみた

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 もブロックしてしまいます。そのため、次のように setIntervalrequestAnimationFrame を使って、メッセージループから起動されるように記述するのが常でした。

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 点が差分です:

  1. unistd.h の代わりに emscripten.h をインクルード
  2. EM_JS マクロを使って start_timercheck_timer の実装を定義
  3. usleepemscripten_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_timercheck_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.jsmysleep を実装します。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);
}




速度の比較

実験計画

  1. n の値を 20, 25, 30, 35 と 4 段階に変化させ、それぞれの n に対し、フィボナッチ数を計算します
  2. 計算を 100 回行い、その実行時間を nano sec. 単位で計測します
  3. 2 を、それぞれの n に対して 5 回ずつ実行し、得られた平均値の平均を算出します
  4. 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 が処理を受け付けないという状態を避けられることに比べれば、多少のパフォーマンスオーバーヘッドは許容できる場合もおおいのでは?という気もしています。

レファンレンス

Top comments (0)