Node.jsのイベントループは、同期コードが終わった後に、次にどのコールバックを実行するかを決めるループです。決まった順番でフェーズを回ります。timers(setTimeout、setInterval)、pending callbacks、poll(ファイルの読み込みやネットワークのデータといったI/O)、check(setImmediate)、close callbacksです。Node.jsはコールバックを1つ実行するたびに、まず process.nextTick のキューを空にし、次にPromiseのキューを空にします。そのため、これらはループが先に進む前に必ず実行されます。
sync、nextTick、promise、setTimeout と、書いた順番とは逆に表示されます。同期コードが必ず最初に終わり、次にNode.jsがnextTickのキュー、続いてPromiseのキューを空にし、そこで初めてループが始まってtimersフェーズに到達します。
イベントループのフェーズ
Node.jsのループは、Node.jsがI/Oに使うCのライブラリ、libuvから来ています。ループは最初の周の前に、すでに時間が来たタイマーを実行し、そのあと1周ごとに次のフェーズを順に実行します。
- Pending callbacks:一部のネットワークエラーのように、前の周から持ち越された少数のシステムコールバック。
- Poll:新しいI/Oイベントとそのコールバック(ファイルの読み込みが終わった、ソケットにデータが届いた)。ほかに予定がなければ、ループはここで待ちます。
- Check:
setImmediateのコールバック。 - Close callbacks:破棄されたソケットのような
'close'イベント。 - Timers:時間が来た
setTimeoutとsetIntervalのコールバック。
多くの解説ではtimersが毎周の最初に書かれていますが、これはバージョン20(libuv 1.45)より前のNode.jsの動き方です。どちらの説明でもコールバックが動く順番は同じで、ある周のtimersのあとには次の周のpollが来ます。
フェーズの外に2つのキューがあります。nextTickのキューと、マイクロタスクのキュー(Promiseのコールバックと queueMicrotask)です。Node.jsはどのフェーズでも、コールバックを1つ実行するたびにこの2つをnextTickから順に空にします。待つもの(タイマー、開いたファイルやソケット、サーバー)がある間はループが回り続け、何もなくなるとプロセスは終了します。
これはNode.js独自のループです。ブラウザもタスクとマイクロタスクという考え方は共通ですが、フェーズも setImmediate も process.nextTick もありません。言語レベルの全体像はJavaScriptのイベントループのページにあります。
process.nextTickとPromise
どちらもループが先に進む前に実行されますが、混ざることはありません。Node.jsはnextTickのキューを、実行中に追加されたtickも含めてすべて実行し、その後でPromiseのキューに移ります。次の結果を予想してみてください。
順番は tick 1、tick 2、tick from tick、promise 1、promise 2 です。tickの中で予約したtickも同じキューに入り、Promiseのほうが先に作られていても、どのPromiseよりも先に実行されます。
ESモジュール(import を使うファイル)のトップレベルでは、この2つが入れ替わります。モジュールのコード自体がPromiseのジョブの中で動くので、Node.jsがnextTickのキューに取りかかる前に、そのPromiseのコールバックが終わってしまうのです。コールバックの中では、どちらのモジュール方式でもnextTickが先というルールが成り立ちます。
setImmediateとsetTimeout(fn, 0)
setTimeout(fn, 0) の本当の意味は「少なくとも1ミリ秒後、timersフェーズで」です。setImmediate(fn) は「次のcheckフェーズで」という意味です。どちらが先に動くかは、どこで呼ぶかによります。
I/Oのコールバックの中では順番が決まっています。コールバックはpollフェーズで動き、その直後にcheckフェーズが来て、timersフェーズはそのあとに来るからです。
毎回 nextTick、setImmediate、setTimeout と表示されます。「今のI/Oの処理が終わったらこれを実行する」という用途には setImmediate が適しているということです。
メインモジュールの、どのコールバックの中でもない場所では、2つの順番は決まりません。
// Order depends on how fast the process started
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
ループが始まった時点で1ミリ秒以上たっていれば、タイマーの時間が来ているので先に表示されます。そうでなければ setImmediate が先です。どちらの結果にも依存するコードは書かないでください。
| 実行されるタイミング | 用途 | |
|---|---|---|
process.nextTick(fn) | 今の処理の直後、Promiseより前 | 呼び出し側がリスナーを登録した後にイベントやエラーを発行する |
Promise.then、queueMicrotask | nextTickのキューが空になった後 | 普通の非同期コード |
setImmediate(fn) | checkフェーズ、I/Oのコールバックの後 | 処理の区切りごとにループへ順番を譲る |
setTimeout(fn, 0) | timersフェーズ、少なくとも1ミリ秒後 | 正確なタイミングが重要でない遅延 |
イベントループをブロックする
JavaScriptのスレッドは1本なので、長時間動くコールバックはほかのすべてを止めます。タイマーも、I/Oのコールバックも、サーバーならほかのリクエストもすべてです。タイマーの遅延は最小値であって、保証ではありません。
先に loop finished が表示され、次に true が表示されます。タイマーは10ミリ秒後に時間が来ていましたが、ビジーループがスレッドを返すまで実行できませんでした。サーバーでルートハンドラーの中に同じループがあれば、ほかのリクエストが0.5秒間すべて止まります。
長い処理への対策は、処理を分割し、区切りごとに setImmediate でループに順番を回すことです。
タイマーはpart 1の後に割り込みます。setImmediate(() => work(part + 1)) を work(part + 1) に置き換えて実行し直すと、3つのpartが一気に実行され、timer は最後に表示されます。
画像処理のように最初から最後まで重い処理は、分割するだけでは足りません。worker_threads でメインスレッドから外に出しましょう。
よくある間違い
process.nextTick を再帰的に呼ぶ。 nextTickのコールバックが次々に新しいnextTickを予約すると、ループは先に進めず、タイマーやI/Oがいつまでも実行されません。繰り返しの処理には setImmediate を使います。
サーバーで同期APIを使う。 fs.readFileSync、crypto.pbkdf2Sync、大きな JSON.parse の呼び出しは、すべてのユーザーに対してループをブロックします。リクエストハンドラーの中では非同期版を使いましょう。
setTimeout(fn, 0) がすぐに動くと思う。 動くのは、すべての同期コード、すべてのnextTick、すべてのPromiseのコールバックが終わり、ループがtimersフェーズに到達してからです。
よくある質問
Node.jsはシングルスレッドですか?
あなたのJavaScriptは1つのスレッドで動くので、2つのコールバックが同時に実行されることはありません。Node.js自体はもっと多くのスレッドを使います。libuvはファイルシステムの処理、DNSの名前解決、一部の暗号処理のためにスレッドプール(デフォルトで4本)を持ち、結果をループに返します。
Node.jsのプログラムはいつ終了しますか?
イベントループに待つものが何もなくなったときです。タイマーも、処理中のI/Oも、開いているサーバーやソケットもない状態です。動いている setInterval や待ち受け中のサーバーは、クリアするか閉じるまでプロセスを生かし続けます。
Node.jsのイベントループはブラウザのものと同じですか?
1つのスレッドがキューに入ったコールバックを実行するという考え方は同じですが、細部は違います。ブラウザには setImmediate も process.nextTick もなく、タスクの合間に描画をはさみます。一方Node.jsはlibuvのフェーズを回します。言語レベルの共通部分はJavaScriptのイベントループのページで扱っています。
イベントループがブロックされているかどうかを知るには?
ループがどれだけ遅れて動いているかを測ります。perf_hooks の monitorEventLoopDelay() が遅延を記録し、平均が数十ミリ秒あれば、どこかのコールバックがスレッドを長くつかみすぎています。どの関数かを知るには --cpu-prof を付けてNode.jsを実行します。