12k
All articles

Node.jsにおけるイベントループ、ワーカースレッド、および並行処理

Node.jsのイベントループ、libuvスレッドプール、worker threads、clusterを整理し、I/OとCPU負荷の違いを明確に解説。

OpenReplay Team
OpenReplay Team
Node.jsにおけるイベントループ、ワーカースレッド、および並行処理

Node.jsは、イベントループによって駆動される単一スレッド上でJavaScriptを実行します。並行処理は待機処理のアウトソーシングによって実現されます。つまり、libuvがブロッキングI/Oをバックグラウンドのスレッドプールに委譲するのであって、JavaScriptを並列実行するわけではありません。CPUバウンドな処理のためにJavaScriptを並列実行する必要がある場合は、別のメカニズムであるワーカースレッドを使用します。各ワーカースレッドは、独自のイベントループを持つ完全なV8アイソレートです。この3つの要素、すなわち単一JSスレッド、libuvスレッドプール、ワーカースレッドは常に混同されており、その混乱がレスポンスの遅延やサーバーの停止を引き起こしています。

本記事では、これらのレイヤーを明確に分離します。イベントループのフェーズとマイクロタスク/マクロタスクの分割、libuvスレッドプールが実際に処理するもの(および処理しないもの)、ワーカースレッドが単純なasync/awaitより優れている場面、クラスタリングとの違い、そしてそれらを選択するための判断基準について説明します。コードはNode.js 24(Active LTS)を対象として記述されており、Node.js 26がCurrentラインです。ワーカースレッドは安定版であり、実験的機能ではありません。

重要なポイント

  • NodeはJavaScriptを1つのスレッドで実行します。イベントループは、コードを並列実行するのではなく、ブロッキングI/Oをlibuvのバックグラウンドスレッドプールに委譲することで並行処理を実現します。
  • libuvスレッドプールのデフォルトは4スレッドで、UV_THREADPOOL_SIZEによって最大1024まで増やすことができます。ファイルシステム、dns.lookup、crypto、zlibの処理を担いますが、ネットワークソケットは対象外です。ネットワークソケットはOSのepoll/kqueue/IOCPを直接使用します。
  • ワーカースレッドは「単なるOSスレッド」ではありません。各ワーカースレッドは独自のイベントループとlibuvループを持つ独立したV8アイソレートです。そのため、ワーカーは通常のオブジェクトを共有できず、メッセージパッシングで通信する必要があります。
  • マイクロタスクはイベントループのフェーズではありません。process.nextTickコールバックが最初にドレインされ、次にPromiseマイクロタスクキューがドレインされ、その後にループが次のマクロタスクに進みます。
  • ボトルネックに応じて選択してください。I/Oバウンドな処理にはasync/await、CPUバウンドなJavaScriptにはワーカースレッド、I/Oバウンドな負荷をコア全体にスケールするにはclusterを使用します。

コアモデル:1つのJSスレッド、イベントループ、そしてlibuv

Node.jsはJavaScriptを単一スレッドで実行し、そのスレッドがイベントループを動かします。ランタイムは、JavaScriptを実行するGoogleのV8エンジンと、イベントループと非同期I/Oを提供するCライブラリであるlibuvの上に構築されています。1つのスレッドで何千もの同時接続を処理できる仕組みは委譲にあります。コードがファイル読み込みなどのブロッキング操作を呼び出すと、Nodeは待機せずにlibuvへ操作を登録し、即座に制御を返します。そして結果が準備できたときにコールバックが実行されます。

Nodeにおける並行処理は待機処理のアウトソーシングです。ファイル読み込みやDNSルックアップが保留中の間、単一JSスレッドは他のコールバックを自由に実行できます。JavaScriptの中では何も並列実行されません。コールスタックは常に1つだけですが、遅い処理が別の場所で行われているため、多くの操作を同時に進行中にできます。

