Node.jsのEvent Loop: メカニズムと実例

1. 概要

Event LoopはNode.jsが単一のJavaScriptスレッドだけで数千のリクエストを同時に処理できる中核的なメカニズムです。毎日async/await、setTimeout、Promiseを書いているが、実際の実行順序がどのように進むのか明確ではない人も多いでしょう。

この記事では、Event Loopの構造を具体的なphaseを通じて説明し、microtaskとmacrotaskを区別し、実行可能なコード例で説明します。読み終わった後、複雑な非同期コードのログ出力順序を正確に予測でき、event loopのブロックなどの一般的なエラーを避けることができるようになります。

2. Node.jsがシングルスレッドで動作する仕組み

Node.jsはメインスレッド(main thread)で1つのスレッドだけでJavaScriptを実行しますが、I/Oタスク(ファイル読み込み、ネットワーク呼び出し、DB問い合わせ)はlibuv — スレッドプールまたはカーネルメカニズム(epoll、kqueue…)を使用して非同期I/Oを処理するCライブラリに送り込まれます。

┌───────────────────────────┐
│   JavaScript (single thread) │
└──────────────┬────────────┘
               │ fs.readFile、setTimeout、netなどを呼び出し
               ▼
┌───────────────────────────┐
│     libuv (C/C++)          │
│  - Thread pool             │
│  - Event loop               │
│  - OS async APIs            │
└───────────────────────────┘

I/Oタスクが完了すると、libuvは対応するコールバックをキューに入れます。Event Loopはこれらのキューを継続的にチェックし、順番が来たときにコールバックを実行します — これが「loop」です。

注意: JavaScriptコードはいつも1つのスレッド上で実行されます。重い同期コード(例えば大きなループ計算)を書くと、event loopをブロックしてしまい、他のすべてのリクエストが待つことになります。

3. Event Loopのphase

Event Loopは単純なループではなく、各回(tick)において6つのphaseを固定された順序で進みます:

   ┌───────────────────────┐
┌─>│        timers          │  setTimeout、setInterval
│  └──────────┬─────────────┘
│  ┌──────────▼─────────────┐
│  │     pending callbacks   │  遅延したI/Oコールバック
│  └──────────┬─────────────┘
│  ┌──────────▼─────────────┐
│  │        idle, prepare    │
│  └──────────┬─────────────┘
│  ┌──────────▼─────────────┐
│  │          poll           │  I/Oイベント(ファイル読み込み、ネットワーク)
│  └──────────┬─────────────┘
│  ┌──────────▼─────────────┐
│  │          check          │  setImmediate
│  └──────────┬─────────────┘
│  ┌──────────▼─────────────┐
└──┤     close callbacks     │  socket.on('close')
   └─────────────────────────┘

3.1. 各phaseの簡単な説明

  • timers: 期限切れのsetTimeout/setIntervalのコールバックを実行します。
  • pending callbacks: 前の周期から延期されたI/Oコールバックを実行します。
  • poll: 新しいI/Oイベント(ファイル読み込み完了、ネットワークレスポンス受信など)を待機して処理します。これは最も時間がかかるphaseです。
  • check: setImmediateのコールバックを実行します。
  • close callbacks: 閉じるイベントを処理します(例:socket.on('close', ...))。

各phaseには独自のキュー(queue)があります。Event Loopは現在のphaseのキュー内のコールバックをすべて(または上限まで)処理してから、次のphaseに進みます。

4. Microtask queue: Promiseとprocess.nextTick

上記の6つのmacrotaskのphaseの他に、Node.jsにはmicrotask queueがあり、これは各phase間で処理されます — ループ全体が完了するまで待つ必要はありません。

2種類のmicrotaskがあります:

  • process.nextTick() — 独自のキュー、他のすべてのmicrotaskより前に必ず処理されます。
  • Promise.then/catch/finally — 標準的なmicrotaskキュー(ECMAScriptスペック準拠)。
console.log('1 - sync start');

setTimeout(() => console.log('2 - setTimeout'), 0);

Promise.resolve().then(() => console.log('3 - promise'));

process.nextTick(() => console.log('4 - nextTick'));

console.log('5 - sync end');

説明:

  • console.logは同期的にすぐに実行されます(1、5)。
  • setTimeoutはコールバックをtimersキューにプッシュします — 次のphaseで実行されます。
  • process.nextTickとPromise.thenはどちらもmicrotaskですが、nextTickの方が優先度が高いです。
  • 同期コードの実行が完了した後、Node.jsはnextTickキューをすべて排出し、次にPromise microtaskキューをすべて排出してから、timers phaseに入る前にこれを行います。
1 - sync start
5 - sync end
4 - nextTick
3 - promise
2 - setTimeout

注意: process.nextTick()を再帰的に連続して呼び出す(例えば、あるコールバック内から別のnextTickを呼び出す)と、event loopは決して次のphaseに進むことができません — これは「I/O starvation」と呼ばれます。

5. setTimeout vs setImmediate: どちらが正しい順序?

これは最も混乱を招く質問です。トップレベルで呼び出す場合、setTimeout(fn, 0)とsetImmediate(fn)の順序は固定されていませんが、I/Oコールバック内で呼び出す場合は固定されています。

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});

このコードはfs.readFileのコールバック内にあるため、つまりpoll phaseにあるため、pollが終了するとEvent Loopは常にcheck phase(setImmediate)に移ってから新しいループがtimers phaseに戻る前に移ります。

immediate
timeout

