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;
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:このような変換を行わず,継続を明示的に扱うオブジェクトとして取得するのが ruby,scheme 等の call/cc であり,次回に紹介する Fiber の素でもあります