「Nodeはシングルスレッドである」という言い方が半分しか正しくない理由はここにあります。JavaScriptの実行はシングルスレッドです。しかしランタイムはそうではありません。libuvはバックグラウンドスレッドのプールを維持しており、OSはNodeに代わってネットワークソケットを処理します。「シングルスレッド」とは、あなたのコードがどこで実行されるかについての記述であり、プロセス全体についての記述ではないと理解してください。

よくある誤解:「Nodeはシングルスレッドである。」あなたのJavaScriptは1つのスレッドで実行されますが、Nodeプロセスは複数のスレッドを使用します。この区別こそが本記事の核心です。

並行処理と並列処理の正確な定義

**並行処理(Concurrency)**とは、複数のタスクが共有リソース上で交互に実行されることで、同じ期間に進捗することを意味します。**並列処理(Parallelism)**とは、複数のタスクが別々のコアで同時に実行されることを意味します。シングルコアマシンでNodeを実行する場合、並行処理はありますが並列処理はありません。イベントループは進行中の操作を素早く切り替えますが、JavaScriptが実行されるのは常に1つのピースだけです。ワーカースレッドとクラスタリングは、OSが異なるコアにスケジュールできる追加の実行コンテキストを導入することで、真の並列処理を実現します。

実践的な意味合いとして、並行処理は待機の問題(I/O)を解決し、並列処理は計算の問題(CPU)を解決します。誤った方を選ぶことが、Nodeのパフォーマンス問題の根本原因のほとんどを占めています。

イベントループの6つのフェーズとマイクロタスクの分割

イベントループは6つのフェーズの固定サイクルで動作し、各フェーズには独自のコールバックキューがあり、次のフェーズに移る前にそれを完全にドレインします。公式のNode.jsイベントループガイドによると、フェーズの順序は以下のとおりです:

  1. Timers — しきい値が経過したsetTimeout()setInterval()によってスケジュールされたコールバックを実行します。
  2. Pending callbacks — 前のサイクルから延期された特定のシステムレベルのコールバックを実行します。
  3. Idle, prepare — 内部使用のみ。
  4. Poll — 新しいI/Oイベントを取得してそのコールバックを実行します。他に処理がない場合、ループはここでI/Oを待機してブロックします。
  5. ChecksetImmediate()コールバックを実行します。
  6. Close callbackssocket.on('close', ...)などのクローズハンドラを実行します。

3番目のフェーズに注目してください。多くの解説は5つしか挙げず、idle/prepareを省略しています。このフェーズは存在しますが、libuvの内部ブックキーピング用に予約されています。実在しますが、直接スケジュールすることはありません。

マイクロタスクはフェーズではない

マイクロタスクはイベントループのフェーズではありません。process.nextTickコールバックが最初にドレインされ、次にPromiseマイクロタスクキューがドレインされ、その後にループが次のマクロタスクに進みます。つまり、process.nextTickPromise.thenより優先され、Promise.thensetTimeoutより優先されます。Nodeのガイドでは、process.nextTickは技術的にはイベントループの一部ではないと明示されています。そのキューは現在のフェーズに関わらず、現在の操作が完了した後に処理され、Promiseキューはその後に続きます。どちらもループが進む前に処理されます。

これにより、明確な3段階の優先順位が得られます:process.nextTick → Promiseマイクロタスク → マクロタスク(タイマー、I/O、setImmediate

具体的な順序のデモ

よくある混乱はsetImmediatesetTimeout(0)の順序です。I/Oコールバックの内部では順序は決定論的ですが、トップレベルでは決定論的ではありません。

// Node.js 24.16.0で実行
const fs = require('node:fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('1: setTimeout(0)'), 0);
  setImmediate(() => console.log('2: setImmediate'));
  Promise.resolve().then(() => console.log('3: promise'));
  process.nextTick(() => console.log('4: nextTick'));
});

出力:

4: nextTick
3: promise
2: setImmediate
1: setTimeout(0)

