【ITニュース解説】JavaScript Advanced Series (Part 2): Event Loop Explained
2025年09月21日に「Dev.to」が公開したITニュース「JavaScript Advanced Series (Part 2): Event Loop Explained」について初心者にもわかりやすく解説しています。
ITニュース概要
JavaScriptは単一スレッドだが、イベントループにより非同期処理を実現する。Web APIと連携し、コールスタック、マクロタスク、マイクロタスクキューを制御。UIをブロックせず、複数の処理を効率的に実行し、スムーズな動作を可能にする。
ITニュース解説
JavaScriptはウェブブラウザ上で動作するプログラミング言語であり、通常は一度に一つの処理しか実行できない「シングルスレッド」である。しかし、ウェブサイトではユーザーの操作、データの取得、アニメーションなどが同時に動いているように見える。この裏側には「イベントループ」という重要な仕組みがある。イベントループはJavaScriptに非同期の能力を与え、複数の処理が同時に動いているかのような錯覚を生み出す。これにより、時間がかかる処理があってもウェブサイトが停止せず、スムーズに動作し続けることができる。もしイベントループがなければ、サーバーからデータを取得するような処理で、データが届くまで画面全体が停止してしまうだろう。この仕組みの理解は、応答性の高いコードを書くための基本となる。
JavaScriptエンジンの中心にあるのは「コールスタック」だ。これは実行中の関数呼び出しを管理するLIFO(Last-In, First-Out、後入れ先出し)のデータ構造で、関数が呼び出されるとスタックに積まれ、処理を終えると取り除かれる。コールスタックはコードの実行順序を管理するが、一つの関数が長時間かかると、その間スタックがブロックされ、他のどのコードも実行できなくなる。これを「メインスレッドのブロック」と呼び、ユーザーインターフェースが応答しなくなる原因となる。イベントループと非同期プログラミングは、このコールスタックの限界を克服するために存在する。
コールスタックの限界を乗り越えるため、ウェブブラウザは「Web API」という組み込み機能を提供する。これらはJavaScriptエンジンの一部ではなく、ブラウザが提供し、メインスレッドとは別の場所でタスクを処理できる。ネットワークからのデータ取得(fetch())、タイマー(setTimeout())、ユーザーのクリックなどのイベント処理がこれにあたる。Web APIの関数が呼び出されると、JavaScriptエンジンはそのタスクをブラウザに引き渡す。ブラウザはバックグラウンドでタスクを処理し、JavaScriptエンジンのコールスタックはブロックされずに他のコードの実行を続ける。タイマーが切れるなどのWeb APIの処理が完了すると、指定されたコールバック関数はすぐには実行されず、後述の「コールバックキュー」に配置され、イベントループによって処理されるのを待つ。このように、時間のかかるタスクをメインスレッドから切り離すことが、非同期JavaScriptの基本であり、高い応答性を持つウェブアプリケーションの構築に不可欠である。
Web APIが処理を完了した後、そのコールバック関数は「コールバックキュー」(または「マクロタスクキュー」)と呼ばれる待機場所に格納される。コールバックキューはFIFO(First-In, First-Out、先入れ先出し)の原則で動作し、非同期操作が完了すると、そのコールバック関数はキューの末尾に追加される。コールバックがキューに入ってもすぐに実行されるわけではない。イベントループがコールバックキューから関数を取り出し、コールスタックで実行するための主なルールは、「コールスタックが空であること」だ。これにより、現在実行中の同期コードがすべて完了してから、新しいコードが導入されることが保証される。例えば、setTimeoutに0ミリ秒の遅延を設定しても、そのコールバック関数は現在の同期コードがすべて完了し、コールスタックが空になった後にのみ実行される。
イベントループはJavaScriptの非同期処理全体の中心的な役割を果たす。その主な役割は、コールスタックとコールバックキューを継続的に監視することである。イベントループのロジックはシンプルだ。もしコールスタックが空であれば、コールバックキューの先頭にあるタスクを取り出し、コールスタックにプッシュして実行させる。このサイクルが、JavaScriptがメインスレッドをブロックせずに非同期操作を処理できる理由である。
コールバックキュー(マクロタスクキュー)に加えて、JavaScriptには「マイクロタスクキュー」という、より優先度の高い別のキューがある。マイクロタスクキューは主に、Promiseのコールバック(then()、catch()、finally()に渡される関数)のために使用される。Promiseが解決または拒否されると、対応するコールバックはマイクロタスクキューに配置される。イベントループはマイクロタスクキューを優先して処理する。各マクロタスクが完了した後、イベントループは次のマクロタスクに進む前に、マイクロタスクキュー内のすべてのタスクを処理する。これは、マイクロタスクが次のレンダリングサイクルやsetTimeoutのコールバックのような他のマクロタスクよりも先に実行されることを意味する。この優先順位は、Promiseベースの操作が適切なタイミングで処理されることを保証し、非同期コードにおいてより予測可能で一貫性のある動作を可能にするために重要である。
以下のコードを例に挙げる。
1setTimeout(() => console.log('setTimeout'), 0); 2Promise.resolve().then(() => console.log('Promise')); 3console.log('Sync');
このコードの実行結果は、まずSync、次にPromise、最後にsetTimeoutとなる。これは、同期コードが最初に実行され、その後マイクロタスク(Promise)が、そして最後にマクロタスク(setTimeout)が実行されることを示している。
マイクロタスクとマクロタスクの区別は、イベントループを理解する上で重要だ。マクロタスクはsetTimeout、setInterval、I/O操作、UIレンダリングなど、コールバックキューに配置されるタスク。マイクロタスクはPromiseのコールバックやqueueMicrotask()など、マイクロタスクキューに配置されるタスクを指す。イベントループはコールスタックが空になった後、まずマイクロタスクキューをチェックし、すべてのマイクロタスクを一つずつ実行する。マイクロタスクキューが空になってから初めて、イベントループはマクロタスクキューから一つのマクロタスクを取り出して処理する。このサイクルが繰り返される。この優先順位は、コードの実行順序に重要な影響を与える。例えば、あるマイクロタスクがさらに別のマイクロタスクをキューに追加した場合、その新しいマイクロタスクも、イベントループが次のマクロタスクに進む前に実行される。
実際のウェブ開発では、ユーザーがボタンをクリックし、データ取得後にUIが更新されるというシナリオがある。ユーザーがボタンをクリックすると、このイベントはマクロタスクとしてマクロタスクキューに入る。イベントループがクリックハンドラをコールスタックで実行する。ハンドラ内でfetch()が呼ばれ、これはWeb APIとしてブラウザに引き渡される。fetch()はPromiseを返し、そのthen()メソッド内のコールバックはまだ実行されない。クリックハンドラが終了すると、コールスタックから取り除かれる。ネットワークリクエストが完了すると、then()に関連付けられたコールバックはマイクロタaskキューに入る。イベントループはマイクロタスクキューをチェックし、then()コールバックをコールスタックで実行する。その中でDOM更新コードが実行される。マイクロタスクキューが空になると、ブラウザはレンダリングの更新を行う(マクロタスク)。このようにイベントループが全体の流れを調整し、スムーズなユーザー体験を生み出す。
イベントループの理解には、潜在的な落とし穴の認識も含まれる。「メインスレッドのブロッキング」は、コールスタック上で長時間かかる同期タスクが実行され、UI更新やユーザーインタラクションなど他のコードの実行を妨げる場合に発生する。これによりアプリケーションはフリーズし、応答しなくなる。これを避けるには、重い計算はWeb Workerにオフロードするか、setTimeoutで小さな非同期の塊に分割すべきだ。「マイクロタスクスタベーション」は、マイクロタスクが継続的に新しいマイクロタスクをキューに追加し続ける場合に起こる。これにより、無限に増え続けるマイクロタスクキューが、レンダリング更新やsetTimeoutコールバックのようなマクロタスクの実行を妨げ、アプリケーションを応答不能にする可能性がある。効率的なJavaScriptコードを書くには、コールスタックを常にクリアに保ち、キューに入れられるマイクロタスクの数に注意を払うことが重要である。
イベントループの概念は、ブラウザだけでなく、Node.js実行環境においても重要だ。基本的な原則(シングルスレッド、コールスタック、非同期タスクのためのキュー)は同じだが、Node.jsのイベントループの実装はブラウザよりも複雑である。Node.jsのイベントループには複数のフェーズがあり、それぞれ独自のコールバックキューを持つ。例えば、setTimeout()やsetInterval()のコールバックを実行する「タイマー」フェーズ、I/Oイベント関連のコールバックを実行する「ポール」フェーズ、setImmediate()のコールバックを実行する「チェック」フェーズなどがある。イベントループの各フェーズの間で、Node.jsはマイクロタスクキューを処理し、これにはprocess.nextTick()からのコールバックとPromiseからのコールバックが含まれる。process.nextTick()はPromiseのマイクロタスクキューよりも高い優先順位を持つ。このより構造化されたイベントループは、Node.jsがI/O集中型の操作で高いパフォーマンスを発揮し、サーバーサイドアプリケーション構築に適している理由である。ブラウザとNode.jsの両方でイベントループを理解していることは、上級JavaScript開発者の特徴である。