JavaScriptのイベントループは、最も重要でありながら最も誤解されやすい概念の一つです。シングルスレッドでありながら、イベントループは高度なキューシステムを通じてノンブロッキングな並行処理を実現します。本記事では、コールスタックからマイクロタスクキューまで、JavaScriptが非同期実行を処理する完全なメンタルモデルを構築します。
コールスタックと実行モデル
JavaScriptはLIFO(後入れ先出し)構造のコールスタックを使用します。関数が呼び出されると新しいスタックフレームがスタックにプッシュされ、関数が戻るとフレームがポップされます。基本原則はrun-to-completionです。各関数は、他のコードに割り込まれることなく完了まで実行されます。
function multiply(a, b) { return a * b; }
function square(n) { return multiply(n, n); }
function logSquare(x) { console.log(square(x)); }
logSquare(4); // コールスタック: logSquare → square → multiply → ...
このシングルスレッドモデルでは、長時間実行される処理がメインスレッドをブロックします。イベントループは、待機処理をランタイム環境(ブラウザまたはNode.js)に委任し、そのコールバックを専用キューを通じて非同期的に処理することでこの問題を解決します。
マクロタスクキュー
マクロタスク(タスクとも呼ばれる)は、setTimeout、setInterval、I/Oイベント、UIイベント、Node.jsのsetImmediateから発生します。イベントループは各反復でキューから1つのマクロタスクを取り出し、完了まで実行した後、次のマクロタスクに進む前にマイクロタスクキューを処理します。
console.log("Start");
setTimeout(() => console.log("Timeout"), 0);
console.log("End");
// 出力: Start, End, Timeout
遅延が0であっても、setTimeoutのコールバックはマクロタスクとしてキューイングされ、現在の同期コードとすべてのマイクロタスクが完了した後にのみ実行されます。この動作は、setTimeout(fn, 0)が即座に実行されると期待する開発者によく誤解されています。
マイクロタスクキュー
マイクロタスクはマクロタスクよりも優先度が高くなります。Promiseの.then()、.catch()、.finally()、queueMicrotask()、MutationObserver、Node.jsのprocess.nextTick()から発生します。重要なルールは、次のマクロタスクが処理される前にマイクロタスクキュー全体がドレインされることです。
console.log("1");
Promise.resolve().then(() => console.log("2"));
setTimeout(() => console.log("3"), 0);
console.log("4");
// 出力: 1, 4, 2, 3
この順序が、両方の準備ができている場合でもPromiseがsetTimeoutコールバックより先に解決される理由です。危険な結果としてマイクロタスクのスターベーションがあります。再帰的にマイクロタスクをスケジュールすると、マクロタスクが実行できなくなり、アプリケーションがフリーズする可能性があります。
| キューの種類 | 発生源 | 優先度 | 1サイクルあたりの処理数 |
|---|---|---|---|
| マクロタスク | setTimeout, I/O, UIイベント | 低い | 1タスク |
| マイクロタスク | Promise, queueMicrotask | 高い | キュー全体 |
| レンダリング | requestAnimationFrame | その間 | ペイント前 |
Async/Awaitの内部動作
asyncとawaitキーワードは、Promiseとジェネレータ関数のシンタックスシュガーです。async関数は最初のawait式まで同期的に実行され、その時点でイベントループに制御を戻し、待機中のPromiseがマイクロタスクとして解決されたときに再開します。
async function example() {
console.log("A");
await Promise.resolve();
console.log("B");
}
example();
console.log("C");
// 出力: A, C, B
awaitキーワードはスレッドをブロックしません。これはPromiseの.then()コールバックに変換され、マイクロタスクとしてキューイングされます。これが、await以降のコードがすべての同期コードの完了後に実行される理由です。
requestAnimationFrameとレンダリング
スタイル再計算、レイアウト、ペイントからなるレンダリングパイプラインは、イベントループサイクルの特定のポイントで発生します。requestAnimationFrameのコールバックは、ブラウザがペイントを実行する前に実行されるため、視覚的な更新に適切なAPIです。
function animate(timestamp) {
element.style.transform = `translateX(${Math.sin(timestamp / 1000) * 100}px)`;
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
アニメーションにsetTimeoutやsetIntervalを使用すると、コールバックがブラウザのペイントサイクルと同期しないため、ジャンクが発生します。一方、requestIdleCallback APIは、フレームバジェットに影響を与えてはならない非クリティカルな処理のために設計されています。
パフォーマンスへの影響
50ミリ秒を超えるJavaScript実行時間のロングタスクは、メインスレッドをブロックし、目に見えるジャンクを引き起こします。Long Tasks APIを使用すると、プログラムによる監視が可能です。
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log("Long task detected:", entry.duration, "ms");
}
});
observer.observe({ entryTypes: ["longtask"] });
ロングタスクはCore Web Vitals、特にINP(Interaction to Next Paint)に直接影響します。緩和戦略には、setTimeoutやscheduler.yield()によるロングタスクの分割、重い計算処理のWeb Workersへのオフロード、requestIdleCallbackを使用した延期可能な処理の実行が含まれます。
結論
イベントループの習得は、パフォーマンスの高いJavaScriptを書くために不可欠です。コールスタック、マクロタスクキュー、マイクロタスクキュー、レンダリングパイプラインの関係を理解することで、一般的なバグを回避し、アニメーションパフォーマンスを最適化し、本番環境の問題を診断できます。優先順位付きのキュー処理を伴うrun-to-completion実行のメンタルモデルは、すべての非同期JavaScript開発の基礎です。
