「Node.jsはシングルスレッド」は半分だけ正しい

第7章ではepollとkqueueによるI/O多重化を、第8章ではその上に立つイベントループというモデルを見ました。 本章では、その代表であるNode.jsの内部に入ります。

「Node.jsはシングルスレッドだ」という説明をよく見かけます。これは半分だけ正しい説明です。 あなたの書いたJavaScriptを実行するのは、確かにメインスレッド1本だけです。 しかしNode.jsのプロセスをpsや/proc/<pid>/taskで見ると、スレッドは1本ではありません。 V8がGCやコンパイルに使う補助スレッドがあり、ファイル読み込みや暗号処理を初めて使った時点で、libuvのワーカースレッドが4本増えます。 本章では、どの処理がどのスレッドで動くのかを、3層構造、イベントループのフェーズ、マイクロタスク、スレッドプールの順に整理します。

V8・バインディング・libuvの3層構造

Node.jsは大きく3つの層でできています。いちばん上がJavaScriptを実行するV8です。 その下に、JavaScriptの世界とC/C++の世界をつなぐNode.jsのバインディング(C++)があり、 さらにその下で、イベントループと非同期I/O、スレッドプールを担当するlibuvがOSとやり取りします。

graph TD
  APP["あなたのJavaScript / TypeScript"] --> LIB["Node.jsの標準ライブラリ<br/>lib/*.js の fs, net, crypto など"]
  APP --> V8["V8<br/>JSの実行・JIT・GC"]
  LIB --> BIND["Node.jsバインディング C++<br/>JSとCの橋渡し"]
  BIND --> V8
  BIND --> UV["libuv<br/>イベントループ・非同期I/O"]
  UV --> POLL["epoll Linux<br/>kqueue macOS<br/>IOCP Windows"]
  UV --> TP["スレッドプール<br/>既定4本のワーカー"]
  TP --> SYS["ブロッキングな処理<br/>read, getaddrinfo, pbkdf2 など"]

  style V8 fill:#8b5cf6,stroke:#6d28d9,color:#fff
  style UV fill:#3b82f6,stroke:#1d4ed8,color:#fff
  style TP fill:#f97316,stroke:#ea580c,color:#fff
Node.jsの3層構造。ネットワークI/OはlibuvがOSのI/O多重化で待ち、ファイルや暗号処理などはスレッドプールのワーカーが実行する

libuvは、Node.js v0.6.0(2011年11月)で導入されたCのライブラリです。それまでNode.jsはUnix専用のlibevを使っていましたが、 WindowsのIOCPにも対応するためにlibuvが作られました。現在はNode.js以外にも、Julia、Neovim、CMake、Pythonのuvloopなどが使っています。 2026年10月28日にActive LTSになったNode.js 26(v26.13.0)はlibuv 1.52.1を同梱しています。

JavaScriptからsocket.write()を呼ぶと、標準ライブラリのJavaScriptがバインディングのC++関数を呼び、 バインディングがlibuvの関数を呼び、libuvがノンブロッキングのwrite()を発行します。書き切れなかった分はepollに登録して、書けるようになるのを待ちます。 メインスレッドが寝るのは、libuvがイベントループの中でepoll_waitを呼んでいるときだけです。

イベントループの各フェーズ(Node 20.3.0以降)

libuvのイベントループは、決まった順番でいくつかのフェーズを回ります。Node.js 20.3.0以降が同梱しているlibuv 1.45.0以降では、1周の流れは次のとおりです。

graph TD
  START["初回のみ<br/>ループ時刻を更新して timers を実行"] --> PEND["pending callbacks<br/>前の周から持ち越したI/Oコールバック"]
  PEND --> IDLE["idle / prepare<br/>libuvとNode.jsの内部処理"]
  IDLE --> POLL["poll<br/>タイムアウトを計算して epoll_wait で待ち<br/>届いたI/Oのコールバックを実行"]
  POLL --> CHECK["check<br/>setImmediate のコールバック"]
  CHECK --> CLOSE["close callbacks<br/>close イベントのコールバック"]
  CLOSE --> TIMERS["ループ時刻を更新して timers<br/>setTimeout / setInterval"]
  TIMERS --> ALIVE{"有効なハンドルや<br/>リクエストが残っている?"}
  ALIVE -->|はい| PEND
  ALIVE -->|いいえ| EXIT["ループ終了<br/>プロセスが終わる"]

  style POLL fill:#3b82f6,stroke:#1d4ed8,color:#fff
  style TIMERS fill:#f97316,stroke:#ea580c,color:#fff
  style START fill:#64748b,stroke:#475569,color:#fff
