← 記事一覧

テクノロジー

Node.js | シングルスレッド・ノンブロッキングI/O・イベントループの仕組み - なぜNode.jsは大量アクセスに強いのか

はじめに

Node.jsに関して、よく「Node.jsはイベントループで動くので大量のリクエストを捌ける」といった性質を耳にします。

それはどうしてなのでしょうか。

今回は、

  • Node.jsの基本アーキテクチャ - シングルスレッド
  • 処理を止めない仕組み - ノンブロッキングI/O
  • イベントループ
  • なぜリクエストを大量に捌けるのか?

についてまとめていきます。

Node.jsはシングルスレッド

シングルスレッドとは、

1つのプロセス内に命令を実行する処理の流れ(スレッド)が1つしか存在しない状態・アーキテクチャ

を指します。

そしてNode.js (JavaScript)はシングルスレッドで動作するプログラミング言語です。実行コードは1つのスレッド(メインスレッド)でのみ動きます。

コードは上から順に同期的に実行されます。

しかしこれでは、I/O待ち等重い処理がある場合に処理が止まってしまいます。

Node.jsには、ここで処理を止めない仕組みがあり、それをノンブロッキングI/Oと呼びます。

ノンブロッキングI/Oとは?

ノンブロッキングI/Oとは、「処理がその場で終わるのを待つのではなくすぐに手元に制御を返す仕組み」です。

ブロッキングI/Oの例

例えば、プログラム中でファイル読み込みやネットワーク通信(I/O)を同期的に処理すると、データが届くまでCPUはその場でフリーズ(ブロック)します。

その際、そのスレッドにおいて他の処理が一切できなくなります。

const fs = require('fs');

console.log('1. 処理を開始します');

const data = fs.readFileSync('./sample.txt', 'utf8');
// ここで読み込み完了まで処理が止まる
console.log('2. ファイルの読み込みが完了しました');

console.log('3. 次の処理を実行します')

↓
1. 処理を開始します
2. ファイルの読み込みが完了しました
3. 次の処理を実行します

上記の例は、ファイルを同期で読み込むreadFileSync (同期API)を使った例で、fs.readFileSync('./sample.txt', 'utf8');で読み込み完了まで処理がブロックされます。

読み込みが完了すると、2, 3の文字が出力されます。

ノンブロッキングI/Oの例

同じようにI/Oが発生した場合、非同期APIを使用するとデータが届いていなくてもOSは「まだデータがないよ」という結果を即座に返ます。スレッドはブロックされないため、即座に他の処理に進みます。

const fs = require('fs');

console.log('1. 処理を開始します');

fs.readFile('./sample.txt', 'utf8', (err, data) => {
    // 読み込みが終わった後に呼び出される(コールバックという)
    console.log('2. ファイルの読み込みが完了しました');
});

console.log('3. 次の処理を実行します(ファイルを待たずに即座に実行される)');

↓
1. 処理を開始します
3. 次の処理を実行します(ファイルを待たずに即座に実行される)
2. ファイルの読み込みが完了しました

上記は、readFile(非同期API)によるファイル読み込み処理です。読み込みが終わるのを待たず、後続コードが処理されます。

そして、ファイル読み込みが完了するとコールバックに登録した処理が呼ばれます。

このような非同期処理は、async/awaitを使って書くことも可能です(むしろこっちが主流です)。

import fs from 'fs/promises';

console.log('1. 処理を開始します');

const data = await fs.readFile('./sample.txt', 'utf8');

console.log('2. ファイルの読み込みが完了しました');
console.log('3. 次の処理');

↓
1. 処理を開始します
2. ファイルの読み込みが完了しました
3. 次の処理

このように、非同期APIを使うとメインスレッドをブロックせずに処理を続けることが可能です。

Node.jsはシングルスレッドなのに、どうやってこの挙動を実現しているのでしょうか?

実は、非同期APIや待ちが発生する処理の際はその処理を別スレッドに委譲する仕組みがあります。

これをスレッドプールと呼びます。

スレッドプール (Thread Pool)

Node.jsはシングルスレッドですが、裏側で「スレッドプール (Thread Pool)」と呼ばれるスレッド群(デフォルトでは4つ)を持ちます。

スレッドプールは、Node.jsが内部で使用する非同期基盤libuvが担います。

