nmi.jp Twitter → @tkihira
embona - ブラウザで動くBonanzaを作ってみた(その3) IoTの超素人から素人を目指す

embona - ブラウザで動くBonanzaを作ってみた(その4)


2015-01-19
Takuo Kihira

前の記事では、ブラウザ上でBonanzaの起動に成功しました。この記事では、実際にBonanzaをJavaScript側から操作することで、よりEmscriptenの深い使い方を学んでみようと思います。。

前の記事はこちら→ embona - ブラウザで動くBonanzaを作ってみた(その3)
とりあえず遊んでみたい、という方はその1の記事の最初にリンクを用意しておきました。そちらをご参照ください。

標準入出力をフェッチする

前の記事で何が嫌だって、毎回標準入力のプロンプトが出てくることです。プログラム側では入力があるかどうか確認しているだけなのに、標準入力に問い合わせがあると律儀にダイアログを出すので、結果的に何度も何度もダイアログが出てくることになります。

そして、ダイアログをキャンセルすると内部では恐らく-1(EOF)が送られてしまい、Bonanzaは勘違いして終了しようとします。その1でビルドしたCのBonanzaは1手に10秒かけていたのに、今回のブラウザBonanzaは1手をミリ秒で処理し、駆け足で終了しようとしているのにお気づきになったでしょうか?それはここの入力のせいなのです。

というわけで、標準入出力をEmscriptenではなくプログラム側で処理する必要があります。Emscriptenではプログラム起動前に呼ばれるプログラムファイルを指定することができるので、これらを処理するために、pre.js というファイルを作り、そこのプログラムで対応してみます。

var stdin_buffer = "limit time 0 1\nstress off\nmove 9999\n";
function input_callback (){
    if(!stdin_buffer) {
        return "\n".charCodeAt(0); // no input
    }
    var c = stdin_buffer[0];
    stdin_buffer = stdin_buffer.substr(1);
    return c.charCodeAt(0);
}
var stdout_buffer = "";
var crflag = false;
function output_callback (_char){
    if(_char == 0 || _char == 0x0a) {
        console.log(stdout_buffer);
        stdout_buffer = "";
        crflag = false;
        return;
    }
    if(_char == 0x0d) {
        crflag = true;
        return;
    }
    if(crflag) {
        crflag = false;
        stdout_buffer = "";
    }
    stdout_buffer += String.fromCharCode(_char);
}
var stderr_buffer = "";
function error_callback (_char){
    if(_char == 0 || _char == 0x0a) {
        console.error(stderr_buffer);
        stderr_buffer = "";
        return;
    }
    stderr_buffer += String.fromCharCode(_char);
}
 
if(typeof Module !== "undefined") {
} else {
    var Module = {};
    Module["preRun"] = [];
}
Module["preRun"].push(function() {
    FS.init(input_callback, output_callback, error_callback)
});

Emscriptenでは、Moduleというグローバル変数を通して、Emscriptenのシステムに関する様々な設定をすることが出来ます。今回はModuleのpreRunを利用して、プログラム起動前の処理を書きます。やることは単純で、標準入出力のコールバック関数(input_callback、output_callback、error_callback)を設定するだけです。

なお、世の中のEmscriptenのサンプルは自前でModuleを再定義している例が多いですが、既にEmscriptenによりModuleが準備されている場合は、決して上書きしないようにしましょう。知らずに上書きすると、ビルドオプションを変えても全然反映されなくなり途方にくれることになります。注意してください。ネットのサンプルではpreRunに関数を代入している例もありますが、preRunは配列なので関数をpushするのが正しい使い方です。

input_callbackでは、あらかじめ設定した文字列を一方的に送り、それが尽きたらずっと改行を送るようにしています。残念ながら現状のEmscriptenでは標準入力のPeekに来た問い合わせに対し「入力内容なし」と答えることが出来ないので、情けない話ですが比較的害のない改行押しっぱなし状態にしておきます。

output_callbackとerror_callbackは単純な実装ですね。Bonanzaは出力にcarriage returnを多用しているので、汚いコードですがそれに対応するようにしています。 これで自前の実装でconsole.logに出力出来るようになりました。ついでにembona.htmlを自前で用意しちゃいます。

