TameJS と Fiber による非同期処理の記述 (1/2)

前回からかなり時間が空いてしまいました...やっと時間取れた.今回は 2 回に分け,1 回目で TameJSを,2 回目で node-fibers を取り上げたいと思います.
ちなみに,「この方法が良い」と言うよりは「こんな方法もありますよね」というスタンスで書いています*1

見出し

  • はじめに
  • TameJS とは?
  • TameJS の利用例
  • TameJS のしていること
  • おわりに

はじめに

Node.js は非同期処理が基本であり,コールバックを多用するスタイルです.そのため,コードは簡単にコールバックのネストだらけになります*2
この "深いネスト" を解消するため,多くの場合は control flow ライブラリ が使用されます*3

TameJS では,非同期処理の記述に await/defer を持ち込み,上記の解決を試みています.

TameJS とは?

TameJS は tjs コードから JavaScript を生成する translator です.tjs コードは await/defer が追加されている点を除けば,JavaScript そのものです.
非同期処理の記述に await/defer を持ち込むことで,ノンブロッキング API を利用しているにもかかわらず,あたかも同期処理であるかのような記述を可能としています*4.これにより,ユーザの記述するコード上からコールバックによるネストを無くすことができます*5

TameJS の利用例

まずは以下のような tjs コードを記述します.

// ファイル名:read_file.tjs

var fs = require('fs');
await {
  fs.readFile('./test.txt', defer(var err, text));
}
if (err) {
  console.log('error: ' + err);
} else {
  console.log(text.toString('utf8'));
}

await ブロックで囲まれたコードが非同期に実行されます.複数の非同期 API を呼び出せば,それぞれ並行して動作します.ブロック中の API から結果が返ってくると,ブロック外のコードへと処理が進むイメージです.
defer にはコールバックが受け取るパラメータを指定します.await ブロックを抜けると,これらのパラメータが値に束縛されます.
利用可能な API については,以下の "API and Documentation" に載っています.

ちなみに,上記の tjs コードは以下の JavaScript とほぼ等価です.見た目はずいぶんと違いますね.

var fs = require('fs');
fs.readFile('./test.txt', function(err, text) {
  if (err) {
    console.log(err);
  } else {
    consloe.log(text);
  }
});

実行方法は,以下に示す 2 つのパターンがあります.


事前にコンパイルして実行
tamejs コマンドでコンパイルします."-o" オプションで出力ファイル名を指定できます.

$ tamejs read_file.tjs  // tjs -> js
$ node read_file.js     // 実行


require されたときにコンパイルして実行*6
*.tjs コードを利用したい JavaScript コード中に,以下を記載します.

// ファイル名:main.js

require('tamejs').register();
require('./read_file.tjs');

// ...

最後に,いつも通り実行します.

$ node main.js

(余談: register() 内部では,".tjs" に対してコンパイル処理を定義しています.TameJS Engine によって *.tjs から *.js に変換し,V8 コンパイルルーチンにコードを渡す,といった流れです.)

TameJS のしていること

await/defer の記述を元に,tjs コードを 継続渡し形式 (CPS)JavaScript に変換しています.CPSググるといっぱい解説が出てきます.
継続とは,端的に言ってしまうと「以降の計算処理」をまとめたものです.CPS では,渡された継続を利用してプログラムの制御を行います.

例えば,以下のような 2 つの処理があったとします.

A;
B;

これを CPS 変換*7すると,以下のようになります.

function _A(next) {
  A;
  next();
}
function _B(next) {
  B;
  next();
}
function end() {}

// 実行
_A(function() { _B(function() { end(); }); });

上記コードにおける next が継続に相当します*8.もしも A の処理が非同期であった場合は,_A に渡された next をコールバック内部で呼び出せばよいわけです.


実際に,前出の read_file.tjs をコンパイルすると,以下のようなコードが生成されます.