逆に、これら2つのコマンドをトップレベル(I/Oコールバックの外)に配置した場合、順序はその時のNode.jsプロセスの起動パフォーマンスに依存します — 本番環境のコードでこの順序に依存してはいけません。

6. Event loopのブロック: 最も一般的なエラー

JavaScriptを処理するスレッドが1つだけなので、長時間実行する同期コードはサーバー全体をブロックします。

const express = require('express');
const app = express();

app.get('/fibonacci', (req, res) => {
  function fib(n) {
    return n <= 1 ? n : fib(n - 1) + fib(n - 2);
  }
  res.send(`Result: ${fib(40)}`); // ブロッキング!
});

app.listen(3000);

/fibonacciへのリクエストが来ると、fib(40)関数が同期的に実行され、数秒かかります。その間、他のすべてのリクエスト(単純なpingでも)は待つことになります。event loopが1つのタスクで詰まっているためです。

6.1. 解決方法: 重い処理をWorker Threadに移す

const { Worker } = require('worker_threads');

app.get('/fibonacci', (req, res) => {
  const worker = new Worker('./fib-worker.js', {
    workerData: { n: 40 }
  });

  worker.on('message', (result) => res.send(`Result: ${result}`));
  worker.on('error', (err) => res.status(500).send(err.message));
});
// fib-worker.js
const { workerData, parentPort } = require('worker_threads');

function fib(n) {
  return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}

parentPort.postMessage(fib(workerData.n));

Workerは独立したスレッドで実行され、メインスレッドを占有しません。メインリクエストのevent loopは、workerが計算している間も他のリクエストを処理するため自由に使えます。

Worker Threadが不要な場合: I/Oタスク(DB読み込み、API呼び出し、ファイル読み込み)の場合 — これらはすでに非同期なので、workerは不要です。Worker ThreadはCPU-boundタスク(重い計算、暗号化、画像処理など)にのみ有用です。

7. perf_hooksを使用してevent loopの遅延を測定する

本番環境でevent loopがブロックされているのを検出するために、組み込みモジュールperf_hooksを使用できます。

const { monitorEventLoopDelay } = require('perf_hooks');

const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();

setInterval(() => {
  console.log(`Event loop delay (mean): ${histogram.mean / 1e6} ms`);
  histogram.reset();
}, 5000);

monitorEventLoopDelayは、event loopが理想的なスケジュールと比べて「遅延」する時間を測定するヒストグラムを作成します。mean値が異常に高くなった場合(例えば100ms以上)、どこかでコードがブロックされているサインです。

8. 実行順序をチェックするテストを書く

microtask/macrotaskの順序を正しく理解していることを確認するために、ログをarrayに記録してから順序をassertするテストを書くことができます。

// order.js
function recordOrder(log) {
  log.push('sync-start');

  setTimeout(() => log.push('timeout'), 0);

  Promise.resolve().then(() => log.push('promise'));

  process.nextTick(() => log.push('next-tick'));

  log.push('sync-end');
}

module.exports = { recordOrder };
// order.test.js
const { recordOrder } = require('./order');

test('実行順序: sync -> nextTick -> promise -> timeout', (done) => {
  const log = [];
  recordOrder(log);

  // 同期コードはすぐに完了
  expect(log).toEqual(['sync-start', 'sync-end']);

  setImmediate(() => {
    expect(log).toEqual([
      'sync-start',
      'sync-end',
      'next-tick',
      'promise',
      'timeout',
    ]);
    done();
  });
});

このテストはJestでsetImmediateを使用して、すべてのmicrotaskとtimers phaseが完了するまで待機してから、logの最終的な順序をassertします。これは、単に目でログを読むのではなく、event loopについての理解を正確に検証する方法です。

npx jest order.test.js
PASS  ./order.test.js
  ✓ 実行順序: sync -> nextTick -> promise -> timeout (5 ms)

9. よくあるエラー

  • process.nextTickの再帰的乱用: I/O starvationを引き起こし、サーバーが新しいリクエストを処理できなくなります。
  • setTimeout(fn, 0)がすぐに実行されると思う: 実際にはevent loopの少なくとも1 tick待機する必要があり、すべてのmicrotaskの後に待機します。
  • メインスレッドでCPU-boundコードを実行する: 例えば巨大なJSONの解析、高costのbcrypt暗号化、同期的な画像処理 — これらはすべてWorker Threadまたは別プロセスに移す必要があります。
  • トップレベルのsetTimeoutとsetImmediateの順序を仮定する: 信頼できません。これに依存するロジックを書くのは避けてください。

注意が必要な時: アプリケーションが大量のデータを処理するエンドポイント(Excelエクスポート、統計計算、画像リサイズなど)を持つ場合、サーバー全体に影響を与える前に早期に検出するために、monitorEventLoopDelayを定期的に測定してください。

10. まとめ

Event LoopはNode.jsが低いリソースで大量の接続を処理できる理由ですが、制約が伴います: すべてのJavaScript同期コードは単一のスレッド上で実行されます。6つのphase(timers、pending callbacks、poll、check、close)とmicrotask queue(process.nextTick、Promise)の位置を明確に理解することで、実行順序を正確に予測でき、デバッグが難しいエラーを回避できます。

CPU-boundタスクに遭遇した場合、worker_threadsを使用することや別のプロセスに分離することをためらわないでください — event loopは、あなたがそれを窒息させなければ真の力を発揮します。

nodejsevent-loopjavascriptasynchronousbackend