<!doctype html>
<html><head><title>embona</title>
<script src="bonanza.js"></script>
</head><body></body></html>

超シンプル。出力はコンソールで確認しましょう、という割り切った手法です。結果はこちらです。

ファイルを自前で準備し、Workerで起動する

さて、今のところブラウザで動いてはおりますが、JavaScriptがメインループをずっと占有しており、ブラウザのタブを閉じることが出来ないほどの重さです。こんな状況ではサービスでは使い物になりません。そこで、WebWorkersを使い、Bonanzaをバックグラウンドで起動することにしましょう。

Workerで起動するためには、Emscriptenの出力をhtmlではなくてjsにすると出来そうです。しかしそうすると、今まで --preload-file でロードしていたfv.bin を自前でEmscriptenのファイルシステムにロードしなければいけません。ちょっと面倒そうですが、実際そこまで面倒ではありません。

ファイルシステム絡みは本体プログラムが起動する前に完了させたいので、pre.jsの中身に記述することにします。上記のファイルの続きに以下を追記します。

Module["noInitialRun"] = true;
 
var xhr = new XMLHttpRequest();
xhr.open("GET", "fv.bin");
xhr.responseType = "arraybuffer";
xhr.send(null);
xhr.onreadystatechange = function() {
    if(xhr.readyState == 4) {
        if(xhr.status == 200) {
            if(xhr.response != undefined) {
                FS.writeFile("fv.bin", new Uint8Array(xhr.response), {encoding: "binary"});
                Module["_main"]();
            }
        }
    }
};

noInitialRunをtrueにすることで、ファイルが準備される前に自動的にmain関数が実行されるのを防ぎます。そしてXHRでファイルをダウンロードし、完了したらFS.writeFileで既存のFS上に出力し準備完了です。全ての準備が完了したので、ここでBonanzaのプログラム(_main)を実行します。

Workerの処理の部分も追加しましょう。embona.htmlを次のように変更します。

<!doctype html>
<html><head><title>embona</title>
<script>
onload = function() {
    var worker = new Worker("bonanza.js");
};
</script>
</head><body></body></html>

Makefileも忘れずに更新します。

bonanza.js : $(OBJS) pre.js
    $(CC) $(LDFLAG1) -o bonanza.html $(OBJS) $(LDFLAG2) -s TOTAL_MEMORY=671088640 --pre-js pre.js

必要の無くなったフラグを消去します。これでリビルドすれば完成です。

見た目は上の画像と同じですが、「Show all messages」というオプションが出ているところで、これがWorkerで起動していることが確認出来ます。リロードしてもサクサクリロード出来て、ユーザー体感は比較にならないほど良くなりました。

メインループをsetTimeoutに変更する

さて、Workerに移したのでメインループが無限ループをしたところでブラウザが止まったりすることはなくなりました。しかし、この状態だと本体からWorkerに通信することが出来ません。本体からpostMessageをしても、Worker側でそれを受け取るためにはループから外れなければいけないからです。

Emscriptenのこの問題に対する一般的な解決方法は、今のところありません。将来的にSharedArrayBufferが実装されたり、同期的かつWorker内部で扱えるStorageが実装されれば問題解決の可能性が出てくるのですが、残念ながら今は両方とも対応していないようです。

今回は、自分でEmscriptenの出力ファイルの Bonanza.js を直接編集して、メインループをsetTimeoutで書きなおしました。

