Dev Study
← アプリケーションの土台
レッスン 10 / 11·約 9 分·第7期 アプリケーションの土台

ランタイム — Node.js は1スレッドで「待つ」をどう扱うか

リクエストがアプリケーションに届くまで
  1. ブラウザURL入力
  2. DNS名前→IP
  3. ルータ経路選択
  4. ロードバランサ振り分け
  5. Nginx受け口
  6. アプリ処理今ここ
  7. DB保存

1本の実行が止まらない理由と、止まる条件を見ます。前段が正しくても、この1本が塞がれば要求は入口で並びます。

まず結論

JavaScript を動かすスレッドは既定で1本ですが、「待つ」仕事はそこで抱えません。ネットワークの待ちはカーネルのイベント通知機構(ポーラ。epoll / kqueue)に、ファイル I/O と名前解決は libuv のスレッドプールに預けられます。だから1本でも多数を捌けます。

預けられるのは待ちだけです。CPU を使い続ける処理はそのスレッドを占有します。実機では3秒のビジーループを回すサーバに2本投げると、2本目は約6秒かかりました。

? 読む前に予測

ハンドラで3秒間回り続けるループを書いた Node のサーバに、2つの要求をほぼ同時に送ると、2つ目は何秒で返るでしょうか。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

待ちを自分で抱えなければ、1本でも足りる

ここまでレスポンスを組み立てるところを見てきました。一段引いて、それを誰が走らせているかを見ます。

読むべき語は2つ。a single JavaScript thread — 1本なのは JavaScript を動かすスレッドで、プロセス全体ではありません。そして offloading operations to the system kernel — 待つ仕事は預けている。

預ける先はカーネルだけではありません。libuv の Design overview は、待ち方が2系統に分かれると書きます。

ネットワークはポーラで待てます。ファイルは待てる仕組みが無いので、別のスレッドで同期のまま呼び、終わったらループに知らせる。引き受けるのは3種類 — ファイルシステム操作、getaddrinfo と getnameinfo、uv_queue_work に渡したコード。既定4、上限 1024 です。

Node のプロセスの中で、待ちが預けられる先
JS スレッド(1本)ハンドラはここだけで走る
非同期の呼び出しで待ちを預ける
libuv のイベントループフェーズを順に回す
待ち方は2系統
カーネルのポーラepoll / kqueue / event ports / IOCP
ネットワーク I/O
スレッドプール(既定4)ファイル操作・getaddrinfo
ポーラで待てないもの
終わったものからコールバックが積まれる
各フェーズの FIFO 行列poll / check / timers など
  • ・預けられるのは「待ち」だけ。計算に預け先は無い
  • ・スレッドプールはネットワーク I/O には使われない(libuv: network I/O is always performed in a single thread)
具体実物

フェーズという段と、図と箇条書きの読み方

ドキュメントは、ループの1周を「フェーズ」という段に分けて説明します。

timers から始まる旧版の図を覚えていると足をすくわれます。現在の箇条書きは timers を最後に置いています。

Node.js ドキュメント Phases Overview の並び(現在のページの箇条書き順)表
順 フェーズ そこで実行されるもの
1 pending callbacks 次の周回に繰り延べた I/O コールバック
2 idle, prepare 内部でのみ使用
3 poll 新しい I/O イベントと I/O コールバック
4 check setImmediate() のコールバック
5 close callbacks socket.on('close', ...) など一部の close
6 timers setTimeout() / setInterval() のコールバック

同じページに、こう注記されています。

同じページの ASCII 図もこの動きです。最上段の timers はループの外で、そこから pending callbacks に入り、最下段の timers から pending callbacks へ戻ります。入る前に1回、以後は poll の後で、図と箇条書きは一致しています。「timers から始まる」は libuv 1.45.0 より前としては正しく、それ以降は当てはまりません。ループに入る前に一度だけ timers が走る後方互換まで含めると、二択の問いではなくなります。

✓ ここまでの確認

ファイルの読み込みとネットワークの受信は、それぞれどこで待たれていますか。

抽象原理

預けられるのは待ちだけで、計算には預け先が無い

一文にすると、Node が1本で多数を捌けるのは、時間のかかる部分が「待ち」だからです。 待ちは外に出せるので、JS のスレッドは空きます。

では、時間のかかる部分が待ちでなかったら。図のどこにも、計算を預ける先はありません。CPU を使い続ける処理は JS のスレッドに居座り、ループは次のフェーズに進めません。poll で取れるはずの別の接続のコールバックも実行されません。

ここでブラウザ編との線引きをします。ブラウザ編『イベントループ — タスクは集合、マイクロタスクは待ち行列』の「タスクキューは set」は、HTML Standard §8.1.7.1 の規定です。Node のフェーズは libuv 由来で、規定する文書が別。名前が同じでも、片方で確かめた性質は持ち込めません。

具体実物

3秒を占有するか、3秒預けるか

ハンドラの中身だけが違う2つのサーバに curl を2本投げました。

同期ビジーループ版(Node v24.21.0、macOS、ポート 3995。1回の計測、time_total は小数2桁に丸めた)コマンド出力
// ハンドラ: const t = Date.now(); while (Date.now() - t < 3000) {} res.end("busy done");
 
