Asyncify を使ってみた
Tags: webassembly, emscripten, asyncify
Published: 2020-03-30 01:16:23

# TL;DR; 

* Aysncify を使うことで、同期的な C/C++ 関数を、非同期なものにできます
* `-s ASYNCIFY=1` をコンパイラオプションに指定した上で、 `-s 'ASYNCIFY_IMPORTS=["関数1", "関数2", …]'`のように非同期化する関数名を列挙してコンパイルします
* 出力される JS ファイルのサイズと、WASM ファイルサイズが増えます

# Aysncify とは

[Asyncify](https://emscripten.org/docs/porting/asyncify.html)とは、[Emscripten](https://emscripten.org/) の提供する機能の 1 つです。これを使うと次のことが可能になります。まさにマジック。

* 同期的なC/C++のコードを、非同期化します
* 非同期なJS APIの結果を、C/C++ 側では（擬似的な）同期的に受け取ることができます

何ができるのかを、試した結果をまとめました。まずは無限ループがあるコードを例にみてゆきましょう。

### 無限ループのあるコードが、メインスレッドで動く

次のようなプログラムがあるとします。タイマーの状態を確認して、その値が `true` になったら無限ループから抜けて終了する、というものです。

~~~C++
#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` を使って、メッセージループから起動されるように記述するのが常でした。

~~~javascript
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 を使って非同期化していきます。

~~~C++
#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_timer` と `check_timer` の実装を定義
3. `usleep` を `emscripten_sleep` で置き換え

`EM_JS` については、[こちらのエントリー](https://qiita.com/chikoski/items/9ac019a86095cfcf2c73)を参照してください。また [`Module` オブジェクトについては、公式ドキュメントに説明があります](https://emscripten.org/docs/api_reference/module.html)。

~~~C++
#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` をオプションに加えることです。

~~~sh
% emcc -s ASYNCIFY=1 -o timer.js timer.c
~~~

出力された　JS ファイルを Node で実行すると、次のようになります：

~~~sh
% node timer.js
Sleeping
Sleeping
Sleeping
Sleeping
Timer happened.
~~~

もちろんブラウザーでも動きます。次のような HTML ファイルを用意して、テスト用の Web サーバーに配置して、アクセスすると、コンソールに上記のように出力されると思います。

この時注目したいのは、ブラウザの UI が固まっていない点です。例えば新しいタブを開こうと思えば、ひらけます。これはメインスレッドが JS / WASM の実行で占有されていないことを示しています。

~~~html
<!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 ファイルに切り出します。

~~~javascript
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` のシグネチャも追加し、それを呼び出すように変更します。

~~~C++
#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`を呼び出すと、処理は呼び出し元へ戻ります。

~~~javascript
function mysleep(interval){
  Asyncify.handleSleep(function(wakeUp) {
    setTimeout(function(){
      wakeUp();
    }, interval);
  });
}
~~~

`lib.js` をリンクできるように、コンパイルオプションに `--js-library` を追加します。また `ASYNCIFY_IMPORTS` というリストに、Asyncify で非同期化する関数を列挙します（[ドキュメント](https://emscripten.org/docs/porting/asyncify.html#more-on-asyncify-imports)）：

~~~sh
% emcc -s ASYNCIFY=1 -s 'ASYNCIFY_IMPORTS=["mysleep"]' --js-library lib.js -o timer.js timer.cpp
~~~

なお `ASYNCIFY_IMPORTS` を指定しない場合でも、コンパイルエラーは起きません。ただ実行した際にエラーが起きます。戻り先で `unreachable` が実行されてしまうためです。

# 簡易的なイベントループを自作する

ここまでで自分で定義した関数を、非同期化できるようになりました。これを踏まえて、次のようなイベントループを持つプログラムを Asyncify を使ってメインスレッドで動くようにしていきます。

~~~c++
#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` に実装します。

~~~javascript
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` としておきます）に定義されています：

~~~javascript
const keys = [];
function setKeyEventHandler() {
  document.body.addEventListener('keydown', e => {
    keys.push(e.keyCode);
  });
}
setKeyEventHandler();
~~~

あとは `update` 関数を適切に実装します。実装は `lib.js` 内に記述します。

これを次のようにコンパイルすることで、メインスレッドを占有しないイベントループが実現できます。

~~~sh
% 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 を使って非同期化したビルドと、しなかったビルドを作り、比較しました。

## フィボナッチ数の計算

次のようなナイーブな実装を行いました：

~~~javascript
function doFib(n) {
  if (n < 3) {
    return 1;
  }
  return doFib(n - 1) + doFib(n - 2);
}
~~~

Asyncify に対応した関数を次のように定義し、これを C++ のコードから呼んで実行時間を計測しました。

~~~javascript
function calc(n) {
  return Asyncify.handleSleep(function(wakeup) {
    const result = doFib(n);
    wakeup(result);
  });
}
~~~

コントロール群のデータは、次のように Asyncify を使わないものを作り、同様に C++ のコードから呼び出し計測しました。

~~~javascript
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.で、小数点３桁以下は切り捨てています）。

検定などしていませんし、実験計画も自信がないので意味ある結果とはいえませんし、`requestAnimationFrame` で次のtickを待っている分遅くなっているのかな？とも思えます。

## コードサイズの比較

単純に `ls` で比較しました。どちらも `-O3` で最適化しています。

|群|JS ファイル|WASM ファイル|
|:-:|----:|-----:|-----:|
|Asyncifyなし| 16K| 9.2K|
|Asyncifyあり| 42K| 22K|

顕著にファイルサイズが増大していることがわかります。

# まとめと雑感

* 良い点：C/C++ としてシンプルなコードを、そのまま Web に持っていける可能性が高まる
* 悪い点：ファイルサイズが増える

Asyncify を使えば C/C++ でよくあるメッセージループも、 C/C++ 側の変更がほとどんどない形でブラウザー上で動作させられる可能性が出てきました。

これまでも [vim.wasm](https://github.com/rhysd/vim.wasm) のように Worker と SharedArrayBuffer を使って実現するという方法もありました。ツールが非同期処理を仮想的に同期化してくれるため開発者の負荷、特にメンタル面の負荷を減らせるように感じています。

コードがシンプルになる一方で、ファイルサイズは増大します。モバイル Web では影響を慎重に計らなければならないでしょう。

パフォーマンスオーバーヘッドも、もしかしたらあるかもしれません。私の実験では、5% 程度のオーバーヘッドがありましたが、適用例が適切だったかは自信がありません。またメインスレッドが占有され、UI が処理を受け付けないという状態を避けられることに比べれば、多少のパフォーマンスオーバーヘッドは許容できる場合もおおいのでは？という気もしています。

# レファンレンス

* [Emscripten プロジェクトサイト](https://emscripten.org/)
* [Asyncify のドキュメント](https://emscripten.org/docs/porting/asyncify.html)
* [Asyncify に対応した仮想的な同期関数](https://emscripten.org/docs/api_reference/emscripten.h.html#pseudo-synchronous-functions)
* [Module オブジェクト](https://emscripten.org/docs/api_reference/module.html)