nextTickとPromiseはどちらもループが進む前にドレインされ、nextTickが先です。次に、コールバックがI/Oサイクル(pollフェーズ)の内部からスケジュールされているため、ループは次にcheckフェーズに到達し、ループがtimersフェーズに戻る前にsetImmediateが実行されます。Nodeのガイドでは、I/Oサイクル内で両方がスケジュールされた場合、setImmediate()は常にタイマーより先に実行されると確認しています。同じ2つをトップレベルでスケジュールすると、順序は非決定論的になります。I/Oコールバックの外ではこの順序に依存しないでください。

libuvスレッドプール:デフォルト4、最大1024 — そして実際に使用するもの

libuvスレッドプールは、ノンブロッキングのOS プリミティブが存在しない操作を実行するためにlibuvが使用する、固定されたバックグラウンドスレッドのセットです。デフォルトは4スレッドで、最大1024まで拡張できます。libuvスレッドプールのドキュメントによると、この最大値はUV_THREADPOOL_SIZE環境変数で設定します。(上限はlibuv 1.30.0で128から1024に引き上げられました。「128」と記載している古い記事は古い情報です。)プールはプロセス内のすべてのイベントループで共有されます。

プールで実行されるのは、特定の有限なリストに限られます。UV_THREADPOOL_SIZEのNode.js CLIドキュメントでは、使用するものとして以下を挙げています:fs API(ウォッチャーと明示的な同期バリアントを除く)、dns.lookup()、そしてcrypto.pbkdf2()crypto.scrypt()crypto.randomBytes()crypto.generateKeyPair()zlib圧縮などの非同期cryptoおよびzlib操作。

ここで最も重要なのは、使用しないものです。**ネットワークI/Oはスレッドプールを使用しません。**公式のイベントループをブロックしないでくださいガイドで説明されているように、ネットワークソケットはOSのポーリングメカニズム(LinuxではEpoll、macOS/BSDではkqueue、WindowsではIOCP)によって処理され、pollフェーズに直接現れます。DNSについても注意点があります:dns.lookup()getaddrinfoを呼び出す)はプールを使用しますが、dns.resolve*()ファミリー(c-aresを使用する)は使用しません。そのため、「DNSはプールを使用する」という一般化は誤りです。

# Nodeが起動する前に設定してください — プールは最初の使用時に事前割り当てされます。
UV_THREADPOOL_SIZE=8 node server.js

運用上の注意点として、libuvはプールの最初の使用時に最大スレッド数を事前割り当てするため、UV_THREADPOOL_SIZEはその前に設定する必要があります。実際には、Nodeが起動する前に設定してください。プールが使用された後にprocess.env.UV_THREADPOOL_SIZEを変更しても何も起きません。

よくある誤解:UV_THREADPOOL_SIZEを増やすとCPU処理が速くなる。」UV_THREADPOOL_SIZEを増やすと同時I/O操作が速くなりますが、CPUバウンドなJavaScriptは決して速くなりません。CPUの処理にはワーカースレッドが必要です。プールはあなたのJavaScriptを実行しないからです。プールはlibuvのCレベルの操作を実行するのであって、あなたのJS関数を実行するわけではありません。

ワーカースレッド:CPUバウンドなJavaScriptのための真の並列処理

ワーカースレッドは、CPUバウンドな処理(そうでなければ単一JSスレッドをブロックする処理)のために、別スレッドでJavaScriptを並列実行します。I/Oバウンドな処理には役立ちません。NodeのビルトインI/Oがすでにより効率的に処理しているからです。worker_threadsのドキュメントでは、ワーカーはCPU集約型のJavaScript操作に有用であり、I/O集約型の処理にはあまり役立たないと述べています。NodeのビルトインI/Oの方がワーカーより効率的だからです。