(function __main_loop() {
 HEAP32[34181088>>2] = 0;
 $3 = (_ponder(220526688)|0);
 $4 = ($3|0)<(0);
 if ($4) {
  $$0$i = $3;
 } else {
  $5 = HEAP32[34180920>>2]|0;
  $6 = $5 & 16;
  $7 = ($6|0)==(0);
  if (!($7)) {
   return;
   //break;
  }
  $8 = ($3|0)==(2);
  if ($8) {
   setTimeout(__main_loop,0);return;
   //continue;
  }
  $9 = HEAP32[34181088>>2]|0;
  $10 = ($9|0)==(-33554432);
  if (!($10)) {
   _show_prompt();
  }
  $11 = (_next_cmdline(1)|0);
  $12 = ($11|0)<(0);
  if ($12) {
   $$0$i = $11;
  } else {
   $13 = HEAP32[34180920>>2]|0;
   $14 = $13 & 16;
   $15 = ($14|0)==(0);
   if (!($15)) {
    return;
    //break;
   }
   $16 = (_procedure(220526688)|0);
   $17 = ($16|0)<(0);
   if ($17) {
    $$0$i = $16;
   } else {
    $18 = HEAP32[34180920>>2]|0;
    $19 = $18 >>> 2;
    $20 = $19 & 4;
    $21 = $20 ^ 4;
    $22 = (($21) + -3)|0;
    $$0$i = $22;
   }
  }
 }
 if ((($$0$i|0) == -1)) {
  label = 13;
  return;
  //break;
 } else if ((($$0$i|0) == -3)) {
  return;
  //break;
 } else if (!((($$0$i|0) == -2))) {
  setTimeout(__main_loop,0);return;
  //continue;
 }
 $24 = HEAP32[220526664>>2]|0;
 HEAP32[$vararg_buffer4>>2] = $24;
 _out_warning(221111352,$vararg_buffer4);
 _shutdown_all();
 setTimeout(__main_loop,0);
})();

while(1) になっていた内部を 名前付き関数式 __main_loopで囲い、breakされた場合はreturnを、continueされた場合やループの最後に到達した場合は再度__main_loopをsetTimeoutで呼んでいます。これで最悪メインループ1回ごとにpostMessageが処理出来るようになります。

実際の所、ここの部分はもう少し努力することが出来たと思います。例えばですが、Bonanzaの思考ルーチンのコードの中にsleepを入れ、Emscripten Asyncifyを利用してEmscripten側で非同期処理を実現するのが良い解決策になりそうだと思っています。しかし今回、敢えてBonanza本体のコードには手を入れないという縛りのもとでやっていたので、上記のような解決策になりました。

上記のように、Emscriptenの出力を編集するのは下策です。Emscriptenでビルドするたびにパッチを当てなければいけませんし、将来Emscriptenの出力が変わる可能性は大いにあります。なによりこれだとClosure Compilerを通すことも出来ません。出力コードを理解しそれを編集出来る事は大切ですが、実際のプロダクト等では取りづらい選択肢になるでしょう。そういった意味でもAsyncifyは便利なので、興味のある方は是非調べてみてください。

ブラウザ版Bonanzaの完成

以上でEmscriptenに関わる話はおしまいで、残りは純粋なJavaScriptの話になります。embona.htmlとpre.jsにmessageを受け取るリスナを付けて通信しつつ、Bonanzaのstdinに適宜文字列を放り込んで処理し、出力をstdoutからパースしてそれを見た目よく出力すれば、ブラウザ版Bonanzaの完成です。簡単ですね!私は将棋のUIを舐めていて、適当に作りはじめたら思いの外苦戦し、今でも相当のバグが残る状態になりました。お恥ずかしい。

その1でお見せしたブラウザ版Bonanzaは、一度fv.binを読み込んだらそのまま保存し続けるようにしたり、少しでもダウンロード時間を減らすためにgzip圧縮をかけてクライアント側で解凍したり、というような修正を入れております。

Bonanzaがこういった移植を念頭に置かれていたおかげで、ビルド周りではほとんどトラブルが無かったのが幸いでした。一方、将棋エンジンの一般的な特性ではありますが、プログラムがメモリを大量に消費したり、起動に180Mbyteものファイルを必要とするあたりはブラウザ向きではなかったと思います。しかし、そのようなプログラムでもブラウザに簡単に移植出来るのは素晴らしいことで、夢が広がります。

日本語のEmscriptenの資料が少なかったので、今回の記事は詳細に書いたつもりです。ちょっとしつこいくらいだったかもしれませんが、もしみなさんの参考になれば幸いです。何か不明点やコメント等あれば、お気軽にTwitter @tkihira までご連絡ください。