- ブラウザURL入力
- DNS名前→IP
- ルータ経路選択
- ロードバランサ振り分け
- Nginx受け口
- アプリ処理今ここ
- 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 です。
- ・預けられるのは「待ち」だけ。計算に預け先は無い
- ・スレッドプールはネットワーク I/O には使われない(libuv: network I/O is always performed in a single thread)
フェーズという段と、図と箇条書きの読み方
ドキュメントは、ループの1周を「フェーズ」という段に分けて説明します。
timers から始まる旧版の図を覚えていると足をすくわれます。現在の箇条書きは timers を最後に置いています。
順 フェーズ そこで実行されるもの1 pending callbacks 次の周回に繰り延べた I/O コールバック2 idle, prepare 内部でのみ使用3 poll 新しい I/O イベントと I/O コールバック4 check setImmediate() のコールバック5 close callbacks socket.on('close', ...) など一部の close6 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本投げました。
// ハンドラ: 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/ & waitA 200 time_total=3.00B 200 time_total=6.00
// ハンドラ: 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/ & waitB 200 time_total=3.01A 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 はシングルスレッドだから重い処理に向かない」の、どこが不正確ですか。
Node のプロセス — JS の実行単位が1つ
待ちは預けられるので I/O は並びます。しかし JavaScript を走らせる単位がプロセス内に1つなので、CPU を使い続ける処理は全接続を待たせます。実機の 3.0 / 6.0 秒がそれです。
処理の中身 2本同時に投げた結果setTimeout 3秒 両方とも約3.0秒同期ビジーループ3秒 3.0秒 / 6.0秒
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の言語機能) です。
自分の現場で確かめる
読んだだけでは身につきません。手元の環境で実際に見てみてください。
理解度チェック
読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。
ビジーループ版は 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 が該当し、待ちを預ける非同期呼び出しは当たらない。