Node.jsを使用して非同期処理を書いていると、「なぜこの順番で実行されるのか」と疑問に感じることがあります。setTimeoutとsetImmediateのどちらが先に実行されるのか、process.nextTick()は他の非同期処理とどう違うのか。これらの疑問を解決するには、Node.jsのイベントループを深く理解する必要があります。
本記事では、イベントループの各フェーズの役割と、非同期処理の実行順序を正確に予測する方法を解説します。
実行環境#
| 項目 |
バージョン |
| Node.js |
20.x LTS以上 |
| npm |
10.x以上 |
| OS |
Windows/macOS/Linux |
前提条件#
- JavaScriptの基礎知識(コールバック、Promise、async/await)
- Node.jsの基本操作経験
- コマンドラインの基本操作
イベントループの全体像#
イベントループは、Node.jsがシングルスレッドでありながらノンブロッキングI/O操作を実現する仕組みです。I/O操作をOSカーネルにオフロードすることで、メインスレッドをブロックせずに複数の処理を効率的に実行します。
イベントループのフェーズ構成#
イベントループは6つのフェーズで構成されており、各フェーズは特定の種類のコールバックを処理します。
┌───────────────────────────┐
┌─>│ timers │ ← setTimeout, setInterval
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ ← 一部のシステムI/Oコールバック
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ ← 内部処理用
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │ ← setImmediate
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │ ← socket.on('close')など
└───────────────────────────┘
各フェーズはFIFO(先入れ先出し)キューを持ち、そのキュー内のコールバックを順次実行します。キューが空になるか、システム依存の上限に達すると、次のフェーズに移行します。
フェーズ間の遷移とマイクロタスク#
重要なのは、フェーズ間の遷移時にマイクロタスクキューが処理されることです。これにより、Promiseの.then()やprocess.nextTick()のコールバックが、次のフェーズに進む前に実行されます。
graph TD
A[現在のフェーズ] --> B{マイクロタスク<br/>キューにタスクあり?}
B -->|Yes| C[process.nextTickキュー処理]
C --> D[Promiseマイクロタスク処理]
D --> B
B -->|No| E[次のフェーズへ]各フェーズの詳細#
timersフェーズ#
setTimeout()とsetInterval()でスケジュールされたコールバックを実行します。ここで重要なのは、タイマーは最小遅延時間を指定するものであり、正確な実行時刻を保証するものではないということです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// timers-example.js
const start = Date.now();
setTimeout(() => {
console.log(`setTimeout: ${Date.now() - start}ms後に実行`);
}, 100);
// 重い同期処理(例:150ms)
const end = start + 150;
while (Date.now() < end) {
// ブロッキング処理
}
console.log('同期処理完了');
|
実行結果は以下のようになります。
1
2
3
4
|
node timers-example.js
# 出力:
# 同期処理完了
# setTimeout: 150ms後に実行(100msではなく150ms以上)
|
タイマーは100msで設定されていますが、同期処理が150ms続くため、実際の実行は150ms以上後になります。イベントループはコールスタックが空になるまで、timersフェーズのコールバックを実行できません。
pending callbacksフェーズ#
前回のループで延期されたI/Oコールバックを実行します。例えば、TCPソケットがECONNREFUSEDエラーを受け取った場合など、一部のシステム操作のコールバックがこのフェーズで処理されます。
通常のアプリケーション開発では、このフェーズを意識することは少ないですが、ネットワークエラー処理のタイミングに影響することがあります。
pollフェーズ#
pollフェーズは、イベントループの中心的な役割を担います。主に2つの機能があります。
- I/Oイベントのポーリング: 新しいI/Oイベントを取得し、I/O関連のコールバックを実行
- ブロッキング時間の計算: 他のフェーズにタスクがなければ、新しいイベントを待機
pollフェーズの動作は以下のフローで表現できます。
graph TD
A[pollフェーズ開始] --> B{pollキューは空?}
B -->|No| C[キュー内のコールバックを実行]
C --> B
B -->|Yes| D{setImmediateあり?}
D -->|Yes| E[checkフェーズへ移行]
D -->|No| F{タイマーの閾値に達した?}
F -->|Yes| G[timersフェーズへ移行]
F -->|No| H[新しいI/Oイベントを待機]
H --> Bcheckフェーズ#
setImmediate()でスケジュールされたコールバックを実行します。pollフェーズが完了した直後に実行されるため、I/Oコールバック内でsetImmediate()を使用すると、常にsetTimeout(..., 0)より先に実行されます。
close callbacksフェーズ#
ソケットやハンドルが突然閉じられた場合(例:socket.destroy())、'close'イベントがこのフェーズで発行されます。
process.nextTick()の仕組み#
process.nextTick()は、イベントループのフェーズ図には含まれていません。これは、process.nextTick()がイベントループの一部ではなく、現在の操作が完了した直後に実行される特別な仕組みだからです。
nextTickQueueの優先順位#
process.nextTick()のコールバックは、現在のフェーズが完了した後、次のフェーズに移行する前に実行されます。これにより、他のどの非同期処理よりも優先的に実行されます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// nexttick-priority.js
console.log('1: スクリプト開始');
setTimeout(() => {
console.log('5: setTimeout');
}, 0);
setImmediate(() => {
console.log('6: setImmediate');
});
Promise.resolve().then(() => {
console.log('4: Promise.then');
});
process.nextTick(() => {
console.log('3: process.nextTick');
});
console.log('2: スクリプト終了');
|
実行結果は以下のようになります。
1
2
3
4
5
6
7
8
|
node nexttick-priority.js
# 出力:
# 1: スクリプト開始
# 2: スクリプト終了
# 3: process.nextTick
# 4: Promise.then
# 5: setTimeout
# 6: setImmediate
|
この結果から、実行順序は以下のようになることがわかります。
- 同期コード(コールスタック内)
process.nextTick()(nextTickQueue)
Promise.then()(マイクロタスクキュー)
setTimeout()(timersフェーズ)
setImmediate()(checkフェーズ)
process.nextTick()の使用場面#
process.nextTick()は以下の場面で有効です。
1. APIの一貫性を保つ
同期的に結果が判明する場合でも、コールバックを非同期で実行することで、APIの動作を一貫させます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
function validateAndProcess(data, callback) {
// バリデーションエラーは同期的に判明
if (!data) {
// process.nextTick()を使用して非同期で返す
return process.nextTick(() => {
callback(new Error('データが必要です'));
});
}
// 正常処理は非同期
setTimeout(() => {
callback(null, `処理完了: ${data}`);
}, 100);
}
// 使用例
validateAndProcess(null, (err, result) => {
if (err) {
console.error('エラー:', err.message);
return;
}
console.log(result);
});
console.log('呼び出し後の処理');
|
2. イベントハンドラの設定を待つ
EventEmitterのコンストラクタ内でイベントを発行する場合、ハンドラが設定される前にイベントが発行されてしまう問題を解決します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
const EventEmitter = require('node:events');
class MyEmitter extends EventEmitter {
constructor() {
super();
// nextTickを使用してハンドラ設定を待つ
process.nextTick(() => {
this.emit('ready');
});
}
}
const emitter = new MyEmitter();
emitter.on('ready', () => {
console.log('readyイベントを受信');
});
|
再帰的なprocess.nextTick()の危険性#
process.nextTick()を再帰的に呼び出すと、イベントループがpollフェーズに到達できなくなり、I/O処理が「餓死」状態になります。
1
2
3
4
5
6
7
|
// 危険な例(実行しないでください)
function recursiveNextTick() {
process.nextTick(recursiveNextTick);
}
// これを実行するとI/Oがブロックされる
// recursiveNextTick();
|
この問題を避けるため、再帰的な非同期処理にはsetImmediate()を使用することが推奨されています。
setImmediate()とsetTimeout(..., 0)は似ていますが、実行されるタイミングが異なります。
メインモジュールでの実行順序#
メインモジュール(I/Oサイクル外)で両方を呼び出した場合、実行順序は不確定です。
1
2
3
4
5
6
7
8
|
// main-module-timing.js
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
|
複数回実行すると、結果が変わることがあります。
1
2
3
|
node main-module-timing.js
# 1回目: setTimeout → setImmediate
# 2回目: setImmediate → setTimeout
|
これは、スクリプトの起動時にイベントループがどのフェーズにいるかが、システムのパフォーマンスに依存するためです。
I/Oコールバック内での実行順序#
I/Oコールバック内では、setImmediate()が常にsetTimeout()より先に実行されます。
1
2
3
4
5
6
7
8
9
10
11
12
|
// io-callback-timing.js
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
});
|
実行結果は常に同じです。
1
2
3
4
|
node io-callback-timing.js
# 出力:
# setImmediate
# setTimeout
|
これは、I/Oコールバックがpollフェーズで実行され、その直後にcheckフェーズ(setImmediate())が来るためです。timersフェーズに戻るのは、イベントループが一周した後になります。
使い分けの指針#
| 状況 |
推奨 |
理由 |
| 一般的な非同期処理 |
setImmediate() |
動作が予測しやすい |
| 一定時間後の実行 |
setTimeout() |
時間指定が可能 |
| 現在の操作完了直後 |
process.nextTick() |
最優先で実行される |
| 再帰的な非同期処理 |
setImmediate() |
I/Oを餓死させない |
マイクロタスクキューとマクロタスクキュー#
Node.jsの非同期処理は、2種類のキューで管理されます。
キューの種類と優先順位#
graph TD
subgraph マイクロタスクキュー
A[nextTickQueue<br/>process.nextTick]
B[Promiseキュー<br/>Promise.then/catch/finally]
end
subgraph マクロタスクキュー
C[timers<br/>setTimeout/setInterval]
D[check<br/>setImmediate]
E[その他のフェーズ]
end
A --> B
B --> C
C --> D
D --> E実行順序のルールは以下の通りです。
- コールスタックが空になるまで同期コードを実行
- nextTickQueueのすべてのコールバックを実行
- Promiseマイクロタスクキューのすべてのコールバックを実行
- マクロタスクキューから1つのコールバックを実行
- 2に戻る
実行順序の予測練習#
以下のコードの実行順序を予測してみましょう。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
// execution-order-quiz.js
console.log('1: 同期処理開始');
setTimeout(() => {
console.log('7: setTimeout 1');
Promise.resolve().then(() => {
console.log('8: setTimeout内のPromise');
});
}, 0);
setImmediate(() => {
console.log('9: setImmediate');
});
Promise.resolve()
.then(() => {
console.log('4: Promise 1');
process.nextTick(() => {
console.log('6: Promise内のnextTick');
});
return Promise.resolve();
})
.then(() => {
console.log('5: Promise 2');
});
process.nextTick(() => {
console.log('3: nextTick');
});
console.log('2: 同期処理終了');
|
実行結果は以下の通りです。
1
2
3
4
5
6
7
8
9
10
11
|
node execution-order-quiz.js
# 出力:
# 1: 同期処理開始
# 2: 同期処理終了
# 3: nextTick
# 4: Promise 1
# 5: Promise 2
# 6: Promise内のnextTick
# 7: setTimeout 1
# 8: setTimeout内のPromise
# 9: setImmediate
|
実行順序の解説は以下の通りです。
| 順番 |
出力 |
理由 |
| 1-2 |
同期処理 |
コールスタックで即座に実行 |
| 3 |
nextTick |
同期処理完了後、最初にnextTickQueueを処理 |
| 4-5 |
Promise 1, 2 |
Promiseマイクロタスクキューを処理 |
| 6 |
Promise内のnextTick |
Promiseの処理中に追加されたnextTickを処理 |
| 7 |
setTimeout 1 |
timersフェーズのコールバック |
| 8 |
setTimeout内のPromise |
setTimeout完了後のマイクロタスク |
| 9 |
setImmediate |
checkフェーズのコールバック |
実践的な実験コード#
イベントループの動作を理解するための実験コードを紹介します。
実験1: フェーズの確認#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
// phase-experiment.js
const fs = require('node:fs');
// 各フェーズでの実行を確認
console.log('=== フェーズ実験開始 ===');
// timersフェーズ
setTimeout(() => {
console.log('[timers] setTimeout');
}, 0);
// checkフェーズ
setImmediate(() => {
console.log('[check] setImmediate');
});
// pollフェーズ(I/Oコールバック)
fs.readFile(__filename, () => {
console.log('[poll] fs.readFile callback');
// I/Oコールバック内でのタイマー比較
setTimeout(() => {
console.log('[timers] I/O内のsetTimeout');
}, 0);
setImmediate(() => {
console.log('[check] I/O内のsetImmediate');
});
process.nextTick(() => {
console.log('[nextTick] I/O内のnextTick');
});
});
// マイクロタスク
process.nextTick(() => {
console.log('[nextTick] トップレベルのnextTick');
});
Promise.resolve().then(() => {
console.log('[microtask] Promise.then');
});
console.log('=== 同期処理完了 ===');
|
実行結果の例は以下の通りです。
1
2
3
4
5
6
7
8
9
10
11
12
|
node phase-experiment.js
# 出力:
# === フェーズ実験開始 ===
# === 同期処理完了 ===
# [nextTick] トップレベルのnextTick
# [microtask] Promise.then
# [timers] setTimeout
# [check] setImmediate
# [poll] fs.readFile callback
# [nextTick] I/O内のnextTick
# [check] I/O内のsetImmediate
# [timers] I/O内のsetTimeout
|
実験2: マイクロタスクの優先順位#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
// microtask-priority.js
Promise.resolve()
.then(() => {
console.log('Promise 1');
process.nextTick(() => {
console.log('nextTick in Promise 1');
});
Promise.resolve().then(() => {
console.log('Promise 1 - nested');
});
})
.then(() => {
console.log('Promise 2');
});
process.nextTick(() => {
console.log('nextTick 1');
process.nextTick(() => {
console.log('nextTick 1 - nested');
});
});
process.nextTick(() => {
console.log('nextTick 2');
});
console.log('同期処理');
|
実行結果は以下の通りです。
1
2
3
4
5
6
7
8
9
10
|
node microtask-priority.js
# 出力:
# 同期処理
# nextTick 1
# nextTick 2
# nextTick 1 - nested
# Promise 1
# Promise 1 - nested
# Promise 2
# nextTick in Promise 1
|
この結果から、nextTickQueueはすべて処理されてからPromiseキューに移行し、Promiseの処理中に追加されたnextTickは次のマイクロタスク処理で実行されることがわかります。
実験3: async/awaitとイベントループ#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// async-await-eventloop.js
async function asyncFunc() {
console.log('async関数開始');
await Promise.resolve();
console.log('await後 1');
await Promise.resolve();
console.log('await後 2');
}
console.log('1: スクリプト開始');
asyncFunc();
Promise.resolve().then(() => {
console.log('Promise.then 1');
}).then(() => {
console.log('Promise.then 2');
});
console.log('2: スクリプト終了');
|
実行結果は以下の通りです。
1
2
3
4
5
6
7
8
9
|
node async-await-eventloop.js
# 出力:
# 1: スクリプト開始
# async関数開始
# 2: スクリプト終了
# await後 1
# Promise.then 1
# await後 2
# Promise.then 2
|
awaitはPromiseの.then()と同様にマイクロタスクキューを使用するため、交互に実行されています。
libuv 1.45.0以降の変更点#
Node.js 20から使用されているlibuv 1.45.0では、タイマーの処理タイミングが変更されました。以前はpollフェーズの前後でタイマーをチェックしていましたが、現在はpollフェーズの後のみでチェックします。
この変更により、特定のシナリオでsetImmediate()とタイマーの相互作用に影響が出る可能性があります。ただし、多くのアプリケーションでは影響は軽微です。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// libuv-change-example.js
const fs = require('node:fs');
// pollフェーズでI/Oを待機中にタイマーが発火するケース
setTimeout(() => {
console.log('タイマー発火');
}, 50);
fs.readFile(__filename, () => {
console.log('ファイル読み込み完了');
});
// Node.js 20以降では、pollフェーズ完了後にタイマーがチェックされる
|
イベントループのベストプラクティス#
1. process.nextTick()の使用を最小限に#
process.nextTick()は強力ですが、過度な使用はI/Oを餓死させる原因になります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
// 推奨されない使い方
function processItems(items, callback) {
if (items.length === 0) {
return process.nextTick(callback);
}
// 大量のアイテムをnextTickで処理すると問題
process.nextTick(() => {
const item = items.shift();
processItem(item);
processItems(items, callback); // 再帰的なnextTick
});
}
// 推奨される使い方
function processItemsBetter(items, callback) {
if (items.length === 0) {
return setImmediate(callback); // setImmediateを使用
}
setImmediate(() => {
const item = items.shift();
processItem(item);
processItemsBetter(items, callback);
});
}
|
2. 重い同期処理を避ける#
イベントループをブロックする同期処理は、すべての非同期処理を遅延させます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// 避けるべきパターン
app.get('/heavy', (req, res) => {
const result = heavySyncComputation(); // イベントループをブロック
res.json(result);
});
// 推奨パターン
const { Worker } = require('node:worker_threads');
app.get('/heavy', (req, res) => {
const worker = new Worker('./heavy-computation.js');
worker.on('message', (result) => {
res.json(result);
});
});
|
3. 適切な非同期パターンの選択#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
// 状況に応じた使い分け
// 1. 現在の操作の直後に実行したい場合
process.nextTick(() => {
// エラーハンドラの設定後に実行
});
// 2. 現在のI/Oサイクル後に実行したい場合
setImmediate(() => {
// I/Oをブロックしない
});
// 3. 一定時間後に実行したい場合
setTimeout(() => {
// タイムアウト処理
}, 1000);
// 4. Promiseチェーンで非同期処理を続ける場合
Promise.resolve()
.then(() => {
// マイクロタスクとして実行
});
|
まとめ#
Node.jsのイベントループを理解することで、非同期処理の実行順序を正確に予測できるようになります。
主要なポイントを振り返ります。
- イベントループは6つのフェーズ(timers、pending callbacks、idle/prepare、poll、check、close callbacks)で構成される
- **process.nextTick()**はイベントループの一部ではなく、現在の操作完了後に即座に実行される
- マイクロタスク(nextTickとPromise)は、各フェーズの間で処理される
- **setImmediate()**はI/Oコールバック内では常に
setTimeout(..., 0)より先に実行される
- 再帰的な非同期処理には
setImmediate()を使用し、I/Oの餓死を防ぐ
実際にコードを実行し、イベントループの動作を確認することで、より深い理解が得られます。本記事で紹介した実験コードを試しながら、Node.jsの非同期処理をマスターしてください。
参考リンク#