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 (マクロタスクキューに待機中) {
      マクロタスクを一つ取り出して実行;
    }
  }
}

ポイントは二つあります。

  1. コールスタックが空になってからキューを見る — 同期コードが優先
  2. マイクロタスクがマクロタスクより優先

マイクロタスク 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

出力順序の説明は次のとおりです。

  1. console.log('1') — 同期実行
  2. setTimeout — マクロタスクキューに登録
  3. Promise.resolve().then(...) — マイクロタスクキューに登録
  4. console.log('4') — 同期実行
  5. コールスタックが空 → マイクロタスクキュー確認 → 3 出力
  6. マイクロタスクキューが空 → マクロタスクキュー確認 → 2 出力

setTimeout が0msなのに Promise.then より遅い理由がこれです。

await の次の部分もマイクロタスク #

async/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 — 直接マイクロタスクを作る #

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 1macro 2 より先に出力されます。

実戦で意識すべき場面 #

1) 無限マイクロタスク → マクロタスクが飢える #

マイクロタスクの中でさらにマイクロタスクを延々と登録すると、マクロタスクとUIアップデートがブロックされます。

やめてください
function loop() {
  Promise.resolve().then(loop);
}
loop();
// ブラウザが止まる — 他のイベント、レンダリングすべて不可

setTimeout で同じことをするとマクロタスクなのでUIが更新される隙間があります。マイクロタスクはより優先なのでより危険です。

2) DOMアップデートとマイクロタスク #

Promise.resolve().then(...) の中のDOM変更はレンダリングされる前に適用されます。同じフレーム内でDOMを何度変えても一度だけ描画されます。

DOMとマイクロタスク
button.addEventListener('click', () => {
  el.textContent = '1';
  Promise.resolve().then(() => {
    el.textContent = '2';   // ユーザーは '2' だけ見る
  });
});

これがReactのようなライブラリがマイクロタスクを活用してbatchアップデートを行える理由の一つです。

3) await の間の同期コード #

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 の次の行がマイクロタスクとして登録されます。

requestAnimationFramerequestIdleCallback #

ブラウザ環境にはマクロタスクのほかに、レンダリングと同期したコールバックが二つあります。

requestAnimationFrame — 次のフレームで実行 #

rAF
requestAnimationFrame(() => {
  // 次のフレームが描画される直前に実行 (通常60fps = 16.7ms間隔)
  el.style.transform = `translateX(${x}px)`;
});

アニメーションやDOM変更のグループ化に向いています。setTimeout よりも正確にフレームに合わせて実行されます。

requestIdleCallback — 暇なときに実行 #

rIC
requestIdleCallback(() => {
  // メインスレッドが暇なときに実行 — 優先度の低い作業
  saveAnalytics();
});

急がない作業 (分析データの保存、バックグラウンドの先計算) に向いています。ユーザーインタラクションを妨げないよう設計されています。

Nodeのイベントループ — 少し違う #

Nodeにもイベントループがありますが、ステージがより細分化されています。

ステージ処理
timerssetTimeout / setInterval コールバック
I/O callbacks一部のシステムコールバック
poll新しいI/Oイベントの受け取り
checksetImmediate コールバック
closesocket.on('close', ...)

setImmediateprocess.nextTick のようなNode専用ツールもありますが — process.nextTick はマイクロタスクよりも先に実行されます。一般のコードで頻繁に使うことはありません。

まとめ #

今回の記事で整理した内容。

  • JavaScriptはシングルスレッド、コールスタックが空になってから非同期処理
  • イベントループ — コールスタックが空になるまで同期、その後マイクロ → マクロ
  • マイクロタスク — Promise.then、queueMicrotask、awaitの次
  • マクロタスク — setTimeout、I/O、UIイベント
  • 一つのマクロを処理した後、マイクロキューが空になるまで空にする
  • 無限マイクロタスクはUIをブロック
  • requestAnimationFrame / requestIdleCallback はレンダリングと同期
  • Nodeのイベントループはステージがより細分化

次回の記事 (#5 メモリモデルとGC) では、JavaScriptがメモリをどう管理するか — ガベージコレクタの動作とメモリリークが起こるパターンを扱います。

X