JavaScriptのPromiseとイベントループの動作原理

JavaScriptの非同期動作は、多くの開発者を悩ませます。setTimeout(() => console.log('timeout'), 0)
と記述して即座の実行を期待しても、Promiseの方が先に解決されます。JavaScriptのPromiseがイベントループとどのように連携するかを理解することで、この現象の理由が明らかになり、より予測可能な非同期コードを書けるようになります。
重要なポイント
- イベントループは、次のマクロタスクに移る前にすべてのマイクロタスクを処理する
- Promiseとasync/awaitはマイクロタスクキューを使用し、setTimeoutより優先される
- 2つのキューシステムを理解することで、JavaScriptの実行順序を予測できる
- 再帰的なマイクロタスクはイベントループを枯渇させ、マクロタスクをブロックする可能性がある
JavaScriptイベントループの基礎
JavaScriptはシングルスレッドで動作し、一度に1つの操作を処理します。イベントループは、コールスタックと2つの異なるキュー(マイクロタスクキューとマクロタスクキュー)を調整することで、非同期操作を可能にします。
コールスタックは同期コードを即座に実行します。スタックが空になると、イベントループは特定の順序で保留中のタスクをチェックします:
- すべてのマイクロタスクを最初に実行
- 1つのマクロタスクを実行
- このサイクルを繰り返す
この優先順位システムが、promiseがsetTimeoutと異なる動作をする理由を説明します。
JavaScriptのマクロタスクとマイクロタスク:重要な違い
マクロタスクとマイクロタスクの違いを理解することは、コードの実行順序を予測する上で不可欠です。
マクロタスクには以下が含まれます:
setTimeout
setInterval
- I/O操作
- UIレンダリング
マイクロタスクには以下が含まれます:
- Promiseコールバック(
.then
、.catch
、.finally
) queueMicrotask()
- MutationObserverコールバック
イベントループは、次のマクロタスクに移る前にすべてのマイクロタスクを処理します。これにより、promiseが常にタイマーより先に実行される優先順位システムが作られます。
Promiseとマイクロタスクキューの実際の動作
コードを通じて、promiseがイベントループとどのように連携するかを見てみましょう:
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve()
.then(() => console.log('3'))
.then(() => console.log('4'));
console.log('5');
出力: 1, 5, 3, 4, 2
実行フローは以下の通りです:
- 同期的な
console.log('1')
が即座に実行される setTimeout
がコールバックをマクロタスクキューにスケジュールする- Promiseコールバックがマイクロタスクとしてキューに入る
- 同期的な
console.log('5')
が実行される - イベントループがすべてのマイクロタスク(3, 4)を処理する
- イベントループが1つのマクロタスク(2)を処理する
マイクロタスクキューは、遅延時間がゼロのタイムアウトであっても、すべてのマクロタスクが実行される前に完全に空になります。
Discover how at OpenReplay.com.
Async/Awaitとイベントループの統合
Async/awaitの動作は、同じマイクロタスクのルールに従います。await
キーワードは関数の実行を一時停止し、続きをマイクロタスクとしてスケジュールします:
async function example() {
console.log('1');
await Promise.resolve();
console.log('2'); // これはマイクロタスクになる
}
example();
console.log('3');
// 出力: 1, 3, 2
await
の後、関数本体の残りの部分がマイクロタスクキューに加わります。これが、コード上では後に記述されているにもかかわらず、console.log('3')
がconsole.log('2')
より先に実行される理由です。
よくある落とし穴と実用的なパターン
マイクロタスクキューの枯渇
再帰的にマイクロタスクを作成すると、イベントループをブロックする可能性があります:
function dangerousLoop() {
Promise.resolve().then(dangerousLoop);
}
// これはしないでください - すべてのマクロタスクをブロックします
タイマーとPromiseの混在
異なる非同期パターンを組み合わせる際は、実行の優先順位を覚えておきましょう:
setTimeout(() => console.log('timeout'), 0);
fetch('/api/data')
.then(response => response.json())
.then(data => console.log('data'));
Promise.resolve().then(() => console.log('immediate'));
// 順序: immediate → data(準備ができたら) → timeout
実行順序のデバッグ
実行フローを追跡するには、このパターンを使用します:
console.log('Sync start');
queueMicrotask(() => console.log('Microtask 1'));
setTimeout(() => console.log('Macrotask 1'), 0);
Promise.resolve()
.then(() => console.log('Microtask 2'))
.then(() => console.log('Microtask 3'));
setTimeout(() => console.log('Macrotask 2'), 0);
console.log('Sync end');
// 出力: Sync start, Sync end, Microtask 1, Microtask 2, Microtask 3, Macrotask 1, Macrotask 2
まとめ
イベントループの2つのキューシステムが、JavaScriptの非同期実行順序を決定します。Promiseとasync/awaitはマイクロタスクキューを利用し、setTimeoutやその他のマクロタスクより優先されます。この知識により、謎めいた非同期動作が予測可能なパターンに変わり、より信頼性の高い非同期JavaScriptコードを書けるようになります。
覚えておきましょう:同期コードが最初に実行され、次にすべてのマイクロタスクがクリアされ、その後1つのマクロタスクが実行されます。このサイクルが繰り返されることで、JavaScriptのシングルスレッドが複雑な非同期操作を効率的に処理できるのです。
よくある質問
Promiseコールバックはマイクロタスクであり、setTimeoutはマクロタスクを作成します。イベントループは、タイムアウトの長さに関係なく、常に次のマクロタスクに移る前にすべてのマイクロタスクを処理します。
はい、マクロタスクの実行を許可せずに継続的にpromiseやマイクロタスクを作成すると、イベントループを枯渇させる可能性があります。これにより、UI更新やその他のマクロタスクの実行が妨げられます。
Async/awaitはpromiseのシンタックスシュガーです。awaitキーワードは実行を一時停止し、続きをマイクロタスクとしてスケジュールし、promiseコールバックと同じ優先順位ルールに従います。
異なるパターンはそれぞれのキュールールに従います。Promiseとasync/awaitはマイクロタスクを使用し、setTimeoutとsetIntervalはマクロタスクを使用します。コールスタックが空のとき、マイクロタスクが常に最初に実行されます。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Check our GitHub repo and join the thousands of developers in our community.