Node.js イベントループの仕組みを徹底解説

Node.js を学習していると、「なぜこのコードは期待通りの順序で実行されないのか?」「なぜ Node.js はシングルスレッドなのに高速なのか?」といった疑問に遭遇することがありませんか?
これらの疑問を解決するカギとなるのが、Node.js の心臓部とも言える「イベントループ」です。イベントループは、Node.js が非同期処理を効率的に実行するための核となる仕組みであり、これを理解することで、Node.js アプリケーションのパフォーマンスを大幅に向上させることができるでしょう。
本記事では、イベントループの基本概念から、6 つのフェーズの詳細、実際のコード例まで、初心者の方にもわかりやすく解説していきます。複雑に思えるイベントループも、ステップバイステップで学んでいけば、きっと理解できるはずです。
イベントループとは何か
シングルスレッドでありながら高パフォーマンスを実現する仕組み
Node.js の最大の特徴の一つは、シングルスレッドでありながら高いパフォーマンスを実現していることです。これは一見矛盾しているように思えませんか?
一般的に、プログラムが複数の処理を同時に行うには、マルチスレッドという仕組みを使います。しかし、Node.js はメインスレッドが一つしかないにも関わらず、数千・数万のリクエストを同時に処理できるのです。
この魔法のような仕組みの正体が「イベントループ」なのです。
イベントループは、以下のような流れで動作します:
javascript// 例:ファイル読み込み処理
const fs = require('fs');
console.log('1. 処理開始');
fs.readFile('large-file.txt', (err, data) => {
console.log('3. ファイル読み込み完了');
});
console.log('2. 他の処理を継続');
// 出力順序:
// 1. 処理開始
// 2. 他の処理を継続
// 3. ファイル読み込み完了
上記のコードを見ると、ファイル読み込み処理がバックグラウンドで実行される間も、メインスレッドは他の処理を継続していることがわかります。これがノンブロッキング I/O の威力です。
他の言語との違い
従来のサーバーサイド言語(Java、PHP、Python など)との大きな違いを表で整理してみましょう。
# | 項目 | 従来の言語 | Node.js |
---|---|---|---|
1 | スレッドモデル | マルチスレッド | シングルスレッド + イベントループ |
2 | リクエスト処理 | 1 リクエスト = 1 スレッド | 1 つのスレッドで複数リクエスト |
3 | メモリ使用量 | 高い(スレッド毎にスタック領域) | 低い(スレッド生成コストなし) |
4 | コンテキストスイッチ | 頻繁に発生 | ほとんど発生しない |
5 | I/O 待機時の動作 | スレッドがブロック | 他の処理を継続 |
この違いにより、Node.js はC10K 問題(1 万個の同時接続を効率的に処理する問題)を elegant に解決しているのです。
イベントループの 6 つのフェーズ
イベントループは、決められた順序で 6 つのフェーズを循環しながら動作します。各フェーズには特定の役割があり、この順序を理解することが重要です。
javascript// イベントループの基本構造(疑似コード)
while (hasWork()) {
// 1. Timer phase
runTimers();
// 2. Pending callbacks phase
runPendingCallbacks();
// 3. Idle, prepare phase
runIdlePrepare();
// 4. Poll phase
runPoll();
// 5. Check phase
runCheck();
// 6. Close callbacks phase
runCloseCallbacks();
}
それぞれのフェーズを詳しく見ていきましょう。
Timer phase(タイマーフェーズ)
このフェーズでは、setTimeout()
とsetInterval()
で登録されたコールバック関数が実行されます。
javascriptconsole.log('開始');
setTimeout(() => {
console.log('Timer phase: setTimeout実行');
}, 0);
setInterval(() => {
console.log('Timer phase: setInterval実行');
}, 100);
console.log('同期処理完了');
注意点として、setTimeout(callback, 0)
であっても、即座に実行されるわけではありません。必ず Timer フェーズまで待つ必要があります。
Pending callbacks phase(保留中コールバックフェーズ)
前回のイベントループで延期された I/O コールバックが実行されるフェーズです。通常、開発者が直接意識することは少ないフェーズですが、システムレベルのエラーハンドリングなどで使用されます。
Idle, prepare phase(アイドル・準備フェーズ)
Node.js 内部でのみ使用されるフェーズです。開発者のコードが直接実行されることはありませんが、次の Poll フェーズの準備が行われます。
Poll phase(ポーリングフェーズ)
最も重要なフェーズの一つです。新しい I/O イベントを取得し、I/O 関連のコールバックを実行します。
javascriptconst fs = require('fs');
console.log('1. 同期処理開始');
// Poll phaseで処理される
fs.readFile('./example.txt', (err, data) => {
console.log('3. Poll phase: ファイル読み込み完了');
});
// Poll phaseで処理される
require('http').get('http://example.com', (res) => {
console.log('4. Poll phase: HTTP リクエスト完了');
});
console.log('2. 同期処理完了');
Poll フェーズは特別で、待機可能なフェーズです。処理すべきイベントがない場合、一定時間待機することがあります。
Check phase(チェックフェーズ)
setImmediate()
で登録されたコールバックが実行されるフェーズです。
javascriptconsole.log('1. 開始');
setImmediate(() => {
console.log('3. Check phase: setImmediate実行');
});
console.log('2. 同期処理完了');
Close callbacks phase(クローズコールバックフェーズ)
ソケットやハンドルのクローズイベントが処理されるフェーズです。
javascriptconst net = require('net');
const server = net.createServer();
server.on('close', () => {
console.log('Close callbacks phase: サーバークローズ');
});
server.close();
各フェーズの詳細動作
フェーズ実行順序の可視化
実際のコード例で、各フェーズの実行順序を確認してみましょう。
javascriptconst fs = require('fs');
console.log('=== 同期処理開始 ===');
// Timer phase
setTimeout(() => console.log('Timer: setTimeout'), 0);
// Check phase
setImmediate(() => console.log('Check: setImmediate'));
// Poll phase
fs.readFile(__filename, () => {
console.log('Poll: ファイル読み込み');
// ファイル読み込み完了後のコールバック内での実行順序
setTimeout(
() => console.log('Timer: ネストしたsetTimeout'),
0
);
setImmediate(() =>
console.log('Check: ネストしたsetImmediate')
);
});
console.log('=== 同期処理完了 ===');
実行結果は環境によって変わる場合がありますが、概ね以下のような順序になります:
makefile=== 同期処理開始 ===
=== 同期処理完了 ===
Timer: setTimeout
Check: setImmediate
Poll: ファイル読み込み
Check: ネストしたsetImmediate
Timer: ネストしたsetTimeout
マイクロタスクの優先順位
各フェーズの間には、マイクロタスクというより高い優先順位のタスクが挿入されます。
javascriptconsole.log('1. 同期処理開始');
// マクロタスク(Timer phase)
setTimeout(() => console.log('4. Timer: setTimeout'), 0);
// マイクロタスク
Promise.resolve().then(() =>
console.log('3. Microtask: Promise')
);
// 最高優先度のマイクロタスク
process.nextTick(() =>
console.log('2. Microtask: nextTick')
);
console.log('1. 同期処理完了');
実行結果:
markdown1. 同期処理開始
1. 同期処理完了
2. Microtask: nextTick
3. Microtask: Promise
4. Timer: setTimeout
マイクロタスクの優先順位は以下の通りです:
# | タスクタイプ | 優先順位 | 実行タイミング |
---|---|---|---|
1 | process.nextTick() | 最高 | 各フェーズの最初 |
2 | Promise.then() | 高 | nextTick 後 |
3 | queueMicrotask() | 高 | Promise.then()と同等 |
4 | フェーズのコールバック | 通常 | 対応するフェーズ内 |
イベントループと非同期処理の関係
Promise、async/await、setTimeout の実行タイミング
現代の JavaScript 開発では、Promise、async/await、そして従来の setTimeout を組み合わせて使用することが一般的です。これらの実行タイミングを正確に理解することで、予期しないバグを防げます。
javascriptasync function demonstrateExecutionOrder() {
console.log('1. 関数開始');
// Timer phase(マクロタスク)
setTimeout(() => console.log('6. Timer: setTimeout'), 0);
// Check phase(マクロタスク)
setImmediate(() => console.log('7. Check: setImmediate'));
// マイクロタスク
Promise.resolve().then(() =>
console.log('4. Microtask: Promise.then')
);
// 最高優先度マイクロタスク
process.nextTick(() =>
console.log('3. Microtask: process.nextTick')
);
// async/await(内部的にはPromise)
const result = await Promise.resolve('async結果');
console.log('5. Async/await:', result);
console.log('2. 関数終了');
}
demonstrateExecutionOrder();
複雑な非同期処理の制御
実際の開発では、複数の非同期処理を組み合わせる場面が多くあります。
javascriptconst fs = require('fs').promises;
async function complexAsyncFlow() {
console.log('=== 複雑な非同期フロー開始 ===');
try {
// 複数のファイルを並行して読み込み
const [file1, file2, file3] = await Promise.all([
fs.readFile('./file1.txt', 'utf8'),
fs.readFile('./file2.txt', 'utf8'),
fs.readFile('./file3.txt', 'utf8'),
]);
console.log('すべてのファイル読み込み完了');
// 順次処理が必要な場合
for (const file of [file1, file2, file3]) {
await processFile(file);
}
} catch (error) {
console.error('エラーが発生しました:', error.message);
}
console.log('=== フロー完了 ===');
}
async function processFile(content) {
return new Promise((resolve) => {
// 重い処理をsetImmediateで分割
setImmediate(() => {
console.log(
`ファイル処理完了: ${content.length}文字`
);
resolve();
});
});
}
イベントループをブロックしない設計
CPU 集約的な処理は、イベントループをブロックしてしまう危険性があります。
javascript// ❌ 悪い例:イベントループをブロック
function heavyComputation(n) {
console.log('重い計算開始');
let result = 0;
for (let i = 0; i < n; i++) {
result += Math.sqrt(i);
}
console.log('重い計算完了');
return result;
}
// ✅ 良い例:setImmediateで分割
function heavyComputationAsync(n, chunkSize = 100000) {
return new Promise((resolve) => {
let result = 0;
let i = 0;
function processChunk() {
const endIndex = Math.min(i + chunkSize, n);
for (; i < endIndex; i++) {
result += Math.sqrt(i);
}
if (i < n) {
// 他の処理に制御を譲る
setImmediate(processChunk);
} else {
console.log('分割処理完了');
resolve(result);
}
}
processChunk();
});
}
// 使用例
heavyComputationAsync(1000000).then((result) => {
console.log('結果:', result);
});
エラーハンドリングとイベントループ
非同期処理でのエラーハンドリングは、イベントループの理解が重要です。
javascript// 未キャッチエラーの監視
process.on('uncaughtException', (error) => {
console.error('未キャッチ例外:', error.message);
// 本番環境では通常、プロセスを終了させる
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('未処理のPromise拒否:', reason);
console.error('Promise:', promise);
});
// 適切なエラーハンドリングの例
async function safeAsyncOperation() {
try {
const result = await riskyOperation();
return result;
} catch (error) {
// ログ記録
console.error('操作に失敗しました:', error.message);
// 適切な後処理
await cleanup();
// エラーを再スロー(必要に応じて)
throw new Error(`処理に失敗: ${error.message}`);
}
}
async function riskyOperation() {
// リスクのある非同期処理
return new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.5) {
resolve('成功');
} else {
reject(new Error('ランダムエラー'));
}
}, 100);
});
}
async function cleanup() {
console.log('クリーンアップ処理実行');
}
まとめ
Node.js のイベントループは、一見複雑に思えますが、その動作原理を理解することで、高性能で安定したアプリケーションを開発できるようになります。
本記事で学んだ重要なポイントを振り返ってみましょう:
イベントループの基本原理
- シングルスレッドでありながらノンブロッキング I/O により高性能を実現
- 6 つのフェーズを循環しながら異なるタイプのタスクを処理
- マイクロタスクは各フェーズよりも高い優先順位を持つ
実践的な開発指針
- CPU 集約的な処理は
setImmediate()
で分割し、イベントループをブロックしない Promise.all()
を活用して並行処理を効率的に実行- 適切なエラーハンドリングで堅牢性を確保
パフォーマンス最適化
process.nextTick()
の過度な使用は避ける- 重い同期処理は非同期で分割する
- I/O 操作は可能な限りバッチ処理する
イベントループの理解は、Node.js 開発者にとって必須のスキルです。この知識を武器に、より効率的で安定したアプリケーションを開発していってください。継続的な学習と実践を通じて、Node.js マスターへの道のりを歩んでいきましょう!
関連リンク
- Node.js 公式ドキュメント - イベントループ
- libuv 公式サイト - Node.js のイベントループを実装するライブラリ
- V8 JavaScript エンジン - Node.js で使用されている JavaScript エンジン
- Node.js Best Practices - Node.js 開発のベストプラクティス集
- MDN Web Docs - イベントループ - ブラウザ環境でのイベントループ解説
- review
チーム開発が劇的に変わった!『リーダブルコード』Dustin Boswell & Trevor Foucher
- review
アジャイル初心者でも大丈夫!『アジャイルサムライ − 達人開発者への道』Jonathan Rasmusson
- review
人生が作品になる!『自分の中に毒を持て』岡本太郎
- review
体調不良の 99%が解決!『眠れなくなるほど面白い 図解 自律神経の話』小林弘幸著で学ぶ、現代人必須の自律神経コントロール術と人生を変える健康革命
- review
衝撃の事実!『睡眠こそ最強の解決策である』マシュー・ウォーカー著が明かす、99%の人が知らない睡眠の驚くべき真実と人生を変える科学的メカニズム
- review
人生が激変!『嫌われる勇気』岸見一郎・古賀史健著から学ぶ、アドラー心理学で手に入れる真の幸福と自己実現