シングルスレッドの JavaScript は、なぜ非同期 I/O を同時に捌けるのか?
あなたも fetch() や setTimeout() を書いて、その裏で通信やタイマーが「同時に」進んでいるように感じたことがあるはずだ。JavaScript はシングルスレッドだと聞いたことがあるはずなのに、それならいったいどこで並行処理が起きているのか、説明できるだろうか。
- JS のコールスタックは 1 本しかなく、コールバックは 1 つずつ順番にしか実行されない。並行に見えるのは、I/O の実体が JS エンジンの外側(OS カーネルやスレッドプール)で進んでいるからだ
- コールスタックが空になるたびに、まずマイクロタスク(Promise など)をキューが空になるまで全部処理し、そのあとマクロタスク(setTimeout など)を 1 個だけ処理する、という順序をイベントループが繰り返している
- Node.js では libuv がこのループを timers→pending callbacks→poll→check という具体的なフェーズに分けており、ネットワーク I/O は OS の epoll 等で、ファイル I/O や DNS 解決は既定 4 スレッドのスレッドプールで処理するという役割分担がある
「シングルスレッド」が指しているのはエンジンの実行、I/O の実体ではない
JavaScript エンジンには、実行中の関数呼び出しを積み上げるコールスタックが 1 本しかない。今実行している関数が最後まで終わるまで次の関数は始まらず、途中で他のコールバックに割り込まれることもない。この性質を run-to-completion と呼び、2 つの Promise コールバックやタイマーコールバックが同時に入り乱れて実行されることはない。
ただし「シングルスレッド」という表現が指しているのはこの実行部分だけの話であって、ソケットにデータが届くのを待つ、ディスクからファイルを読む、DNS に問い合わせる、といった I/O の実体まで 1 本のスレッドが順番にこなしているわけではない。これらの完了待ちは JS エンジンの外側、つまり OS カーネルやランタイムが用意した別のスレッドで並行して進行しており、JS スレッドはあとから届く完了通知を受け取って処理するだけだ。
コールスタックが空くたびに、2 段のキューを順番に空にする
この「完了通知を受け取って処理する」役目を担うのがイベントループだ。コールスタックが空になるたびに、待機中のコールバックをキューから取り出して実行する繰り返し処理のことを指す。待機中のコールバックは 1 本のキューにまとまっているのではなく、優先度の異なる 2 種類のキューに分かれている。Promise の .then() や queueMicrotask() で登録したコールバックはマイクロタスクキューに、setTimeout() や DOM イベント、ネットワーク応答のコールバックはマクロタスク(タスク)キューに積まれる。
イベントループはコールスタックが空になるたびに、まずマイクロタスクキューが空になるまで処理し切ってから、マクロタスクキューの先頭から 1 個だけ取り出して実行し、また最初に戻る。次のコードを実行すると、書いた順番ではなくこのルールに従った順に出力される。
Promise.resolve().then(() => console.log("A: マイクロタスク"));
setTimeout(() => console.log("B: マクロタスク"), 0);
console.log("C: 同期コード");
// 出力順: C → A → B
呼び出し中の関数(コールスタック)
│ 空になった
▼
┌───────────────────────┐
│ マイクロタスクキュー │← Promise.then 等
│ 空になるまで全部実行 │
└──────────┬────────────┘
▼
┌───────────────────────┐
│ マクロタスクキュー │← setTimeout 等
│ 先頭から1個だけ実行 │
└──────────┬────────────┘
│ 完了通知を積む
▼
OS / スレッドプール
(JSエンジンの外側)
setTimeout(fn, 0)は「今すぐ」の意味ではない
ここまでの説明だけを読むと、setTimeout(fn, 0)と書けば 0 ミリ秒後、つまりほぼ即座にコールバックが実行されると考えたくなる。しかし実際には、直前までに積まれたマイクロタスクがすべて片付くまでマクロタスクの実行順は回ってこないため、同じタイミングで Promise の .then() を仕掛けていれば、そちらが先に動く。
加えて HTML 標準では、setTimeout の呼び出しがネストして 5 階層以上重なった場合、指定した遅延が 4 ミリ秒未満であっても実際の遅延は 4 ミリ秒に切り上げられると定めている。ネストしたタイマーを遅延 0 で延々と呼び続けるコードがメインスレッドの実行機会を独占し続けるのを防ぐための下限値だ。
一人だけのシェフが仕切る厨房を思い浮かべるとよい。シェフ(コールスタック)は一度に一皿しか調理せず、途中で別の注文に手を出すことはない。ただし「オーブンで焼く」「仕入れ業者から食材が届くのを待つ」といった時間のかかる作業は、シェフ自身がその場に突っ立って待つのではなく、オーブンや業者(OS カーネルやスレッドプール)に任せてしまう。焼き上がりや配達が完了すると、その完了票が受け取り箱(キュー)に置かれ、シェフは今の一皿を出し終えたタイミングでそれを取りに行く。注文がいくつも同時にこなされているように見えるのは厨房の外の設備が並行して動いているからで、シェフ自身は最後まで一皿ずつしか触っていない。
Node.js はこのループを libuv の複数フェーズに分解している
ブラウザのイベントループは、ここまで説明したマイクロタスク・マクロタスクの 2 段モデルでおおむね説明できる。Node.js ではこのループの実装をlibuv(Node が OS の非同期 I/O 機構をラップして提供する C 言語製のライブラリ)が担っており、内部は timers → pending callbacks → poll → check → close callbacks という順に進む具体的なフェーズに分かれている。timers フェーズは期限が来た setTimeout/setInterval を、poll フェーズは新しい I/O イベントの取得と大半の I/O コールバックの実行を、check フェーズは setImmediate() を、というようにフェーズごとに実行するコールバックの種類が決まっている。
このフェーズ分けの裏には、I/O の種類によって並行処理の仕組みが違うという役割分担がある。ネットワークソケットの読み書きは Linux の epoll や macOS の kqueue といった OS 機構でノンブロッキングに監視されており、スレッドを消費しない。一方でファイルシステム操作や DNS 名前解決(getaddrinfo)には OS 側にノンブロッキングな標準 API が揃っていないため、libuv は既定 4 スレッドのスレッドプール(ファイル I/O や DNS 解決などをバックグラウンドの複数スレッドに投げて処理する仕組み)にその処理を回している。スレッドプールのサイズは環境変数 UV_THREADPOOL_SIZE で変更でき、既定値の 4 のまま大量のファイル読み込みや DNS 解決を同時に走らせると、スレッドの空きを待つ待機列ができ、I/O 自体は速くてもコールバックの実行がまとめて詰まって見えることがある。
Promise チェーンの直後に置いたはずの setTimeout のコールバックが「思ったより後に」動く、という体験は大抵ここで説明した優先順位の話に行き着く。また Node.js で bcrypt のハッシュ化や sharp の画像リサイズのようにスレッドプールを使うライブラリを大量に同時実行すると、CPU やディスクにはまだ余裕があるのに処理がなかなか終わらないことがある。これはスレッドプールの既定サイズ 4 がボトルネックになっているサインで、UV_THREADPOOL_SIZE の見直しが判断材料になる。
マイクロタスクとマクロタスクの実行順を、Node.js で実際に目で確認する。
node -e "
console.log('1: 同期処理(冒頭)');
setTimeout(() => console.log('4: マクロタスク setTimeout'), 0);
Promise.resolve().then(() => console.log('3: マイクロタスク Promise'));
console.log('2: 同期処理(末尾)');
"
出力は登録した順番(1 → setTimeout → Promise → 2)ではなく、1 → 2 → 3 → 4 の順になるはずだ。同期コードが最後まで実行されてコールスタックが空になったあと、マイクロタスクの Promise コールバックがマクロタスクの setTimeout コールバックより先に処理されることが確認できる。
- setTimeout(fn, 0) は 0 ミリ秒後にすぐ実行される — 実際にはマイクロタスクの処理が先に回り込み、さらにネストが深いタイマーは仕様上 4 ミリ秒未満の指定でも 4 ミリ秒に切り上げられる。
- 非同期処理は JS のどこか別のスレッドで並行に動いている — JS 自体のコールスタックは 1 本しかなく、コールバックは一つずつ順番にしか実行されない。並行しているのは OS カーネルやスレッドプールの側の話だ。
- Node.js のイベントループは全ての I/O をスレッドプールで処理している — ネットワーク I/O は epoll のような OS 機構でスレッドを使わずに処理され、スレッドプールが使われるのはファイル I/O や DNS 解決など一部の処理に限られる。
- コールスタック
- 実行中の関数呼び出しを積み上げる領域。JS エンジンには 1 本しかない。
- マイクロタスク
- Promise の
.then()やqueueMicrotask()で登録され、コールスタックが空くたびにキューが空になるまで処理されるコールバック。 - マクロタスク
setTimeoutや DOM イベントなどのコールバック。マイクロタスクを処理し終えたあとに 1 個だけ実行される。- イベントループ
- コールスタックが空になるたびに待機中のコールバックをキューから取り出して実行する繰り返し処理。
- libuv
- Node.js が OS の非同期 I/O 機構をラップして提供する C 言語製のライブラリ。イベントループの実装本体。
- スレッドプール
- ファイル I/O や DNS 解決など、OS にノンブロッキング API がない処理をバックグラウンドの複数スレッドに投げる仕組み。既定サイズは 4。
- Event loop | MDN — マイクロタスクとマクロタスクの優先順位、run-to-completion を定義する公式リファレンス。
- The Node.js Event Loop, Timers, and process.nextTick() — libuv のフェーズ構成と process.nextTick の扱いを解説する Node.js 公式ドキュメント。
- Thread pool work scheduling | libuv — スレッドプールの既定サイズ 4 と UV_THREADPOOL_SIZE による変更方法を定義する libuv 公式ドキュメント。