【JS応用】イベントループ(event loop): microtask と macrotask

イベントループの深淵:MicrotaskとMacrotaskの完全理解

モダンなフロントエンド開発において、JavaScriptの非同期処理を正しく理解することは、単にバグを防ぐためだけでなく、アプリケーションのパフォーマンスを最適化するための必須条件です。JavaScriptはシングルスレッドで動作する言語でありながら、なぜ重い処理を並行してこなせるように見えるのか。その鍵を握るのが「イベントループ」であり、その挙動を制御する「Microtask」と「Macrotask」の優先順位です。本記事では、ブラウザのレンダリングエンジンとJavaScriptエンジンがどのように協調して非同期処理を処理しているのかを詳細に解説します。

イベントループの基本概念と実行コンテキスト

JavaScriptのランタイムは、単一のコールスタック(Call Stack)を保持しています。同期的なコードはすべてこのスタック上で実行されます。しかし、ネットワークリクエストやタイマー処理などの非同期操作は、コールスタックの外側で管理されるWeb API(ブラウザ環境の場合)に委譲されます。

イベントループの役割は、コールスタックが空になったタイミングで、タスクキューから次の処理を取り出し、実行することです。ここで重要なのが、タスクには「優先順位」が存在するということです。タスクは大きく分けて「Macrotask(タスク)」と「Microtask」の2種類に分類されます。

MacrotaskとMicrotaskの定義と分類

Macrotaskは、ブラウザがレンダリングやユーザー入力などの処理と並行して行う「大きな」処理です。一方でMicrotaskは、現在のタスクの直後に、他のタスクやレンダリング処理が割り込む前に実行される「微小な」処理です。

Macrotaskに含まれる代表的なもの:
・script(全体のコード実行)
・setTimeout
・setInterval
・setImmediate(Node.js環境)
・I/O操作
・UIレンダリングの更新

Microtaskに含まれる代表的なもの:
・Promise.then / catch / finally
・MutationObserver
・queueMicrotask
・process.nextTick(Node.js環境)

イベントループのアルゴリズムは、以下の手順を繰り返します。
1. 現在のMacrotask(スタック上のコード)を実行する。
2. Microtaskキューが空になるまで、すべてのMicrotaskを順次実行する。
3. 必要に応じてブラウザがUIのレンダリング(再描画)を行う。
4. 次のMacrotaskへ移動する。

この「Microtaskはスタックが空になるたびに、次のレンダリングの前にすべて処理される」というルールが、非同期処理の実行順序を決定づける核となります。

実務における実行順序の解析

以下のコード例を通じて、MicrotaskとMacrotaskの挙動を詳細に追いかけてみましょう。


console.log('1: 同期処理');

setTimeout(() => {
  console.log('2: Macrotask (setTimeout)');
}, 0);

Promise.resolve().then(() => {
  console.log('3: Microtask (Promise)');
});

console.log('4: 同期処理');

このコードの出力結果は必ず以下のようになります。
1: 同期処理
4: 同期処理
3: Microtask (Promise)
2: Macrotask (setTimeout)

なぜこうなるのでしょうか。まず、メインスクリプト(Macrotask)が実行され、1と4が出力されます。この時点でコールスタックは空になります。イベントループは次にMicrotaskキューを確認し、Promiseのコールバックを実行します。その後、ようやく次のMacrotaskであるsetTimeoutのコールバックが実行されるのです。

複雑な階層構造と注意点

さらに複雑な例を見てみましょう。Microtaskの中でさらにMicrotaskを生成した場合、イベントループはどう動くのでしょうか。


console.log('Start');

setTimeout(() => console.log('Timeout'), 0);

Promise.resolve().then(() => {
  console.log('Promise 1');
  Promise.resolve().then(() => console.log('Promise 2'));
});

console.log('End');

実行順序は:
Start -> End -> Promise 1 -> Promise 2 -> Timeout

Microtaskキューが空になるまでイベントループはMacrotask(Timeout)へ移動しません。つまり、Promise 1の実行中に生成されたPromise 2も、現在のMicrotaskフェーズ内で処理されます。これは非常に強力ですが、注意が必要です。もしMicrotask内で無限ループに近い再帰的なMicrotask生成を行うと、ブラウザのメインスレッドが占有され、UIが完全にフリーズします。Macrotaskはレンダリングの機会を挟めますが、Microtaskはレンダリングをブロックし続けるからです。

実務アドバイス:パフォーマンスとデバッグ

実務において、この知識をどのように活用すべきでしょうか。

1. 非同期処理の予測可能性:
「いつ実行されるか」を正確に予測することは、ReactのuseEffectやVueのnextTickなどのフレームワーク内部の挙動を理解する助けになります。例えば、DOMの更新後に何かを実行したい場合、VueのnextTickは内部でPromise(Microtask)を利用して、レンダリング後に処理を割り込ませています。

2. UIブロッキングの回避:
重い計算処理をMicrotaskに入れると、その処理が終わるまで画面は一切更新されません。もしユーザーにロード中であることを伝えたい場合は、重い処理をsetTimeout(Macrotask)に分割し、レンダリングが挟まる余地を作るべきです。

3. Promiseの多用による副作用:
Promiseを過剰に連鎖させると、Microtaskキューが非常に長くなります。大規模なデータ処理を行う際は、Web Workersを使用してメインスレッドから処理を切り離すのがベストプラクティスです。

4. デバッグのヒント:
ブラウザのデベロッパーツール(Chrome DevTools)の「Performance」タブを使うと、タスクの実行時間やレンダリングのタイミングを可視化できます。実行順序が怪しい場合は、タスクの階層を確認してください。

まとめ

イベントループは、JavaScriptが「シングルスレッドでありながら非同期」という矛盾を解決するための、非常にエレガントで巧妙なメカニズムです。Macrotaskは「タスクの単位」、Microtaskは「即時反映されるべきタスクの単位」と捉えると理解がスムーズです。

・Microtaskは、現在のタスクが終了し、他のMacrotaskが実行される前にすべて消化される。
・Microtaskの過剰な蓄積はUIのレンダリングを遅延させる。
・非同期処理の実行順序を制御することで、パフォーマンスとUXを最適化できる。

フロントエンドエンジニアとして、これらの内部挙動を意識することは、単なる知識の習得を超え、堅牢で反応の良いアプリケーションを構築するための「武器」になります。コードを書く際、ふと「今、この処理はどのキューに入っているだろうか?」と考える習慣を持つことが、スペシャリストへの第一歩です。

コメント

タイトルとURLをコピーしました