以下のような処理はスレッドプールで処理されます。

  • ファイルシステム操作 (ファイルI/O)
  • DNS関連処理 (名前解決等)
  • 暗号化処理

⚠️ネットワークI/Oはここに含まれません(後述)

先ほどの非同期APIによるファイル読み込みを図にするとこうです。

① : ファイル読み込みをスレッドプールに委譲する。その間、メインスレッドは空きができる

②~④ : スレッドプールによる処理が終わるとメインスレッドに処理が戻り、後続処理を続ける

スレッドプールを使うかどうかはNode.jsが自動で判断します。先ほど挙げた、ファイルI/Oや暗号化処理がそれに当たります。

では、開発者が書いた処理を明示的にスレッドプールに渡すことはできるのでしょうか?

答えは「できない」です。

が、ワーカースレッド(Worker Thread)という独自スレッドを作成して処理を割り当てることはできます。

ワーカースレッド(Worker Thread)

例えば開発者が、ファイル読み込みやAPIリクエストではなく、独自に書いた計算処理が重すぎてメインスレッドをブロックしてしまっている場合、Node.jsはスレッドプールに投げてくれることはありません。この場合は、woker_threadsモジュールを用いて明示的に別スレッドにします。

import { Worker, isMainThread, parentPort } from 'worker_threads';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);

if (isMainThread) {
    // 【メインスレッド】重い処理を別スレッドにお願いする
    const worker = new Worker(__filename);
    worker.on('message', (result) => {
        console.log('別スレッドから結果を受け取った:', result);
    });
} else {
    // 【ワーカースレッド側】メインスレッドを止めずに、裏で重い計算をこなす
    const heavyResult = doHeavyCalculation(); 
    parentPort.postMessage(heavyResult);
}

処理の流れ

  1. node worker-sample.jsを実行し、メインスレッドとしてファイルを読み込む。この時、isMainThread=true
  2. 別スレッド(ワーカースレッド)作成し、__filenameで自分自身を渡す。
  3. ワーカースレッドは、worker-sample.jsを頭から読み、else内の処理を行い、postMessageでメインスレッドに通知。
  4. worker.onが通知を受け取り、メインスレッドに処理が戻る。

このような挙動になります。

3のワーカースレッドがメインスレッドへ通知、4で処理がメインスレッドに戻る、というような挙動を管理する機構をイベントループと呼びます。

イベントループ(Event Loop)

Node.jsは、シングルスレッドでありつつ、重い処理はスレッドプールやワーカースレッドに処理を委譲することでメインスレッドを止めないアーキテクチャです。

このように、メインスレッドは非同期タスクを監視して、完了した処理のコールバックを実行するループ構造です。

これをイベントループと呼びます。

イベントループは、メインスレッドの中で以下のフェーズを無限ループしながら、実行できるコールバックがないか常に監視しています。

ネットワークI/Oは?

ファイルI/Oや暗号化処理はスレッドプールが行うことがわかりました。では、同じように待ちが発生するネットワーク通信はどうするのでしょうか。

ネットワークI/Oは、スレッドプールを使わずOSに処理を投げます。

ネットワークI/Oの非同期処理はOSに備わっているのでそれを利用します。(Linuxのepollなど)。一方、ファイルI/OにはそのようなOS標準機能がないのでNode.js側で対応する仕組みにあんっています。

Node.jsは大量リクエストを捌ける

ここまでで、Node.jsは「シングルスレッド+イベントループ+ノンブロッキングI/O」であることがわかりました。

  • メインスレッドは1つなのでメモリ消費が少ない
  • ノンブロッキングI/Oにより、重いI/O処理はOSやスレッドプールに投げ、メインスレッドは次々にリクエストに対応できる

これにより、少ないリソースで効率的にリクエストを捌くことができます。

おわりに

今回は、Node.jsのアーキテクチャを整理しました。

  • シングルスレッド: Node.jsは基本メインスレッドで処理するが、重い処理やI/O待ちを分散させる機構を持つ(イベントループ)。
  • ノンブロッキングI/O:
    • ネットワーク通信は OS にノンブロッキングで丸投げする。
    • ファイル読み込みや重い計算は スレッドプール や ワーカースレッド に逃がす。

こうした「シングルスレッド + イベントループ + ノンブロッキングI/O」の組み合わせにより大量リクエストを捌くことができます。