- Published on
Node.js Event Loop と libuv 内部構造完全ガイド — 6 Phase、Microtask、Worker Threads、V8 ヒープ (2025)
- Authors

- Name
- Youngju Kim
- @fjvbn20031
はじめに — 「シングルスレッド」の真実
Node.js のドキュメントには「event-driven, non-blocking I/O」とあり、チュートリアルには「Node はシングルスレッド」と書かれる。どちらも半分は正しく、半分は誤解を招く。
JavaScript 実行コンテキスト (V8) はシングルスレッドである。 しかし Node プロセス全体はシングルスレッドではない。libuv の worker thread pool はデフォルト 4 本 (最大 1024 本)、V8 自身も GC/コンパイル用スレッドを別に回している。htop で単純な Express サーバを見ても、7〜10 本のスレッドが立っている。
それでも開発者が書く JS コードは 1 本の Event Loop スレッドで順次実行される。libuv がループを管理し、V8 が JS コールバックを実行し、再び libuv に戻る。本稿ではこの 6 phase、microtask/macrotask の優先順位、setImmediate と setTimeout の競合、async/await の内部変換、CPU バウンド処理による loop block への対処 までを解剖する。
「1 スレッドでなぜ 10 万接続を捌けるのか」の答えは、OS カーネルの I/O 多重化 (epoll/kqueue/IOCP) + libuv の抽象化 + V8 の高速クロージャ呼び出しという 3 層構造にある。
1. Node.js 全体アーキテクチャ
┌──────────────────────────────────────────────────────┐
│ User JavaScript (app.js) │
└──────────────────────────────────────────────────────┘
↕ (bindings)
┌──────────────────────────────────────────────────────┐
│ Node.js Core Modules (JS + C++) │
│ fs, net, http, crypto, stream, ... │
└──────────────────────────────────────────────────────┘
↕
┌──────────────┐ ┌──────────────┐ ┌───────────────┐
│ V8 │ │ libuv │ │ OpenSSL/zlib │
│ (JS Engine) │ │ (Event Loop) │ │ (Crypto/Gzip)│
└──────────────┘ └──────────────┘ └───────────────┘
↕
┌──────────────────────────────────────────────────────┐
│ OS Kernel (Linux: epoll, macOS: kqueue, Win: IOCP) │
└──────────────────────────────────────────────────────┘
- V8: Google の JS エンジン。Parser、bytecode (Ignition)、JIT (Sparkplug/Maglev/TurboFan)、GC (Orinoco)。
- libuv: Linux の epoll、macOS/BSD の kqueue、Windows の IOCP、Solaris の event ports を統一 API で包む C ライブラリ。Event Loop、Thread Pool、DNS、File I/O、TCP/UDP、Timer を管理。
- Bindings: V8 から C++ を呼ぶ N-API。
2. libuv Event Loop — 6 phase の正体
┌───────────────────────────┐
┌─▶│ timers │ ← setTimeout / setInterval
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ pending callbacks │ ← 遅延された I/O エラー等
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ idle, prepare │ ← 内部用
│ └─────────────┬─────────────┘ ┌──────────────┐
│ ┌─────────────▼─────────────┐ │ incoming │
│ │ poll │◀─────┤ connections │
│ └─────────────┬─────────────┘ └──────────────┘
│ ┌─────────────▼─────────────┐
│ │ check │ ← setImmediate
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
└──┤ close callbacks │ ← socket.on('close')
└───────────────────────────┘
1 tick とはこれを 1 周することである。各 phase に FIFO キューがあり、準備済みのコールバックを処理してから次の phase へ進む。
2.1 Timers
setTimeout(fn, 100) は min-heap に「満了時刻 = now + 100ms」のノードを入れる。各 tick の Timers phase で heap top が現在時刻以下なら pop してコールバックを実行。つまり setTimeout は 「下限」であって精度保証ではない。Poll が長引けば遅延する。
2.2 Pending Callbacks
一部の TCP エラーなど、前の tick で発生して今実行すべきシステムコールバック。ユーザーからはほぼ見えない。
2.3 Idle, Prepare
libuv 内部用。metrics hook などの挿入点。
2.4 Poll — ほぼすべての時間を過ごす場所
ここで epoll_wait() (または kqueue/IOCP) を呼ぶ。
1. poll queue のコールバックをすべて実行
2. queue が空なら:
a. setImmediate が予約されていれば → check phase へ
b. timer が近ければ → timers phase へ
c. それ以外 → epoll_wait() でカーネルレベルでブロック
3. 新しいイベントが来たら poll queue に入れて実行
Node が idle で CPU 0% なのはここ。OS スケジューラが他プロセスに CPU を回す。
2.5 Check
setImmediate() コールバックを実行。
2.6 Close Callbacks
socket.destroy()、stream.end()、'close' リスナーがここ。
3. setImmediate vs setTimeout(fn, 0)
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
出力順は 予測不可能。
setTimeout(fn, 0)は内部でsetTimeout(fn, 1)にクランプされる。- Timers phase 到達時点で 1ms が経過しているかで勝敗が決まる。
確定順序が必要な場合 — I/O コールバック内部:
const fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
})
この場合は 常に immediate が先。fs コールバックが Poll phase で動き、次の phase は Check (setImmediate) だから。
4. Microtask — Event Loop の外側
4.1 2 つの microtask queue
process.nextTickqueue: Node 固有。Promise より優先度が高い。- Promise microtask queue: V8 標準。
各 phase のコールバック 1 個を実行するたびに:
1. process.nextTick queue を全て空にする
2. Promise microtask queue を全て空にする
3. 次のコールバック
4.2 実験
setImmediate(() => console.log('1. setImmediate'))
setTimeout(() => console.log('2. setTimeout'), 0)
Promise.resolve().then(() => console.log('3. promise'))
process.nextTick(() => console.log('4. nextTick'))
console.log('5. sync')
出力:
5. sync
4. nextTick
3. promise
2. setTimeout (または 1 番が先のこともある)
1. setImmediate
4.3 process.nextTick が危険な理由
function explode() {
process.nextTick(explode)
}
explode()
これは無限ループ。nextTick queue が空にならない限り次の phase に進まないので、I/O も timer も全て starvation する。Promise.resolve().then(...) の再帰でも同じ。再帰 async は必ず setImmediate か setTimeout で loop に息を吐かせる。
4.4 Node 11 で変わった点
Node 10 までは phase 全体が終わってから microtask を処理していた。Node 11 以降はブラウザと同じく 各コールバックの間で microtask を処理する。古いチュートリアルはここで嘘になる。
5. Thread Pool — シングルスレッド神話のひび
5.1 Worker Pool (UV_THREADPOOL_SIZE)
libuv はデフォルト 4 本の worker thread pool を持つ。以下はそこに回される:
- File I/O (ほとんどの
fs.*) - DNS 解決 (
dns.lookup、glibc のgetaddrinfo) - CPU-heavy crypto (
crypto.pbkdf2、bcrypt、scrypt、argon2) zlib圧縮
UV_THREADPOOL_SIZE=16 (最大 1024) は アプリ起動前にセットする。
5.2 なぜ File I/O は Thread Pool なのか
Linux の epoll は 通常ファイルを監視できない。ファイル読みは常にブロッキング (io_uring 登場前まで)。libuv は worker thread でブロッキング read/write を実行し、完了を main loop に非同期通知する。ソケットは epoll で監視できるので Pool を使わない。Node がネットワークに強く大量ファイル I/O に弱いのはここ。
5.3 io_uring の希望
Node 20+ で libuv は Linux の io_uring 実験サポートを追加 (UV_USE_IO_URING=1)。ファイル I/O も本物の非同期でカーネル処理 → Pool を迂回できる。段階的にデフォルトへ。
5.4 Pool Starvation
for (const user of users) {
bcrypt.hash(user.password, 10, callback)
}
bcrypt が 4 本の worker を占有し、他の fs 呼び出しが全部待たされる。対策: UV_THREADPOOL_SIZE を増やす、Worker Threads に分離、p-queue で同時実行数制御。
6. Worker Threads — 真の並列 JS
6.1 なぜ必要か
libuv の worker pool は C++ タスク専用。JS コードで CPU 集約処理を書くと main loop がブロックされる。
// 全員を巻き込んでブロック
app.post('/process', (req, res) => {
const result = heavyMatrixMultiplication(req.body.matrix)
res.json(result)
})
6.2 API
// main.js
const { Worker } = require('worker_threads')
const w = new Worker('./heavy.js', { workerData: matrix })
w.on('message', (result) => res.json(result))
// heavy.js
const { parentPort, workerData } = require('worker_threads')
parentPort.postMessage(heavyMatrixMultiplication(workerData))
各 worker は独立した V8 isolate (独自ヒープ、独自 loop)。postMessage (structured clone) で通信。SharedArrayBuffer + Atomics で共有メモリも可能。
6.3 Worker / Cluster / child_process
| 方式 | プロセス分離 | メモリ共有 | 起動時間 | 用途 |
|---|---|---|---|---|
| Worker Threads | なし | SharedArrayBuffer | ~30 ms | CPU バウンド JS |
| Cluster (fork) | あり | IPC のみ | ~100 ms | HTTP サーバ水平スケール |
| child_process.spawn | あり | stdio/IPC | ~100 ms+ | 外部バイナリ |
| child_process.fork | あり | IPC のみ | ~100 ms | 他の Node スクリプト |
6.4 Piscina
Worker を毎回新規作成すると 30〜50ms ずつオーバーヘッド。Piscina は worker pool の抽象化で再利用と自動拡張を提供する。Node プロダクションでの標準。
7. async/await の内部
async function foo() {
return 42
}
// ≡
function foo() {
return Promise.resolve(42)
}
V8 は async 関数を state machine にコンパイルする。各 await は resume point。Promise が resolve すると resume ジョブが microtask として enqueue され、次の microtask phase で実行される。
console.log('1')
demo()
console.log('2')
出力は 1, A, 2, B, C。demo() は最初の await まで同期で走り、そこで返る。
async はタダではない。2018 年の zero-cost async で Promise 生成は減ったが、tight loop で await するとやはり遅い。
// 遅い: 1 万回 × await → 1 万 microtask
for (const item of items) await validate(item)
// 速い
await Promise.all(items.map(validate))
8. I/O モデル比較
Thread-per-Connection (従来の Rails/Java)
1 万接続 × 8MB stack = 80GB RAM、コンテキストスイッチコストも爆発。
Node (event-driven)
1 スレッド + epoll で 1 万 fd を監視。数百 MB で捌ける。CPU バウンドに弱い。
Go / Elixir (M:N)
Goroutine (2KB stack) / Erlang process (~300B)。ランタイムがスケジュール、開発者は同期風に書ける。
Node の強み: エコシステム、フロント/バック同言語、V8 の速さ。弱み: 1 つのブロッキング処理で全体が止まる。
9. Loop Block の診断
9.1 Event Loop Lag 計測
const { monitorEventLoopDelay } = require('perf_hooks')
const h = monitorEventLoopDelay({ resolution: 20 })
h.enable()
setInterval(() => {
console.log('p99 delay (ms):', (h.percentile(99) / 1e6).toFixed(2))
h.reset()
}, 5000)
p99 が 100ms を超えると体感遅延が始まる。
9.2 CPU プロファイリング
node --inspect app.js
# または
npx 0x app.js
9.3 よくある 5 つの犯人
- 大きな JSON parse/stringify → streaming parser (stream-json)。
- 同期 fs API (
fs.readFileSyncなど) → async 版へ。 - 正規表現の catastrophic backtracking → RE2 バインディング。
- Event Loop 上の bcrypt → Worker Thread に分離。
- 100 万件の tight for-loop → batch +
setImmediateで yield。
10. V8 — JS が実際に走る場所
10.1 コンパイルパイプライン (2024〜2025)
Source → Parser → AST → Ignition (bytecode)
→ (interpreter) → Sparkplug (baseline) → Maglev (mid-tier) → TurboFan
Tiered JIT。ホットなコードほど深い最適化層へ。
10.2 Hidden Class (Shape)
function Point(x, y) { this.x = x; this.y = y }
const p1 = new Point(1, 2) // Shape A
const p2 = new Point(3, 4) // Shape A (再利用)
p2.z = 5 // Shape B へ遷移 (deopt)
ホットパスでは生成後のプロパティ追加/削除を避ける。
10.3 GC — Orinoco
- Young (Scavenger): 速い Minor GC
- Old (Mark-Sweep-Compact): incremental/concurrent marking
--max-old-space-size=4096 で old space 上限。Node 18+ は cgroup 制限を検出する。
11. libuv 内部データ構造
| 用途 | データ構造 |
|---|---|
| Timer | min-heap |
| Pending queue | intrusive doubly linked list |
| Thread pool tasks | FIFO queue + condition variable |
| DNS 解決 | c-ares + worker pool |
uv_run(loop, UV_RUN_DEFAULT) が 6 phase をめぐる関数そのもの。UV_RUN_ONCE で 1 tick だけ回せる (テストや埋め込みで使う)。
12. Stream API と backpressure
// 悪: 2GB ファイルをメモリに一気に
const data = fs.readFileSync('/big.csv')
// 良: chunk 単位
fs.createReadStream('/big.csv')
.pipe(csvParser())
.pipe(uploader)
デフォルト 16KB の chunk。highWaterMark を超えると .write() が false を返し、producer は 'drain' まで待つ。pipeline() (または stream/promises) が安全。
Node 18+ は WHATWG Web Streams も標準サポート。Readable.fromWeb / .toWeb で相互変換。Fetch、Undici、Bun は Web Streams 前提。
13. Cluster とスケール
const cluster = require('cluster')
const os = require('os')
if (cluster.isPrimary) {
for (let i = 0; i < os.cpus().length; i++) cluster.fork()
} else {
require('./app')
}
全 worker が同じ TCP ポートを共有。SO_REUSEPORT 対応ならカーネルがラウンドロビン。現代的には「コンテナ 1 個 = 1 Node プロセス」で Kubernetes にスケーリングを任せる方が単純。
14. AbortController
const controller = new AbortController()
const { signal } = controller
fetch('/api', { signal })
fs.readFile('/big', { signal }, cb)
setTimeout(() => controller.abort(), 5000)
Node 15+ で Web 標準をコアサポート。Abort で Promise が reject されるが、進行中のリソース解放は自動ではない。ネットワークはソケット close、fs は途中のものは最後まで走ることがある。
15. 診断
node --inspect=0.0.0.0:9229 app.js
const v8 = require('v8')
v8.writeHeapSnapshot('/tmp/heap.heapsnapshot')
AsyncLocalStorage (async_hooks ベース) はリクエスト別 context の非同期伝播を担う。Python の contextvars、Go の context.Context 相当。OpenTelemetry Node はこれで trace を伝搬する。diagnostics_channel (Node 16+) は APM 連携の標準 hook point。
16. セキュリティ
Permissions Model (Node 20+)
node --permission --allow-read=./data --allow-write=./logs app.js
Deno に触発された権限モデル。fs/child_process/worker_threads を制限。サプライチェーン攻撃対策に有用。
npm 攻撃面
- Typosquatting、dependency confusion、post-install script。
- 対策:
npm ci+ lockfile、--ignore-scripts、Socket/Snyk/Dependabot、内部レジストリ (Artifactory/Verdaccio)。
Prototype Pollution
Object.create(null) や Object.hasOwn() の活用を推奨。
17. Bun、Deno と Node
- Bun: Zig 実装、JavaScriptCore、独自 event loop。ベンチで Node の 2〜4 倍。プロダクション実績はまだ。
- Deno: V8 + Rust + tokio。TS 標準、権限モデル、URL import。Deno 2 で npm 互換性が大幅向上。
- Node の対応: 組み込み test runner、
--watch、--env-file、Permission model、Web Streams、fetch、WebSocket。
2025 年の構図: Node がデフォルト、Bun は実験/高速プロトタイプ、Deno は TS 中心の新規案件。
18. 実戦チェックリスト
--max-old-space-sizeをコンテナメモリに合わせる- Event loop lag を監視
UV_THREADPOOL_SIZEを CPU コア数に合わせる- SIGTERM での graceful shutdown
- Log rotation (pino + pino-roll)
- HTTP keep-alive 設定
unhandledRejection/uncaughtExceptionハンドラ登録- Health / readiness probe
- コンテナ内の PID 1 問題 (
--init) - 依存スキャンを CI に統合
パフォーマンス: 同期 API 除去、巨大 JSON を streaming、tight loop は Promise.all、CPU 処理は Worker Threads、HTTP は undici + agent 共有。
おわりに — Event Loop を頭の中に描く
次の問いに即答できるなら、「なぜ API が遅いか」のデバッグが楽になる:
- HTTP コールバックはどの phase で走るか (Poll)
JSON.stringifyが巨大オブジェクトでブロックしたら何が起きるか (ループ全停止)await db.query(...)の瞬間に Event Loop は何をしているか (他の I/O 処理)process.nextTickを再帰呼び出しするとなぜ無限ループか (次 phase へ進めない)- Worker Thread と Cluster Worker の違い (同プロセス vs 別プロセス)
Node.js は V8 + libuv + OS カーネルの三層協演。その中心に 20 年進化し続ける Event Loop がある。次回は WebSocket と Server-Sent Events の内部 — リアルタイムプロトコルが TCP 上でどう動き、Socket.IO のようなライブラリがどうフォールバックし、10 万接続を捌く設計パターン (pub/sub、sticky session、Redis adapter) までを掘る。