多くの記事が見落とす重要な正確さのポイントがあります:ワーカースレッドは「単なるOSスレッド」ではありません。各ワーカースレッドは、独自のイベントループ独自のlibuvループを持つ独立したV8アイソレートです。このアイソレーションこそが、ワーカーが通常のJavaScriptオブジェクトを共有できない理由であり、postMessageで渡すすべてのものがコピーされる理由です。

よくある誤解:「ワーカーは単なるOSスレッドである。」各ワーカーはスレッド内で動作する完全なV8アイソレートと独自のイベントループおよびlibuvループです。そのため、メインとワーカーの間でグローバル変数もクロージャスコープも共有されません。

アイソレーションとメッセージパッシング

ワーカーはメッセージパッシングで通信し、ペイロードはディープコピーされます。workerDatapostMessage()を通じて渡すデータは、HTML構造化クローンアルゴリズムに従ってクローンされます。関数、クラスプロトタイプ、ライブ参照は転送を生き残れません。コピーを回避する唯一の方法は共有メモリです。ワーカースレッドはSharedArrayBufferを通じてのみメモリを共有できます(またはArrayBuffer転送することで、コピーではなく所有権を移動できます)。それ以外はすべて構造化クローンされます。

// main.js — Node.js 24.16.0で実行
const { Worker } = require('node:worker_threads');

const worker = new Worker('./fib-worker.js', { workerData: { n: 42 } });
worker.on('message', (result) => console.log('fib(42) =', result));
worker.on('error', (err) => console.error(err));
// fib-worker.js
const { parentPort, workerData } = require('node:worker_threads');

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

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

ファイルを分割したくない場合は、isMainThreadを使って1つのファイルで両方の役割を分岐させることができます。完全なAPIサーフェス(MessageChannelMessagePort、転送リスト、receiveMessageOnPort)については、worker_threadsのドキュメントを参照してください。C++やJavaのスレッドと比較すると、このモデルはデフォルトの共有メモリ(およびそれに伴うロック、ミューテックス、競合状態)をデフォルトのアイソレーションと引き換えにしています。より安全ですが、コピーがコストとなります。

タスクごとにワーカーを作成するのではなく、プールを使用する

リクエストごとにWorkerを生成するのは非効率です。Nodeのドキュメントでは、実際にはワーカーのプールを使用すべきと明示されています。そうしなければ、ワーカーの作成オーバーヘッドがメリットを上回る可能性があるからです。コミュニティ標準のプールはpiscinaです。Piscinaは高速で効率的なNode.jsワーカースレッドプール実装です。最新バージョンは5.2.0ですpackage.jsonのレンジには5.xと記述し、CIではポイントリリースをピン留めしてください。

見落とされがちな注意点があります:すでに非同期の処理をワーカーに移動しないでください。非同期のcrypto、fs、zlibはすでにlibuvのバックグラウンドスレッドで実行されているため、それをワーカーでラップすると、スレッドが別のスレッドをスケジュールするだけで、何も節約できません。ワーカーが真価を発揮するのは、同期的でCPUバウンドなJavaScriptに対してのみです。

実例:全員をブロックするCPUバウンドなルート

メインスレッドでの同期CPUタスクは、それをトリガーしたリクエストだけでなく、すべての進行中のリクエストのイベントループをフリーズさせます。以下はExpressハンドラでの失敗パターンです:

// server-blocking.js — Node.js 24.16.0、express 5.xで実行
const express = require('express');
const app = express();

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

app.get('/report', (req, res) => {
  res.json({ value: fib(45) }); // 数秒間イベントループをブロックする
});

app.get('/health', (req, res) => res.send('ok'));

app.listen(3000);

fib(45)が実行中の間、/healthは何も返しません。単一スレッドが計算で占有されており、すべての同時リクエストがその後ろに並んでいます。修正方法は、CPU処理をプールにオフロードして結果をawaitし、イベントループが引き続きリクエストを処理できるようにすることです:

