初心者向けNode.jsイベントループ解説
Node.jsはシングルスレッドで、イベントループを使って何千もの同時接続を処理すると聞いたことがあるでしょう。しかし、それは実際にはどういう意味なのでしょうか?Node.jsを始めたばかりの方にとって、イベントループは謎のブラックボックスのように見えるかもしれません。この記事では、わかりやすい言葉で解説し、より良い、より高速なNode.jsコードを書けるようにします。
なぜイベントループが存在するのか
従来のWebサーバー(PHPを使ったApacheなど)は、リクエストごとに新しいスレッドまたはプロセスを作成します。それは機能しますが、同時接続が多い場合にはうまくスケールしません。各スレッドはメモリとCPUを消費します。Node.jsは異なるアプローチを取ります。JavaScriptを単一のスレッドで実行し、イベントループを使ってI/O操作を非同期に処理します。つまり、コードはデータベースクエリやファイル読み取りの完了を待たずに、他のタスクを実行し続け、結果が準備できたときに戻ってきます。
主要なコンポーネント
イベントループを詳しく見る前に、主要な登場人物を明確にしましょう。
- コールスタック: 同期的なJavaScriptコードが実行される場所です。関数は実行されるときにプッシュされ、ポップされます。
- Node API: ファイルI/O、ネットワークリクエスト、タイマーなどの高コストな操作を処理するC++ APIです。これらはバックグラウンドで(libuvスレッドプールを使って)実行されます。
- コールバックキュー: 完了したNode API操作からのコールバックが実行を待つ場所です。
- イベントループ: コールスタックが空かどうかを継続的にチェックし、空であればキューからスタックへコールバックを移動するオーケストレーターです。
イベントループの仕組み: 簡単な例
次のコードを考えてみましょう。
console.log('Start');
setTimeout(() => {
console.log('Timeout');
}, 0);
console.log('End');
出力は何でしょうか?Start、End、Timeoutと推測したなら、正解です。タイムアウトが0ミリ秒であっても、コールバックはすぐには実行されません。何が起こるかというと:
console.log('Start')がコールスタックにプッシュされ、実行され、ポップされます。setTimeoutが呼び出されます。Node.jsはタイマーを登録し、0ms後に期限切れになるように設定します。コールバックは保存され、関数は戻ります。console.log('End')がプッシュされ、実行され、ポップされます。- これでコールスタックは空です。イベントループはタイマーフェーズをチェックし、タイマーが期限切れになっていることを確認します。コールバックをコールスタックに移動し、
Timeoutがログに出力されます。
これはノンブロッキングの性質を示しています。タイマーは残りのコードをブロックしません。
イベントループのフェーズ
イベントループはいくつかのフェーズでコールバックを処理し、それぞれに特定の目的があります。これらのフェーズを理解することで、実行順序を予測できるようになります。
| フェーズ | 説明 |
|---|---|
| Timers | setTimeout()とsetInterval()でスケジュールされたコールバックを実行します。 |
| Pending Callbacks | 次のループイテレーションに延期されたI/Oコールバックを実行します。 |
| Idle, Prepare | Node.jsが内部的に使用します。 |
| Poll | 新しいI/Oイベントを取得し、I/O関連のコールバック(タイマー、setImmediate、クローズコールバックを除くほぼすべて)を実行します。 |
| Check | setImmediate()でスケジュールされたコールバックを実行します。 |
| Close Callbacks | クローズコールバック(例: socket.on('close', ...))を実行します。 |
各フェーズの間で、Node.jsはマイクロタスクをチェックします。process.nextTick()コールバックとPromiseです。これらは優先度が高く、現在の操作が完了した直後、次のフェーズに移る前に実行されます。
マイクロタスク: nextTickとPromise
マイクロタスクはイベントループのフェーズの一部ではありません。各フェーズの後、および各コールバックの後に処理されます。これにより、タイマーやI/Oコールバックよりも先に実行されます。例えば:
setTimeout(() => console.log('Timeout'), 0);
Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));
console.log('Sync');
出力: Sync、nextTick、Promise、Timeout。同期的なコードが最初に実行され、次にマイクロタスク(PromiseよりnextTickが先)、そしてタイマーです。
警告: 再帰的なprocess.nextTick()呼び出しはイベントループを枯渇させ、I/Oが実行されなくなる可能性があります。再帰的な操作にはsetImmediate()を使用してください。
setImmediate vs setTimeout
setImmediate()は、現在のポーリングフェーズが完了した直後にコールバックを実行するように設計されています。対照的に、0msのsetTimeout()は次のタイマーフェーズを待ちます。両者の順序はコンテキストによって異なる場合がありますが、I/Oコールバック内ではsetImmediate()が常に先に実行されます。
これがコードにとって重要な理由
イベントループを理解することで、よくある落とし穴を避けられます。
- イベントループをブロックしない:
fs.readFileSyncのような同期操作や重い計算はループ全体をブロックし、アプリが応答しなくなります。非同期版を使用するか、ワーカースレッドにオフロードしてください。 - マイクロタスクを賢く使う: マイクロタスクはI/Oより先に実行されるため、重いマイクロタスク処理はI/Oコールバックを遅延させる可能性があります。
- コールバックの順序を理解する: タイマー、Promise、I/Oを混在させる場合、フェーズを知ることで出力を予測できます。
実践例: ノンブロッキングなファイル読み取り
イベントループがノンブロッキングI/Oをどのように実現するかを示します。
const fs = require('fs');
console.log('Before read');
fs.readFile('large-file.txt', 'utf8', (err, data) => {
if (err) throw err;
console.log('File read complete');
});
console.log('After read');
出力: Before read、After read、次にFile read complete。ファイル読み取りはバックグラウンドで行われ、完了するとコールバックがキューに入ります。その間、他のコードを実行できます。
FAQ
Node.jsは本当にシングルスレッドですか?
Node.jsはJavaScriptを単一のスレッドで実行しますが、ファイルI/O、DNS、その他の操作のためにlibuvスレッドプールで複数のスレッドを使用します。したがって、内部的には完全にシングルスレッドではありません。
コールスタックとイベントループの違いは何ですか?
コールスタックは同期的なコードを実行します。イベントループは非同期コールバックを管理し、スタックが空のときにキューからコールスタックへ移動します。
複数のイベントループを作成できますか?
いいえ、各Node.jsプロセスには1つのイベントループがあります。ただし、ワーカースレッドを使用して別々のJavaScriptスレッドを実行でき、それぞれが独自のイベントループを持ちます。
パフォーマンス問題のデバッグでサーバーログを扱う場合は、Nginx Log Analyzerを試して、ログデータをすばやく解析・可視化してください。