Skip to content
Published on

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

Authors

はじめに — 「シングルスレッド」の真実

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'))

出力順は 予測不可能

  1. setTimeout(fn, 0) は内部で setTimeout(fn, 1) にクランプされる。
  2. 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.nextTick queue: 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 は必ず setImmediatesetTimeout で 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.pbkdf2bcryptscryptargon2)
  • 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 msCPU バウンド JS
Cluster (fork)ありIPC のみ~100 msHTTP サーバ水平スケール
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, Cdemo() は最初の 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 つの犯人

  1. 大きな JSON parse/stringify → streaming parser (stream-json)。
  2. 同期 fs API (fs.readFileSync など) → async 版へ。
  3. 正規表現の catastrophic backtracking → RE2 バインディング。
  4. Event Loop 上の bcrypt → Worker Thread に分離。
  5. 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 内部データ構造

用途データ構造
Timermin-heap
Pending queueintrusive doubly linked list
Thread pool tasksFIFO 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) までを掘る。