「for やめろ」またはイベントループと nextTick()
ものすごく遅レスですが、LLDiver で @esehara さんの LT であった話。
forやめろ、あるいは「繰り返し」という呪縛から逃れるために
簡単に言うと、 1~10 までを出力する方法を複数考えるというもの。 for, while, 再帰, goto etc.. と出て、途中で終わっちゃったので結論はよくわかりませんでしたが、 Node ではどれも使わずにできるな、と思ったのでちょっと例を出してみます。
ちなみに、タイトルでネタバレしている通りイベントループの話です。
そしてよくある「イベントループとは何か」「なぜ止めてはいけないのか」「process.nextTick()
とは何か」「setImmediate()
と何が違うのか」
などを解説する良い例だったので、書いてるうちに実はそっちがメインの解説となりました。
サンプルの実行結果は Node v0.11.13 です。(書き終わってから stream の古いインタフェースだったことに気づいた、、ごめんなさい)
リソースの読み込み
例えば、ファイルやネットワークからデータを次々に読む場合、
そのファイル相当に対して、 read()
的な API を何度も呼ぶことになるでしょう。
ここでは、標準入力経由で大きなデータをガンガン読み込む例を考えてみます。 例えば Go で書くとこんな感じになるでしょう。(エラー・終了処理は無視する)
package main import ( "fmt" "os" ) func main() { file, _ := os.Open("./bigfile") for { data := make([]byte, 65536) n, _ := file.Read(data) fmt.Printf("%s", data[:n]) } }
Go ではファイルを読む方法はいくつかありますが、ここでは低レベル API を使うため、バッファを毎回確保し for
を回してそこに入れています。
回さないと一回読み出してプログラムが終了してしまうので、 for
が必要です。
しかし、これを Node のイディオムで書くと、以下のようになります。
var fs = require('fs'); var bigfile = fs.createReadStream('./bigfile'); bigfile.on('data', function(chunk) { console.log(chunk); });
for や while、再起、 goto などは使っていません。 使っているのは、データが読み込まれたタイミングで発生するイベントです。
for を使っていないのだから、これを用いると出題である「for を使わずに 1~10 まで表示」という問題は、ちょっと変えれば解決します。
var fs = require('fs'); var bigfile = fs.createReadStream('./bigfile'); var c = 1; bigfile.on('data', function(chunk) { console.log(c++); if (c > 10) { return bigfile.pause(); } }); // 1 2 3 4 5 6 7 8 9 10
さて、ではこれは本当に 「for を使ってない」と言えるのでしょうか? プログラム上出てきていないので、「このプログラムファイル内」では間違いなく使っていません。 しかし、実行時に「ランタイム」では繰り返し相当の処理が行われています。
それが、それこそが、「イベントループ」だ!!
イベント駆動とは何か
Node ではイベント駆動のモデルを採用しています。
先の例では、ランタイム内で「データが標準出力から一定量読み込まれた」という状態の変化が発生した時に「 data
イベント」というイベントを発生させることによって、それを EventEmitter である、bigfile
に通知しています。
ユーザランドでは、そのイベントが発生したときに実施したい操作を、コールバックとして登録しておくことで、イベント発生時の処理を実行しています。
イベントループとは何か
ではランタイムでは、データを読む部分はどうなっているのでしょうか?
実装で言うと、ランタイム内では最初の Go の例と同じように、ループを回して何度もデータを読み出しています。
Node の場合、実際は while
文であり、読み出したデータが一定量溜まったら、ユーザ側にイベントとして通知しています。
ブラウザの中でも考え方は同じで、例えば DOM に対してクリックが発生したかどうかは、内部の while
ループの中でずっと監視しています。
この while
ループのことをイベントループと呼んでいるといって、差支えないでしょう。
外部リソースに頼らない書き方
さて、さっきの例はファイルを読んでおり、外部からの情報に頼るとかちょっとチート感あるでしょう。
そこで、外部リソースを無くして、純粋にイベントループのみを利用するとこんな感じにも書けます。
process.on('count', function(c) { if (c > 10) { return; } console.log(c); process.emit('count', ++c); // B }); process.emit('count', 0); // A // 0 1 2 3 4 5 6 7 8 9 10
ちゃんとやるなら自分で EventEmitter
を継承したものを実装すべきですが、手を抜いてグローバルな process
が EventEmitter
であることを利用しています。
イベントループが回る最初の回で、 count
というイベントにコールバックを設定し、その後すぐに登録したイベントを emit()
(A) で発火しています。
イベントループの最後に emit()
(A) で発火された count
のコールバックが実行されます。その中でまた emit()
(B) して、次のループの最後にまたコールバックが呼ばれ、また emit()
(B) が実行されます。
よって、このソースコードに関しては for
なしで命題を実装できているわけです。(同じことは、ブラウザでやるなら addEventListener()
と dispatchEvent()
などで同じことができます。 ちなみに拙作ですが Node.js の events の移植 を使うこともできます。)
が、この実装では不十分です。 というか、こんな実装したらイスが飛んできてしまいます。
emit はブロックする
一行足せばわかりますが、 emit()
は対応するコールバックが終わるまでブロックします。
つまり、そのコールバックの中でさらに emit()
を呼ぶこの実装では、 emit()
が終了待ちの列を作ります。
process.on('count', function(c) { if (c > 10) { return; } console.log(c); process.emit('count', ++c); console.log(c); }); process.emit('count', 0); // 0 1 2 3 4 5 6 7 8 9 10 11 10 9 8 7 6 5 4 3 2 1
要するにこれでは、実質的にただの「再帰」です。
そこで、毎回ループごとに emit()
を実行してコールバックが終了するように、 process.nextTick()
を使います。
ざっくり言えば、 process.nextTick()
とは、このコールバックの実行を、「イベントループの次の周の最初」に実行するように登録する API です。
process.on('count', function(c) { if (c > 10) { return; } console.log(c); process.nextTick(function() { process.emit('count', ++c); }); }); process.emit('count', 0); // 0 1 2 3 4 5 6 7 8 9 10
コールバックが emit()
を予約して抜けるようになったイメージです。
コールバックが終わっても、イベントループの次の周でまた emit()
が実行され、それを繰り返します。
ともかく、 emit()
によるループのブロックは防げていることがわかります。
が、しかしこのコードには注意すべき点が二つあります。
一つ目は、同じ JS でもブラウザでは使えないこと。
二つ目は、 process.nextTick()
が実行されるタイミングです。
process.nextTick() が無い場合
ブラウザは、イベントループがあるにもかかわらず process.nextTick()
相当の API が無いため、イベントループの次の周に処理を登録するには、 setTimeout()
を使っていました。
setTimeout()
はそもそも、イベントループに対して処理を登録し、ループが回るたびに「指定された時間が経過していたら実行」するための API です。
process.on('count', function(c) { if (c > 10) { return; } console.log(c); setTimeout(function() { process.emit('count', ++c); }, 0); }); process.emit('count', 0); // 0 1 2 3 4 5 6 7 8 9 10
したがって、 setTimeout(fn, 0)
のように、 0
秒後にタイムアウトするようにすると、イベントループに登録し、次の周では指定時間が経過していると評価されて、コールバックが実行されます。
これで同様のことが実現でき、ブラウザにはかつてこの方法しかありませんでした。
setImmediate()
しかし、イベントループの次の周に登録することと、 0
秒後に登録するのでは、本質的な意味が違います。また、内部的にも実行されるタイミングが微妙に違います。そこでこの setTimeout(fn, 0)
でやりたかったことを標準の API にしたのが setImmediate()
です。
この API は現時点では、特定のブラウザ と Node.js の v0.9 以降で使用することができます。
process.on('count', function(c) { if (c > 10) { return; } console.log(c); setImmediate(function() { process.emit('count', ++c); }); }); process.emit('count', 0); // 0 1 2 3 4 5 6 7 8 9 10
これで、少なくともこの目的であれば setTimeout(fn, 0)
よりは、対応しているのであれば setImmediate()
を使うことが推奨されます。
では setImmediate()
と process.nextTick()
は Node 上では何が違うのでしょうか?
イベントループ上での実行タイミング
さて、同じようなことを行う API を二つ紹介しましたが、 Node の JS ランタイムの実装である V8 では、どちらもイベントループ上で実行されるタイミングが微妙に違います。
この辺については、 Object.observe()とNode.jsのイベントループの関係 - ぼちぼち日記 で詳細に解説されています。 エントリ中の図を引用します。
この図からわかるこの二つの違いは、大きくは I/O イベントの前後どちらで実行されるか、という点です。
process.nextTick()
: I/O コールバックの前setImmediate()
: I/O コールバックの後
これは、最初の大きなファイルからの読み込みと組み合わせて書いてみるとよくわかります。
まずは process.nextTick()
を使った数え上げの後に、ファイルの読み込み(I/O)を書いた例です。
実行すると、数え上げが終わってからやっと I/O が始まっているのがわかります。
これは process.nextTick()
が I/O よりも前に再帰展開されるからであり、その再帰が終わるまでイベントループがブロックされるていることを意味します。
var fs = require('fs'); var random = fs.createReadStream('./bigfile'); // I/O read var c = 1; random.on('data', function(chunk) { console.log('chunk'); c++; if (c > 10) { return random.pause(); } }); process.on('count', function(d) { if (d > 10) { return; } console.log(d); process.nextTick(function() { process.emit('count', ++d); }); }); process.nextTick(function() { process.emit('count', 0); }); // 0 1 2 3 4 5 6 7 8 9 10 chunk chunk chunk ...
同じコードを setImmediate()
に一カ所だけ書き換えてみます。
すると、 I/O 処理の後に数え上げ処理が実行されるため、両者がおおむね順番に実行されていることがわかります。
(ただし、 I/O の制御はカーネルが行っているため、イベントループ中毎回必ずデータが読まれるとは限らないため、交互とはならないことがあります。)
var fs = require('fs'); var random = fs.createReadStream('./bigfile'); // I/O read var c = 1; random.on('data', function(chunk) { console.log('chunk'); c++; if (c > 10) { return random.pause(); } }); process.on('count', function(d) { if (d > 10) { return; } console.log(d); setImmediate(function() { process.emit('count', ++d); }); }); setImmediate(function() { process.emit('count', 0); }); // 0 chunk 1 chunk 2 chunk 3 chunk 4 5 chunk...
そして、これは実は再帰じゃありません。イメージとしては、while
ループの処理を動的にオン/オフしているイメージでしょうか?
イベントループハラスメント
先ほどの process.nextTick()
の再帰のように同期処理によってイベントループを止めるというのは、その後の I/O 処理までループが到達しなくなってしまうことを意味します。
これは、例えば再帰でフィボナッチ数列を出すような処理を Node で行うと、そこでイベントループが止まり、非同期 I/O を止めてしまうという意味であり、こうしたイベントループを止めてしまうような処理を「イベントループハラスメント」と言った人もいました。
ネットワーク系、特に Web 系のプログムでは、 I/O 周りがボトルネックになることが多いことから、この点を解決するために非同期 I/O を採用し、イベント駆動にするためにイベントループを回している Node にとって、そのループを止めるのは全てを台無しにする行為なので、そこを理解した上でイベントループを止めないようにするのが重要です。
そして、その一つのイベントループ、正確にはシングルスレッドで回っている while
ループの上に処理を登録して、ループごとに完了した I/O を処理することができるのが Node のメリットであり、それが重たい I/O の待ち時間を無駄なく使うことができるアーキテクチャだったため、たくさんのスレッドを立ててロックを取り合い処理するモデルよりもシンプルで省メモリだったのが Node が他のランタイムと違ったところです。
イベントが発火した「後」に処理を登録するためには、確かにコールバックが必要です。コールバックが無いモデルを好む気持ちはわかりますが、イベント駆動でない限り、多くのランタイムは自分でこのイベントループ相当の while
を回し、そこにフラグと処理を書くことになるとは思います。それたぶん Node がやってくれてることかもしれません。
(イベントループを止めずに、一周に一回フィボナッチを計算するという処理を setImmediate()
で書くこともできます、やってみてください。)
for を使うなイベントループを使え
JavaScript では、あなたが for
を回さなくても、イベントループはいつでもそこで回っています。
それを止めないように、それに乗っかるように、あなたが書こうとした for
が本当に必要か考えてみてはいかがでしょう?