1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【JS/TS】イベントループをざっくり理解しよう

Last updated at Posted at 2025-01-29

はじめに

業務でTypeScriptを扱うことになりました。
前の業務ではC#を使っていたので文法はなんとなく読めるんですが、そもそもJSとNodeが全く理解できてないのでそちらから勉強を始めました。

JSがシングルスレッドのなのは知っていたのですが、じゃあどうやって複数リクエストだったり非同期を実現しているのかというところでイベントループから書いていきます。

JS/TS/Node.jsに対する前提知識

シングルスレッドである

最重要
JavaScript コードそのものは、原則として 単一のメインスレッド で実行されます。
C# のようにユーザがスレッドプールを細かく扱うわけでもなく、PHP のようにリクエストごとに別プロセスを立ち上げるわけでもありません。
その代わり、イベントループとキューを使って複数の非同期処理を並行(≠並列)実行する仕組みを実現しています。

なお、Node.js の内部では I/O 処理を担う libuv のスレッドプールがあり、ファイル操作や DNS ルックアップなど一部の処理は並列化されています。

また、worker_threadschild_processcluster を利用すれば、複数のスレッド/プロセスを明示的に扱うことも可能なようです(ここでは割愛)。

イベントループ

Nodeの公式ドキュメントを読むと、処理をキューに溜め込んでポーリングしながら実行してるよみたいなことが書かれています。

細かいのですが、実行環境によって挙動が少し違うようで、今回はNode.jsのイベントループについて書いていきます。

macrotask queue(タクスキュー)

  • setTimeout, setInterval, I/Oコールバック などがここに積まれる
  • 1つのタスク(macrotask)が完了すると、イベントループは次のタスクを取り出して実行する

microtask queue(マイクロタスクキュー)

  • Promisethen コールバックや async/await の後続処理、process.nextTickの処理がここに積まれる
  • macrotask が終わった “直後” に、優先的に microtask queue が空になるまで実行される
  • この順序の違いにより、Promise.thensetTimeout より先に処理されるなど、実行タイミングに差が生じる

イベントループの流れ

  1. トップレベルの同期処理を実行
  2. microtask queue をすべて実行
  3. macrotask を 1 つ取り出して実行
  4. 2~3を繰り返す

要するにキューにmacrotask, microtaskという単位で処理を溜めていき順番に実行するということになります。
このときmicrotaskはキュー単位で実行され、溜まっているmicrotaskを一気に処理させます。

サンプルコード

sample.ts
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

より詳しくキューへの登録順序を表すと以下になります。

  1. console.log("A: start") (同期)
  2. setTimeout(...) (macrotaskキューに登録)
  3. Promise.resolve().then(...) (microtaskキューに登録)
  4. console.log("D: end") (同期)

トップレベルの同期コードが処理された後microtaskが処理、その後macrotaskが処理されるという流れになってますね。

より複雑な処理

sample.ts
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は別々のキューのイメージを持ってたりします)
image.png
青: 同期
黄: 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 の方が先に来るのか」といった疑問が解決しやすくなると思います。

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?