S.F. Page

Programming,Music,etc...

node.js - 非同期処理やQの話

Pyramid of Doom

非同期処理をうまく処理するためにQを使っている。QはPromiseとそれを使用するアルゴリズムで構成されている。 どうも人間というのは上から下にフローが流れていかないと理解が難しくなるようである。コールバックによる非同期処理を行うと縦方向と同時に右横方向にフローが流れていく。これを「Pyramid of Doom(破滅(死)のピラミッド?)」と呼ぶそうだ。
step1(function (value1) {
    step2(value1, function(value2) {
        step3(value2, function(value3) {
            step4(value3, function(value4) {
                // Do something with value4
            });
        });
    });
});
q/README.md at v1 · kriskowal/q
if文でもネストが深くなると難解になってくるが、非同期コールバックを多用したプログラミングを使用するとネストがすぐ深くなり、巨大なピラミッドが比較的簡単なコードでできてしまう。同期処理では数行程度で書けてしまうものでもそうなってしまうこともある。 そもそも上記のコードもコールバックなしの同期処理だと以下のようなコードで済むだろう。
var value1 = step1();
var value2 = step2(value1);
var value3 = step3(value2);
var value4 = step4(value3);
非同期処理をnode.jsが多用するのは理由がある。JavaScriptのようにシングル・スレッドを前提とした言語では実行時の処理効率を上げる効果があるからだ。非同期処理は時間のかかる処理を後回しにして次の処理に取り掛かることができる。その後回しの処理を実現するのがコールバックである。ある時間のかかる関数に対して処理完了後に行いたい関数を事前に登録する。関数は処理が完了したら登録した関数を呼び出す。 最初の非同期処理コードであればstep4の処理を待たずに後続の処理に取り掛かることができ、待ち時間を有効活用できる。同期処理であれば処理はstep4の終了まで待たねばならない。 ただまてよ?と思う。非同期で呼び出すということは別の処理を平行して行っているということである。JavaScriptはシングル・スレッドではなかったのか。いったいどうやって並行処理を実現するのだろう。

node.jsの非同期処理

[Node.js] 非同期型イベント駆動とは 〜 JSおくのほそ道 #001 - Qiitaによると
  • 並行処理はワーカー(スレッドもしくはプロセス)が行う。
  • メイン・スレッドはワーカーに処理を任せ処理を続行する。
ということで、ランタイム側でワーカーを使っていて、さらに「[JavaScript] JavaScriptはシングルスレッド:非同期処理の仕組み | Ouka Studio」を引用すると
  • 関数単位で順番に実行されていく
  • 非同期処理は、実行の準備ができたコールバック関数が待ち行列をつくり、現在実行中の関数の処理が終了してから、順に関数が実行されていく
だそうである。 node.jsはV8をベースに、C++で実装されている。非同期処理はほぼC++側が担っている。ただすべての操作が非同期で行われるわけではない。OSのAPIがブロッキングI/Oしかサポートしていない場合などはワーカースレッドで処理させて疑似的に非同期処理を実現しているようだ。 このあたりを探っているとnode.jsの「非同期型のイベント駆動モデル」というのは今のマルチスレッド環境よりも低レベルな機能をマルチスレッド環境上で実現しているというものなのかなと思う。昔CPUがシングル・スレッドしかサポートしていなかったころ、「割り込み」という仕組みがあったが、これを今風にしたもののように思える。

Promise(future)

話は戻るが、このコールバックを避ける非同期処理のデザイン・パターンとして「Promise(future)」というものがある。これはPromise - JavaScript | MDNによると、
作成時点では分からなくてもよい値へのプロキシです。
ということである。
future - Wikipediaによれば、
何らかの処理を別のスレッドで処理させる際、その処理結果の取得を必要になるところまで後回しにする手法。処理をパイプライン化させる。
まあ概念を表すにはこれでいいのかもしれないけれどもちょっと「もやっ」とする。
私なりの理解としては実質的な面でいえば 「関数の戻り値でコールバック登録できるようにしたもの」 がPromiseであると思う。「q/README.js at v1 · kriskowal/q」にPromiseの実装方法が段階的に書いてあってそれを読むとよりはっきりする。 この「関数の戻り値でコールバック登録できる」ことによって、ネストを1段階下げることができるようになる。これを汎用化し、アルゴリズムを付け加えたのがQである。

Qを使えば

非同期処理は下記のように書くことができる。
Q.fcall(promisedStep1)
.then(promisedStep2)
.then(promisedStep3)
.then(promisedStep4)
.then(function (value4) {
    // Do something with value4
})
.catch(function (error) {
    // Handle any error from all above steps
})
.done();
しかしこういう風に書けるのも関数がPromiseオブジェクトを返すからである。既存のnode.js APIはPromiseオブジェクトなど返しはしない。普通に考えるとAPIをラップするには以下のようなラッパーを書く必要があると思う。

function wrap(path){
  var defer = Q.defer();
  fs.mkdir(path,function(err){
    if(err) defer.reject(err);
    else defer.resolve();
  });
  return defer.promise;
}

Qは既存関数をうまくラップしてPromiseを返してくれるヘルパーを持っている。例えばnode.jsではコールバックは第一引数がエラー情報と決まっているので、それに対応したQ.nfcallがあり、Q.nfcallで呼び出すとpromiseオブジェクトを戻り値とする関数に変換される。つまり上記のラッパー関数は下記のように簡潔に書くことができる。
  Q.nfcall(fs.mkdir,path);
このようなヘルパーがQにはいくつかあって、使い分けすることでほぼ既存関数をラップすることができる。ちょっと気を付けないといけないのは、
  • nfcall,nfapplyは単純な関数で利用可
  • インスタンス・メソッドはnbind,ninvoke,npostを使用する
という使い分けが必要なことである。私はここで少しハマってしまった。。。