var tame = require('tamejs').runtime;
var __tame_fn_6 = function (__tame_k) {
    var fs = require ( 'fs' ) ;
    var __tame_fn_0 = function (__tame_k) {
        var err, text;
        var __tame_fn_1 = function (__tame_k) {
            var __tame_defers = new tame.Defers (__tame_k);
            var __tame_fn_2 = function (__tame_k) {
                fs . readFile ( './test.txt' ,
                __tame_defers.defer (
                    function () {
                        err = arguments[0];
                        text = arguments[1];
                    }
                )
                ) ;
                tame.callChain([__tame_k]);
            };
            __tame_fn_2(tame.end);
            __tame_defers._fulfill();
        };
        var __tame_fn_3 = function (__tame_k) {
            var __tame_fn_4 = function (__tame_k) {
                console . log ( 'error: ' + err ) ;
                tame.callChain([__tame_k]);
            };
            var __tame_fn_5 = function (__tame_k) {
                console . log ( text . toString ( 'utf8' ) ) ;
                tame.callChain([__tame_k]);
            };
            if (err) {
                tame.callChain([__tame_fn_4, __tame_k]);
            } else {
                tame.callChain([__tame_fn_5, __tame_k]);
            }
        };
        tame.callChain([__tame_fn_1, __tame_fn_3, __tame_k]);
    };
    tame.callChain([__tame_fn_0, __tame_k]);
};
__tame_fn_6 (tame.end);

"次の処理" を __tame_fn_* で包んで継続とし,callChain に渡しています.callChain は以下のように定義されており,次々と継続を処理していきます.

function callChain (l) {
    if (l.length) {
        var first = l.shift ();
        first (function () { callChain (l); });
    }
};


await ブロック内部において,defer 指定の変数が値に束縛されるまで待つのは,Defers#defer と Defers#_fulfill が同期部分の継続呼び出しをコントロールしているためです (上記コードにおける __tame_fn_3).
Defers 内部ではコールバック呼び出しをカウンタ管理しており,defer 呼び出しで内部カウンタを increment,非同期 API のコールバックが呼び出されたタイミングで _fulfill を呼び出して decrement しています*9.このカウンタが 0 になるタイミング → 最後に呼び出されたコールバックのタイミング,で次の継続 (同期コードの部分) が呼び出されます.



図にするとこんな感じです.黒四角の番号は __tame_fn_*, R は readFile, E と E' は tame.end, C は readFile のコールバック,青四角は callChain による継続を表しています.function を直接呼び出す場合は黒矢印,継続として呼び出す場合は青矢印で表しています.
6 から順に呼び出しが進んでいき,2 内部の tame.callChain が終わると一旦イベントループに戻ります.1 で _fulfill が呼び出されていますが,先に述べた Defers の働きにより,3 以降の継続は呼び出されません.
その後,readFile の結果としてコールバック (図中 C) が呼び出されると,Defers が内部に保持していた 3 以降の継続が呼び出されます.











おわりに

TameJS により,非同期処理を見通しの良いコードで記述することができます.外部リソースの参照・更新を頻繁に行いながら処理を進めるコードで利用すると良さそうです.
あとは IDE でこの記述がサポートされるともっと便利になりますね.
次回は Fiber を利用します.Node.js 上で coroutine が使えます.
(2/2 へ続く...)

*1:どちらもコアは,古より伝わりし手法です

*2:いやマジで

*3:有名どころは async,Step,Slide とかでしょうか,正直詳しくは分かりません...

*4:当然ですが,ノンブロッキング API はキチンと "ノンブロッキング" として動作します

*5:後で述べますが,プリコンパイル後の JavaScript はコールバックだらけです

*6:手元の環境は,最近ランタイム系をごちゃごちゃいじったせいか,うまく動きません...なぜだ

*7:JavaScript

*8:このような変換を行わず,継続を明示的に扱うオブジェクトとして取得するのが rubyscheme 等の call/cc であり,次回に紹介する Fiber の素でもあります

*9:_fulfill は defer の返すクロージャ内部で呼び出されます