$ curl -s -o /dev/null -w "A %{http_code} time_total=%{time_total}\n" http://127.0.0.1:3995/ & curl -s -o /dev/null -w "B %{http_code} time_total=%{time_total}\n" http://127.0.0.1:3995/ & wait
A 200 time_total=3.00
B 200 time_total=6.00
setTimeout 版(Node v24.21.0、macOS、ポート 3994。1回の計測、time_total は小数2桁に丸めた)コマンド出力
// ハンドラ: setTimeout(() => res.end("timer done"), 3000);
 
$ curl -s -o /dev/null -w "A %{http_code} time_total=%{time_total}\n" http://127.0.0.1:3994/ & curl -s -o /dev/null -w "B %{http_code} time_total=%{time_total}\n" http://127.0.0.1:3994/ & wait
B 200 time_total=3.01
A 200 time_total=3.01

どちらも「3秒かかる処理」です。違うのは、その3秒を自分で数えているか、預けているかだけ。ビジーループは JS のスレッドを離さないので2本目は1本目の後に始まり約6秒。setTimeout は即座に制御を返すので2本とも約3秒です。

大事なのは、2本目も接続そのものは成立している点です。カーネルが接続を受け取る仕事と、JavaScript が中身を処理する仕事は別々に進みます。遅れるのは後者だけです。

同じ型は実験中にもう一度出ました。server.listen() のコールバックから execSync("curl ...") を呼ぶと、curl は接続できたまま応答を受け取れず、プロセスは止まりました。JS スレッドが埋まり、accept 済みの接続を読めないためです。

✓ ここまでの確認

「Node.js はシングルスレッドだから重い処理に向かない」の、どこが不正確ですか。

⇄ 2つを見比べる
具体例 A

Node のプロセス — JS の実行単位が1つ

待ちは預けられるので I/O は並びます。しかし JavaScript を走らせる単位がプロセス内に1つなので、CPU を使い続ける処理は全接続を待たせます。実機の 3.0 / 6.0 秒がそれです。

何が並び、何が並ばないか(実機の観測)表
処理の中身 2本同時に投げた結果
setTimeout 3秒 両方とも約3.0秒
同期ビジーループ3秒 3.0秒 / 6.0秒
具体例 B

nginx のワーカー — 実行単位が複数に分かれている(第39週)

第39週『無停止で入れ替える — そして期末の切り分け』で、設定の反映が新旧のワーカーの入れ替えとして起きることを見ました。前提は、ワーカーが複数あり独立に接続を持つ構造です。実行単位が分かれていれば、1つが塞がっても残りは動きます。

実行単位の切れ目表
JS/処理の実行単位 1つが塞がると
Node のプロセス プロセス内に1つ そのプロセスの全接続
nginx のワーカー ワーカーごと そのワーカーの接続

どちらも「1本のスレッドで多数を捌く」と説明されます。それでも止まる条件が違うのはなぜでしょうか。

「1本」が数えているものを、それぞれ言葉にしてみてください。

この章のまとめ

  • 1本なのは JavaScript を動かすスレッド。ドキュメントも a single JavaScript thread is used by default と対象を限る。プロセスが1スレッドではない。
  • 待ちの預け先は2系統。ネットワークはポーラ(epoll / kqueue / event ports / IOCP)、ファイル操作と getaddrinfo/getnameinfo は libuv のスレッドプール(既定4、上限 1024)。
  • CPU を使い続ける処理には預け先が無いので、JS のスレッドを占有し他の接続を待たせる(実機: 3.0 秒 / 6.0 秒)。
  • Node のフェーズは libuv 由来で、HTML 仕様のイベントループとは規定する文書が別。ブラウザ編で確かめた性質を持ち込まない。
  • ドキュメントは箇条書きで timers を最後に置き、libuv 1.45.0 以降 timers が poll の後に走ると注記する。ASCII 図も同じ動きを描く。旧版の「timers から始まる」図は当てはまらない。
  • 「1本で多数を捌く」と説明されたら、何の1本かと、それが塞がると何が止まるかを確かめる。

この先の学びにどう繋がるか

並行処理の型は既存のクイズ群で広げられます。サーバーのしくみ の サーバーのしくみ III(並行処理モデル)・サーバーのしくみ VI(具体的なサーバー製品)、フロントエンド の フロントエンド IV(JavaScriptの言語機能) です。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

環境:

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 3正解: 0

ビジーループ版は 3.0 秒 / 6.0 秒、`setTimeout` 版は両方とも約 3.0 秒でした。この差はどこから生じますか。

用語集(4語)
イベントループ(Node)
libuv が回す繰り返し。pending callbacks / idle, prepare / poll / check / close callbacks / timers を「フェーズ」と呼ぶ。HTML 仕様とは由来が別。
ポーラ
多数のソケットの状態変化をまとめて待つカーネルのイベント通知機構。libuv は Linux で epoll、macOS と BSD で kqueue、SunOS で event ports、Windows で IOCP を使う。
スレッドプール(libuv)
ファイルシステム操作・getaddrinfo/getnameinfo・uv_queue_work に渡されたコードを走らせるスレッドの集まり。既定 4、UV_THREADPOOL_SIZE で変更でき上限 1024。
ブロッキング(この文脈での)
JavaScript のスレッドが制御を返さないまま時間を使うこと。同期のビジーループや同期版の I/O が該当し、待ちを預ける非同期呼び出しは当たらない。