9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

WebAssemblyAdvent Calendar 2019

Day 24

Asyncify を使ってみた

Last updated at Posted at 2019-12-26

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 が処理を受け付けないという状態を避けられることに比べれば、多少のパフォーマンスオーバーヘッドは許容できる場合もおおいのでは?という気もしています。

レファンレンス

9
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?