JavaScript上級 #4 イベントループとタスク
中級 #2 で Promise と async/await の使い方を見ました。今回の記事は、その非同期が実際にどう動作するか — イベントループとタスクキューを覗いてみます。
JavaScriptはシングルスレッド #
まず最も重要な事実 — JavaScriptは一度に一行だけ実行します。一つの関数が動作している間、他の関数は決して割り込めません。
function a() {
b();
console.log('a 終了');
}
function b() {
console.log('b 終了');
}
a();
// b 終了
// a 終了
これを可能にするデータ構造がコールスタック (call stack) です。関数が呼び出されるとスタックに積まれ、終わると抜けていきます。
コールスタックが空になってから次の仕事をする #
JavaScriptが非同期を扱う際の核心ルールは次のとおりです。
コールスタックが空のときだけ、次の非同期処理が実行される。
console.log('1');
setTimeout(() => console.log('2'), 0);
console.log('3');
// 1, 3, 2 (この順序)
setTimeout(..., 0) なのに 2 が最後。理由は — コールスタックの同期コードがすべて終わってからタイマーコールバックが実行されるためです。0msでも同じです。
イベントループ #
これを管理するのがイベントループ (event loop) です。単純化すると、次の動作を延々と繰り返します。
while (true) {
if (コールスタックが空) {
if (マイクロタスクキューに待機中) {
// キューが空になるまですべて実行
マイクロタスクキューのすべての処理を実行;
} else if (マクロタスクキューに待機中) {
マクロタスクを一つ取り出して実行;
}
}
}ポイントは二つあります。
- コールスタックが空になってからキューを見る — 同期コードが優先
- マイクロタスクがマクロタスクより優先
マイクロタスク vs マクロタスク #
JavaScriptの非同期は二つのキューに分かれます。
| 種類 | どこから入るか |
|---|---|
| マクロタスク (Task) | setTimeout, setInterval, I/O, UIイベント |
| マイクロタスク (Microtask) | Promise.then/.catch/.finally, queueMicrotask, await の次の部分 |
マイクロタスクがマクロタスクより先に実行されます。しかもキューが空になってすべてのマイクロタスクが実行されるまで続きます。
核心の例 #
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 出力順序: 1, 4, 3, 2
出力順序の説明は次のとおりです。
console.log('1')— 同期実行setTimeout— マクロタスクキューに登録Promise.resolve().then(...)— マイクロタスクキューに登録console.log('4')— 同期実行- コールスタックが空 → マイクロタスクキュー確認 →
3出力 - マイクロタスクキューが空 → マクロタスクキュー確認 →
2出力
setTimeout が0msなのに Promise.then より遅い理由がこれです。
await の次の部分もマイクロタスク
#
async function a() {
console.log('1');
await null;
console.log('3');
}
console.log('start');
a();
console.log('2');
// start, 1, 2, 3
a() の中で await null に出会うと、その時点で関数が一旦止まり、次の行 (console.log('3')) がマイクロタスクとして登録されます。コントロールが呼び出し元に戻って console.log('2') が実行され、その後マイクロタスクが実行されます。
これがasync/awaitの実際の動作原理です。
queueMicrotask — 直接マイクロタスクを作る
#
console.log('1');
queueMicrotask(() => console.log('2'));
console.log('3');
// 1, 3, 2
ブラウザもNodeもこの関数を持ちます。Promise.resolve().then(...) より短く明確にマイクロタスクを作れます。ライブラリ内部の実装でたまに登場します。
同じキューの中ではFIFO #
同じキューの中の処理は入った順番に処理されます。
setTimeout(() => console.log('A'), 0);
setTimeout(() => console.log('B'), 0);
setTimeout(() => console.log('C'), 0);
// A, B, C
Promise.resolve().then(() => console.log('A'));
Promise.resolve().then(() => console.log('B'));
Promise.resolve().then(() => console.log('C'));
// A, B, C
一つのマクロタスク vs すべてのマイクロタスク #
もう一度強調します — マクロタスクを一つ処理するたびに、マイクロタスクキューが空になるまですべてのマイクロタスクを終えてから次のマクロタスクへ行きます。
setTimeout(() => {
console.log('macro 1');
Promise.resolve().then(() => console.log('micro 1'));
}, 0);
setTimeout(() => console.log('macro 2'), 0);
// macro 1, micro 1, macro 2
macro 1 が実行されながら micro 1 がマイクロタスクキューに入ります。macro 1 が終わってコールスタックが空になると — マクロタスクキューへ行く前にマイクロタスクキューを先に空にします。だから micro 1 が macro 2 より先に出力されます。
実戦で意識すべき場面 #
1) 無限マイクロタスク → マクロタスクが飢える #
マイクロタスクの中でさらにマイクロタスクを延々と登録すると、マクロタスクとUIアップデートがブロックされます。
function loop() {
Promise.resolve().then(loop);
}
loop();
// ブラウザが止まる — 他のイベント、レンダリングすべて不可
setTimeout で同じことをするとマクロタスクなのでUIが更新される隙間があります。マイクロタスクはより優先なのでより危険です。
2) DOMアップデートとマイクロタスク #
Promise.resolve().then(...) の中のDOM変更はレンダリングされる前に適用されます。同じフレーム内でDOMを何度変えても一度だけ描画されます。
button.addEventListener('click', () => {
el.textContent = '1';
Promise.resolve().then(() => {
el.textContent = '2'; // ユーザーは '2' だけ見る
});
});これがReactのようなライブラリがマイクロタスクを活用してbatchアップデートを行える理由の一つです。
3) await の間の同期コード
#
async function process() {
console.log('1');
await fetch('/api');
console.log('2'); // fetch が終わってから実行
}
process();
console.log('3'); // process が await で止まっている間に実行
process() が await に出会うと一旦止まります。その間、呼び出し元 (console.log('3')) が進む機会を持ちます。fetchが終わると process の次の行がマイクロタスクとして登録されます。
requestAnimationFrame と requestIdleCallback
#
ブラウザ環境にはマクロタスクのほかに、レンダリングと同期したコールバックが二つあります。
requestAnimationFrame — 次のフレームで実行
#
requestAnimationFrame(() => {
// 次のフレームが描画される直前に実行 (通常60fps = 16.7ms間隔)
el.style.transform = `translateX(${x}px)`;
});アニメーションやDOM変更のグループ化に向いています。setTimeout よりも正確にフレームに合わせて実行されます。
requestIdleCallback — 暇なときに実行
#
requestIdleCallback(() => {
// メインスレッドが暇なときに実行 — 優先度の低い作業
saveAnalytics();
});急がない作業 (分析データの保存、バックグラウンドの先計算) に向いています。ユーザーインタラクションを妨げないよう設計されています。
Nodeのイベントループ — 少し違う #
Nodeにもイベントループがありますが、ステージがより細分化されています。
| ステージ | 処理 |
|---|---|
| timers | setTimeout / setInterval コールバック |
| I/O callbacks | 一部のシステムコールバック |
| poll | 新しいI/Oイベントの受け取り |
| check | setImmediate コールバック |
| close | socket.on('close', ...) |
setImmediate や process.nextTick のようなNode専用ツールもありますが — process.nextTick はマイクロタスクよりも先に実行されます。一般のコードで頻繁に使うことはありません。
まとめ #
今回の記事で整理した内容。
- JavaScriptはシングルスレッド、コールスタックが空になってから非同期処理
- イベントループ — コールスタックが空になるまで同期、その後マイクロ → マクロ
- マイクロタスク — Promise.then、queueMicrotask、awaitの次
- マクロタスク — setTimeout、I/O、UIイベント
- 一つのマクロを処理した後、マイクロキューが空になるまで空にする
- 無限マイクロタスクはUIをブロック
requestAnimationFrame/requestIdleCallbackはレンダリングと同期- Nodeのイベントループはステージがより細分化
次回の記事 (#5 メモリモデルとGC) では、JavaScriptがメモリをどう管理するか — ガベージコレクタの動作とメモリリークが起こるパターンを扱います。