T-CREATOR

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

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コンテキストスイッチ頻繁に発生ほとんど発生しない
5I/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

マイクロタスクの優先順位は以下の通りです:

#タスクタイプ優先順位実行タイミング
1process.nextTick()最高各フェーズの最初
2Promise.then()nextTick 後
3queueMicrotask()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 マスターへの道のりを歩んでいきましょう!

関連リンク