libuv 1.45.0(Node 20.3.0)以降のイベントループ。timersは1周の最後、check・closeの後に回る。ループ先頭でtimersを処理するのは最初の1周だけ

各フェーズで何が動くか

timersでは、期限が来たsetTimeoutとsetIntervalのコールバックを実行します。 pending callbacksは、前の周で実行しきれずに持ち越された一部のI/Oコールバック(TCPのエラー通知など)を扱います。 idleとprepareはlibuvとNode.jsの内部用で、アプリのコードが直接ここで動くことはありません。 pollはI/Oを待ち、届いたソケットの読み込みやファイル読み込み完了のコールバックを実行するフェーズで、ほとんどのアプリのコードはここで動きます。 checkではsetImmediateのコールバックを、close callbacksではsocket.on('close')のようなクローズ時のコールバックを実行します。

Node.jsは、これらのフェーズでコールバックを1つ実行するたびに、後で説明するnextTickキューとマイクロタスクキューを空にしてから次へ進みます。 そのためPromiseのthenは、どのフェーズで作られても、そのコールバックが終わった直後に実行されます。

pollはどれだけ待つのか

pollフェーズでepoll_waitに渡すタイムアウトは、libuvが毎周計算します。 すぐに処理すべきもの(持ち越しのpendingコールバック、閉じかけのハンドル、setImmediateで積まれたコールバックなど)があれば0にして、待たずに先へ進みます。 そうでなければ、いちばん近いタイマーの期限までの時間だけ待ちます。タイマーが1つもなければ、I/Oが来るまで無期限に待ちます。

たとえば100ms後に期限が来るタイマーが1つだけあり、他に何もなければ、メインスレッドはepoll_waitの中で最大100ms寝ます。 その間にソケットにデータが届けば、カーネルがスレッドを起こし、pollフェーズでそのコールバックが実行されます。 サーバーが暇なときにCPU使用率がほぼ0%になるのは、メインスレッドがこのS状態で寝ているからです。

setTimeout(0)とsetImmediateの順序

まず、setTimeout(fn, 0)は0msではありません。Node.jsは遅延が1未満の値を1msに丸めます(lib/internal/timers.js)。 そのため、メインモジュールで次のコードを実行すると、出力の順序は実行するたびに変わりえます。

// main.cjs — メインモジュールで直接呼ぶ
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));

// $ node main.cjs を何度か実行すると
// timeout → immediate のときと immediate → timeout のときがある

ループの最初の1周では、先頭でtimersを実行します。このとき、スクリプトの実行開始からループに入るまでに1ms以上経っていればタイマーの期限が来ているのでtimeoutが先に出ます。 1msに満たなければタイマーはまだ期限前なので、pollからcheckに進んでimmediateが先に出ます。 どちらになるかは、マシンの速さやそのときの負荷しだいです。

一方、I/Oのコールバックの中で同じことをすると、順序は必ずimmediateが先になります。

// io.cjs — I/Oのコールバック内で呼ぶ
const fs = require("node:fs");

fs.readFile(__filename, () => {
  // ここは poll フェーズ
  setTimeout(() => console.log("timeout"), 0);
  setImmediate(() => console.log("immediate"));
});

// 出力は常に immediate → timeout

readFileのコールバックはpollフェーズで動きます。そこから見て次に来るのはcheckフェーズなので、setImmediateが先に実行され、timersはその後です。 「I/Oの後に、タイマーより先に確実に何かをしたい」ときはsetImmediateを使います。

マイクロタスクとprocess.nextTick

イベントループのフェーズとは別に、Node.jsには2つのキューがあります。1つはprocess.nextTickのキュー、もう1つは PromiseのthenやqueueMicrotaskが積まれるV8のマイクロタスクキューです。 どちらも、今実行中のコールバックが終わった直後、次のフェーズに進む前に空になるまで処理されます。

