はじめに
業務でTypeScriptを扱うことになりました。
前の業務ではC#を使っていたので文法はなんとなく読めるんですが、そもそもJSとNodeが全く理解できてないのでそちらから勉強を始めました。
JSがシングルスレッドのなのは知っていたのですが、じゃあどうやって複数リクエストだったり非同期を実現しているのかというところでイベントループから書いていきます。
JS/TS/Node.jsに対する前提知識
シングルスレッドである
最重要
JavaScript コードそのものは、原則として 単一のメインスレッド で実行されます。
C# のようにユーザがスレッドプールを細かく扱うわけでもなく、PHP のようにリクエストごとに別プロセスを立ち上げるわけでもありません。
その代わり、イベントループとキューを使って複数の非同期処理を並行(≠並列)実行する仕組みを実現しています。
なお、Node.js の内部では I/O 処理を担う libuv のスレッドプールがあり、ファイル操作や DNS ルックアップなど一部の処理は並列化されています。
また、worker_threads
や child_process
、cluster
を利用すれば、複数のスレッド/プロセスを明示的に扱うことも可能なようです(ここでは割愛)。
イベントループ
Nodeの公式ドキュメントを読むと、処理をキューに溜め込んでポーリングしながら実行してるよみたいなことが書かれています。
細かいのですが、実行環境によって挙動が少し違うようで、今回はNode.jsのイベントループについて書いていきます。
macrotask queue(タクスキュー)
-
setTimeout
,setInterval
,I/Oコールバック
などがここに積まれる - 1つのタスク(macrotask)が完了すると、イベントループは次のタスクを取り出して実行する
microtask queue(マイクロタスクキュー)
-
Promise
のthen
コールバックやasync/await
の後続処理、process.nextTick
の処理がここに積まれる - macrotask が終わった “直後” に、優先的に microtask queue が空になるまで実行される
- この順序の違いにより、
Promise.then
がsetTimeout
より先に処理されるなど、実行タイミングに差が生じる
イベントループの流れ
- トップレベルの同期処理を実行
- microtask queue をすべて実行
- macrotask を 1 つ取り出して実行
- 2~3を繰り返す
要するにキューにmacrotask
, microtask
という単位で処理を溜めていき順番に実行するということになります。
このときmicrotask
はキュー単位で実行され、溜まっているmicrotask
を一気に処理させます。
サンプルコード
console.log("A: start");
setTimeout(() => {
console.log("B: setTimeout callback");
}, 0);
Promise.resolve().then(() => {
console.log("C: promise.then callback");
});
console.log("D: end");
$ npx ts-node sample.ts
A: start
D: end
C: promise.then callback
B: setTimeout callback
より詳しくキューへの登録順序を表すと以下になります。
-
console.log("A: start")
(同期) -
setTimeout(...)
(macrotaskキューに登録) -
Promise.resolve().then(...)
(microtaskキューに登録) -
console.log("D: end")
(同期)
トップレベルの同期コードが処理された後、microtask
が処理、その後macrotask
が処理されるという流れになってますね。
より複雑な処理
console.log("1. Synchronous start");
setTimeout(() => {
console.log("2. Timeout 0");
Promise.resolve().then(() => {
console.log("3. Promise inside Timeout");
});
}, 0);
Promise.resolve().then(() => {
console.log("4. Promise then");
setTimeout(() => {
console.log("5. Timeout inside Promise then");
}, 0);
});
Promise.resolve().then(() => {
console.log("6. Promise then");
setTimeout(() => {
console.log("7. Timeout inside Promise then");
}, 0);
});
console.log("8. Synchronous end");
$ npx ts-node sample.ts
1. Synchronous start
8. Synchronous end
4. Promise then
6. Promise then
2. Timeout 0
3. Promise inside Timeout
5. Timeout inside Promise then
7. Timeout inside Promise then
めちゃめちゃややこしいですね。
しかし、複雑になってもキューの考え方は同じで図解すると以下のようになります。(個人的には5, 7は別々のキューのイメージを持ってたりします)
青: 同期
黄: microtask
橙: macrotask
まとめ
- Node.js は単一スレッドで動作し、並列処理は行われません
- しかし、イベントループによって 非同期 I/O(ネットワーク・ファイル操作など)の待ち時間をブロックしない仕組みを実現しています
- macrotask(タイマやI/Oコールバックなど) と microtask(Promise の then コールバックなど) は別々のキューに積まれ、それぞれ以下の順序で処理されます:
(同期コードを実行) → microtask を一気に実行 → 次の macrotask → microtask → …
- 結果的に、
Promise.then()
はsetTimeout()
やsetInterval()
より先に実行されるケースが多く、実行順序を理解していないとハマりどころになります - 「シングルスレッドなのに複数のリクエストを同時に捌ける」 のは、まさにこのイベントループと非同期 I/O のおかげです
Node.js や TypeScript の非同期コードを書くうえでは、イベントループの仕組みを理解すると「なぜ思った通りのタイミングで実行されないのか」「なぜ Promise の方が先に来るのか」といった疑問が解決しやすくなると思います。
参考