// server-pooled.js — Node.js 24.16.0、express 5.x、piscina 5.2.0で実行
const express = require('express');
const Piscina = require('piscina');
const path = require('node:path');

const pool = new Piscina({ filename: path.resolve(__dirname, 'fib-task.js') });
const app = express();

app.get('/report', async (req, res) => {
  const value = await pool.run({ n: 45 }); // ワーカーで実行、ループは自由なまま
  res.json({ value });
});

app.get('/health', (req, res) => res.send('ok'));

app.listen(3000);
// fib-task.js
module.exports = ({ n }) => {
  const fib = (x) => (x < 2 ? x : fib(x - 1) + fib(x - 2));
  return fib(n);
};

これでfib(45)はワーカーで実行され、イベントループはレスポンシブを保ち、重いルートがバックグラウンドで計算している間も/healthはすぐに応答します。

ブロックされたイベントループには、本番環境で特徴的なシグネチャがあります。1つの同期CPUタスクが単一JSスレッドをすべての進行中のリクエストに対してフリーズさせるため、1人のユーザーのネットワーク問題ではなく、多くの同時ユーザーが同じ時刻に停止するという形で現れます。この相関したフリーズパターンは、セッションリプレイが同時セッション全体で表面化するものであり、実行時にはnode:perf_hooksmonitorEventLoopDelayで確認できます。p99が高い場合は、ループ(またはスレッドプール)が飽和していることを示します。

クラスタリングと複数プロセス:コア全体へのI/Oのスケーリング

クラスタリングは、複数のプロセスをフォークすることでNode.jsアプリケーションをCPUコア全体にスケールします。各プロセスは独自のイベントループとメモリを持ちながら、リスニングソケットを共有してアプリ全体を実行します。clusterモジュールはこれを行うためのビルトインの方法です。ボトルネックがI/Oバウンドなスループットであり、1つのコアではなくマシンのすべてのコアを使用したい場合に適切なツールです。

ワーカースレッドとの違いは重要です。クラスタワーカーはIPCで通信する完全に分離されたメモリを持つ別々のプロセスです。ワーカースレッドはSharedArrayBufferでメモリを共有できる1つのプロセス内の別々のスレッドです。クラスタはコア全体でより多くの同時リクエストを処理するためのものです。ワーカースレッドはCPUバウンドなJavaScriptをリクエストスレッドからオフロードするためのものです。実際には、高スループットのサービスは両方を組み合わせることが多いです。コアをまたぐためにcluster(またはプロセスマネージャ/コンテナレプリカ)を使用し、各プロセス内でCPUスパイクを吸収するためにワーカープールを使用します。

判断ガイド:async、プール、ワーカー、またはcluster

ボトルネックに応じて選択してください。I/Oバウンドな処理にはasync/await、CPUバウンドなJavaScriptにはワーカースレッド、I/Oバウンドな負荷をコア全体にスケールするにはcluster(または複数プロセス)、そしてリクエストごとにワーカー起動コストを払うことになる場合はpiscinaのようなワーカープールを使用します。

ツールJSを並列実行するかメモリを共有するか最適な用途主なコスト
async/await + イベントループいいえN/A(1スレッド)I/Oバウンドな処理(ネットワーク、DB、ファイル)CPU処理を行うとブロックする
libuvスレッドプールいいえ(Cを実行、JSではない)N/A同時fs/dns.lookup/crypto/zlib固定サイズ、あなたのJSには使えない
ワーカースレッド(+ プール)はいSharedArrayBuffer経由のみCPUバウンドなJavaScript起動 + 構造化クローンのコピー
Cluster / 複数プロセスはいいいえ(IPCのみ)I/Oバウンドな負荷をコア全体にスケールプロセスオーバーヘッド、共有状態なし