// order.cjs と order.mjs に同じ内容を保存して実行する
setTimeout(() => console.log("setTimeout"), 0);
Promise.resolve().then(() => console.log("then"));
queueMicrotask(() => console.log("queueMicrotask"));
process.nextTick(() => console.log("nextTick"));
console.log("sync");
実行方法出力の順序
node order.cjs(CommonJS)sync → nextTick → then → queueMicrotask → setTimeout
node order.mjs(ES Modules)sync → then → queueMicrotask → nextTick → setTimeout

CommonJSでは、メインスクリプトの同期部分が終わると、Node.jsはまずnextTickキューを空にし、次にマイクロタスクキューを空にします。 だからnextTickがthenより先に出ます。 ES Modulesでは、モジュールの評価そのものがV8のマイクロタスクキューの中で行われます。同期部分が終わってもV8はまだマイクロタスクの処理中なので、 thenとqueueMicrotaskを続けて実行し、マイクロタスクキューが空になってNode.jsに制御が戻ってから、ようやくnextTickが動きます。 どちらの場合もsetTimeoutは最後で、マイクロタスクは同じ周のフェーズより必ず先に実行されます。

process.nextTickは、v22.7.0とv20.18.0でドキュメント上Legacy扱いになりました。削除予定ではありませんが、 新しいコードでは標準APIのqueueMicrotaskを使うことが推奨されています。 なお、nextTickやマイクロタスクの中でさらにnextTickやマイクロタスクを積み続けると、キューがいつまでも空にならず、イベントループは次のフェーズに進めません。 I/Oもタイマーも止まるので、再帰的に積む処理には注意してください。

スレッドプール — 何がワーカーで動くのか

ネットワークI/Oはepollやkqueueで待てますが、Linuxの通常のファイル読み込みや、getaddrinfoによる名前解決、パスワードハッシュの計算は、 そのままではノンブロッキングにできません。libuvはこうした処理をスレッドプールのワーカースレッドに渡し、ワーカーが普通のブロッキング呼び出しを行います。 終わると結果がメインスレッドに通知され、pollフェーズでコールバックが呼ばれます。

APIスレッドプールを使うか実際の仕組み
fs.readFile、fs.promises.*などの非同期fs使うワーカーがブロッキングのopen・readを呼ぶ
fs.watch使わないOSのファイル監視の仕組み(Linuxではinotify)
fs.readFileSyncなどの*Sync全般使わないメインスレッドでその場で実行する。終わるまでイベントループが止まる
dns.lookup(http.getなどが既定で使う)使うブロッキングなC関数getaddrinfoをワーカーで呼ぶ
dns.resolve*使わないc-aresがソケットでDNSサーバーに問い合わせ、epollで待つ
crypto.pbkdf2・scrypt・randomBytes(コールバック版)・randomFill・generateKeyPair使うCPUを使う計算をワーカーで実行する
zlibの非同期API(gzipなど)使う圧縮・展開の計算をワーカーで実行する
net・http・TCP・UDPのソケット使わないepoll(Linux)・kqueue(macOS)・IOCP(Windows)で待つ

見落としやすいのがdns.lookupです。http.get('https://example.com')のようにホスト名で接続すると、 名前解決は既定でdns.lookupを通るので、スレッドプールを1本使います。 ネットワーク処理はスレッドプールを使わないはずなのに、ファイル読み込みが重なると外部API呼び出しまで遅くなる、という現象はここから起きます。

スレッドプールの大きさは既定で4本、最大1024本です(libuv 1.30.0で上限が128から引き上げられました)。 変えるには、プロセス起動時に環境変数UV_THREADPOOL_SIZEを設定します。 第12章のstraceによる実測では、readFileSyncのopenatはメインスレッドのTIDで、fs.readFileのopenatはlibuv-workerスレッドのTIDで発行されていました。 ワーカースレッドは最初に使われた時点で作られます。

pbkdf2を8並列にすると完了時刻が「階段」になる

スレッドプールが4本しかないことは、CPUを使う処理を並べると目で見て確認できます。 次のコードはcrypto.pbkdf2を8本同時に投げ、それぞれの完了時刻を出力します。

// pbkdf2.cjs — 8本同時に投げて完了時刻を見る
const crypto = require("node:crypto");
const start = Date.now();

for (let i = 1; i <= 8; i++) {
  // 反復回数は1本が数百msかかる程度にマシンに合わせて調整する
  crypto.pbkdf2("secret", "salt", 600000, 64, "sha512", () => {
    console.log(`#${i} done at ${Date.now() - start}ms`);
  });
}

// $ node pbkdf2.cjs
// $ UV_THREADPOOL_SIZE=8 node pbkdf2.cjs
設定1〜4本目の完了5〜8本目の完了
既定(4スレッド)約250ms約500ms
UV_THREADPOOL_SIZE=8290〜380msの間にすべて完了(同左)

表はMac(M3 Pro)上のDockerコンテナ(node:24-bookworm-slim)で測った結果です。 既定の4スレッドでは、最初の4本がワーカーを埋めて約250msで同時に終わり、残りの4本は空いたワーカーで処理されて約500msで終わります。完了時刻が2段の階段になります。UV_THREADPOOL_SIZE=8にすると8本が同時に走るので、全部が290〜380msの間に終わります。1本あたりが250msより少し遅いのは、8本が同時にCPUを取り合うためです。

この実験から分かるのは、スレッドプールが埋まると、後から来た処理は自分の順番が来るまで何もされずに待つということです。 パスワードハッシュが4本走っている間は、無関係なファイル読み込みやdns.lookupも待たされます。 ただし、コア数を超えてプールを大きくしてもCPUの計算は速くなりません。どう設計すべきかは次章で扱います。

libuvとio_uring

第7章で見たio_uringを、libuvもLinuxで使っています。1つはepollへの登録・変更の操作をまとめて発行するバッチ化で、もう1つはファイルI/Oです。 ファイルI/Oをスレッドプールではなくio_uringで行えれば、ワーカーの取り合いを減らせます。

ただし、2024年2月にNode.jsが公表したCVE-2024-22017で問題が見つかりました。プロセスがsetuid()で権限を落とす前にlibuvがio_uringを初期化していると、 権限を落とした後もio_uring経由の操作が元の権限で実行されてしまうというものです。回避策として、環境変数UV_USE_IO_URING=0でio_uringを無効にする方法が案内されました。 libuv 1.49以降は、ファイルI/O向けのSQPOLLを使うio_uringは既定で無効になっています。 第7章で見たDockerのseccompによる制限もあるので、本番環境のファイルI/Oは今もスレッドプールで動いていると考えておくのが安全です。

まとめ — 1本のスレッドとその周りのスレッド

Node.jsでJavaScriptを実行するのはメインスレッド1本です。ネットワークI/Oはlibuvがepoll・kqueue・IOCPで待ち、 ファイル操作・dns.lookup・暗号・圧縮はスレッドプールのワーカーが実行します。「シングルスレッド」が正しいのは、JavaScriptの実行についてだけです。

イベントループはpending → idle/prepare → poll → check → close → timersの順に回り、Node.js 20.3.0以降はタイマーが1周の最後に来ます。 各コールバックの直後にはnextTickとマイクロタスクが処理され、その順序はCommonJSとES Modulesで逆になります。 スレッドプールは既定4本で、埋まると無関係な処理まで待たされます。

ここまでの話を裏返すと、メインスレッドで重い計算をするとすべての接続が止まり、スレッドプールを重い処理で埋めるとファイルや名前解決が止まる、ということになります。 次章(第10章)では、Cloudflareの2019年の障害を起こしたReDoS、巨大なJSONの処理、スレッドプールの枯渇を取り上げ、 worker_threadsやchild_processを使った逃がし方と、イベントループの遅延の計測方法を扱います。

理解度チェック

問題 0 / 50%
Q1

libuv 1.45.0(Node.js 20.3.0)以降のイベントループで、2周目以降の1周の中で各フェーズが実行される順に並べてください。

矢印ボタンで正しい順序に並べ替えてください

1poll
2pending callbacks
3check
4close callbacks
5timers