簡単なルール:

  • I/Oバウンドでまだ並列化していない? async/awaitを使用してください。スレッドプールとOSがすでに無料で並行処理を提供しています。
  • ルートが重い同期計算を行っている? ワーカースレッドにオフロードし、再利用のためにプールの後ろに置いてください。
  • 同時トラフィックで1つのコアが飽和している? コア全体にクラスタリングしてください(または複数のコンテナレプリカを実行してください)。
  • 速度向上のためにUV_THREADPOOL_SIZEを増やしたい? プールバックの操作(大量の同時fs/crypto)でI/Oバウンドになっている場合のみ有効です。CPUバウンドなJavaScriptは決して速くなりません。

今日書くコードへの将来的な注意点として:2026年10月のNode.js 27から、プロジェクトは年1回のメジャーリリースに移行し、奇数/偶数のケイデンスが終了します。Node.jsリリーススケジュールを追跡し、現在のLTSに対してビルドしてください。

これらすべてを整理するメンタルモデルは、「Nodeはシングルスレッドか?」という問いをやめ、「私のボトルネックは何か?」と問うことです。待機はイベントループの仕事、並列計算はワーカープールの仕事、コア全体への負荷分散はclusterの仕事です。遅いエンドポイントをプロファイリングし、それが待機でスタックしているのか計算でスタックしているのかを特定すれば、適切なツールが自然に導かれます。そしてmonitorEventLoopDelayで修正を検証してからリリースしてください。

よくある質問

Node.jsにおけるワーカースレッドとclusterモジュールの違いは何ですか?

ワーカースレッドは1つのプロセス内の別々のスレッドで、それぞれがSharedArrayBufferを通じてメモリを共有できる独自のイベントループを持つV8アイソレートです。clusterはIPCで通信し、リスニングソケットを共有する完全に分離されたメモリを持つ別々のプロセスをフォークします。CPUバウンドなJavaScriptをリクエストスレッドからオフロードするにはワーカースレッドを使用し、I/Oバウンドな負荷をCPUコア全体にスケールするにはclusterを使用してください。高スループットのサービスは多くの場合、両方を組み合わせます。

UV_THREADPOOL_SIZEを増やしてもCPUバウンドなコードが速くならないのはなぜですか?

libuvスレッドプールはファイルシステム呼び出し、dns.lookup、非同期crypto、zlibなどのCレベルの操作を実行するのであって、あなたのJavaScriptを実行するわけではありません。UV_THREADPOOL_SIZEを増やすと、それらのI/Oスタイルの操作が同時に実行できる数が増えるだけです。CPUバウンドなJavaScriptは依然として単一JSスレッドで実行されるため、決して速くなりません。CPU処理にはワーカースレッドが必要です。ワーカースレッドは独自のV8アイソレートでJavaScriptを並列実行します。

Node.jsはネットワークリクエストをlibuvスレッドプールで処理しますか?

いいえ。ネットワークソケットはOSのポーリングメカニズム(LinuxではEpoll、macOS/BSDではkqueue、WindowsではIOCP)によって処理され、イベントループのpollフェーズに直接現れます。libuvスレッドプールはファイルシステム操作、getaddrinfo経由のdns.lookup、非同期crypto、zlibを処理しますが、ネットワークI/Oは処理しません。dns.resolve関数はc-aresライブラリを使用してプールをバイパスするため、「DNSはプールを使用する」はdns.lookupにのみ当てはまります。

ワーカースレッドはメインスレッドとJavaScriptオブジェクトを共有できますか?

いいえ。ワーカーはメッセージパッシングで通信し、workerDataやpostMessageを通じて渡されるデータはHTML構造化クローンアルゴリズムを使用してディープコピーされます。そのため、関数、クラスプロトタイプ、ライブ参照は転送を生き残れません。メモリを共有する唯一の方法はSharedArrayBuffer、またはコピーではなく所有権を移動するArrayBufferの転送です。このデフォルトのアイソレーションにより、C++やJavaのような共有メモリスレッドのロックや競合状態を回避できます。

We use cookies to improve your experience. By using our site, you accept cookies.