[JavaScript講座] JavaScriptの実行モデルとマイクロタスク

当ページのリンクには広告が含まれています。
目次

シングルスレッド+イベントループ

主役はこの3つ

  1. コールスタック(Call Stack)
    • 「いま実行中の関数」が積み上がるスタック
    • foo() から bar() を呼ぶ → foo の上に bar が積まれる → bar 終了 → foo に戻る、という普通のスタック挙動
  2. タスクキュー(マクロタスクキュー)
    • 後で実行する「大きめの仕事」の待ち行列
    • 例:setTimeout のコールバック、ユーザーイベント(クリックなど)、setInterval, MessageChannel など
  3. マイクロタスクキュー
    • もっと細かい「すぐ実行したい後処理」の待ち行列
    • 例:Promise.then / catch / finallyqueueMicrotaskMutationObserver など

そして、イベントループ がこの3つを回します:

  1. タスクキューから1つ「タスク」を取り出して、コールスタック上で実行する
  2. そのタスクで発生した処理が終わり、コールスタックが空になったら
    → マイクロタスクキューを全部処理しきる(空になるまで)
  3. 必要に応じて DOM の再描画などを行う(ブラウザの場合)
  4. 次のタスクをタスクキューから取ってきて、1に戻る

この 「タスク→マイクロタスク全部→描画→次のタスク…」 のループが JS の実行モデルの核です。

コールスタックの復習

function c() {
  console.log("c");
}

function b() {
  c();
}

function a() {
  b();
}

a();

このときのコールスタックの変化は:

  1. a() 呼び出し → stack: [a]
  2. a 内で b() → stack: [a, b]
  3. b 内で c() → stack: [a, b, c]
  4. c 終了 → [a, b]
  5. b 終了 → [a]
  6. a 終了 → [](空)

スタックが空になった瞬間に、イベントループが「次のタスク or マイクロタスク」を実行してよい状態になります。

タスク(マクロタスク)とは何か

タスク(マクロタスク)は、ざっくり言うと:「1回の大きな仕事(1チャンクのJSコード)」です。

代表的なタスク発生源

ブラウザでは:

  • 初回に読み込まれた <script> ブロック
  • setTimeout(fn, delay)setInterval(fn, interval) のコールバック
  • ユーザーイベント(クリック・キーボード入力など)
  • postMessage のメッセージイベント
  • XHR / fetch のonloadとかは、内部的には(ホストの実装にもよるけど)タスクとしてキューに積まれるイメージ

Node.js では:

  • タイマー(setTimeout, setInterval
  • I/O 完了のコールバック
  • setImmediate など

重要なのは:

  • 各タスクは「コールスタックが空になるまで」走り続ける
    → 長い処理を書くと、その間 UI が固まる
  • タスクが終わるたびに、マイクロタスクキューがすべて処理される

マイクロタスクとは何か

マイクロタスクは、タスクの中でスケジュールされる「より細かい後処理」です。

代表的なマイクロタスクの発生源

  • Promise.then(...), .catch(...), .finally(...)
  • queueMicrotask(...)
  • MutationObserver のコールバック

流れ:

  1. タスク中で Promise.resolve().then(...) などを呼ぶ
  2. その .then のコールバックは すぐには実行されず、マイクロタスクキューに積まれる
  3. 現在のタスク(そのJSコード全体)が終わり、コールスタックが空になった瞬間
  4. イベントループが、マイクロタスクキューから1つずつ取り出して実行
  5. マイクロタスクの中でさらにマイクロタスクを追加した場合も、キューが空になるまで続ける

setTimeout と Promise.then の実行順

一番よくある「なんでこうなるの?」問題を、マイクロタスクの観点で整理します。

console.log(1);

setTimeout(() => {
  console.log("timeout");
}, 0);

Promise.resolve().then(() => {
  console.log("promise");
});

console.log(2);

実行結果:

1
2
promise
timeout

なぜこうなるか?

  1. 最初のタスク(このスクリプト全体)が開始
    • console.log(1)1
    • setTimeout(...) → 「コールバックをタスクキューに積んでね」とブラウザに頼む(実際には一定の遅延の後)
    • Promise.resolve().then(...) → コールバックを マイクロタスクキュー に積む
    • console.log(2)2
  2. このスクリプト(最初のタスク)が終わり、コールスタックが空になる
  3. イベントループ:「まずマイクロタスクキューを全部処理しよう」
    • promise コールバック実行 → console.log("promise")
  4. マイクロタスクキューが空になる
  5. 次のタスクをタスクキューから取り出す
    • setTimeout のコールバックがタスクとして実行される → console.log("timeout")

→ 結果として マイクロタスク(Promise.then)が、タスク(setTimeout)よりも先に実行 されます。

queueMicrotask で明示的にマイクロタスクを使う

queueMicrotask は、「いまのタスクが終わったらすぐ実行したい処理」 を登録するAPIです。

console.log("A");

queueMicrotask(() => {
  console.log("microtask");
});

console.log("B");

実行結果:

A
B
microtask

Promise.resolve().then(...) とほぼ同じタイミングですが、
queueMicrotask は 純粋に「マイクロタスクを積む」ためだけのAPI です。

PromisequeueMicrotask の違い

  • Promise.resolve().then(fn) も内部的にマイクロタスクを使う
  • ただし Promise は「状態」や「エラー伝搬」などの仕組みもセット
  • 「単に後でちょっと実行したいだけ」であれば queueMicrotask(fn) が素直

マイクロタスクの「全部処理し終えるまで抜けない」という性質

イベントループのルールとして、「マイクロタスクキューが空になるまで次のタスクに進まない」というのがあります。

ループでキューに積み続けるとどうなるか

queueMicrotask(function repeat() {
  console.log("microtask");
  queueMicrotask(repeat);
});
  • 最初の queueMicrotask が呼ばれた後、タスクが終了すると
  • repeat がマイクロタスクとして実行される
  • その中でまた queueMicrotask(repeat) が呼ばれる
  • マイクロタスクキューは 常に空にならない
  • 結果として、「次のタスク」に進むことができず、描画やイベント処理がフリーズする

→ マイクロタスクは 短くて軽い処理 にとどめる必要がある。

async/await とマイクロタスク

async/await は Promise のシンタックスシュガーなので、
await の「続き」はマイクロタスクとして実行されます。

async function main() {
  console.log("before");
  await Promise.resolve();
  console.log("after");
}

console.log("start");
main();
console.log("end");

実行順:

start
before
end
after

なぜか:

  1. main() を呼んだ時点で、async function は Promise を返す
  2. await Promise.resolve() に到達した瞬間、「ここで一旦中断」し、
    • await の 後ろの処理」をマイクロタスクとしてキューに積む」イメージ
  3. いま実行中のタスク(このスクリプト)が終わる
  4. マイクロタスクとして console.log("after") が動く

これも、Promise.then がマイクロタスクに乗るのと同じ原理です。

ブラウザのレンダリングとの関係

ブラウザのイベントループでは、1フレームごとにおおむね:

  1. タスクを1つ実行
  2. マイクロタスクを全部実行
  3. DOMの変更を反映してレイアウト・ペイント(描画)する
  4. 次のタスクへ…

という流れになっています。

そのため:

  • タスクやマイクロタスクの処理が長時間かかると、描画が遅れる
    • → スクロールがカクつく、UI が固まって見える
  • 「DOM をちょっと変更して、その結果をすぐに測定したい」ようなときに
    Promise.resolve().then(...) / queueMicrotask(...) で「1タスク後」に回すテクニックが使われる

ただし、マイクロタスクで長大な処理や無限ループをすると、
永遠に描画フェーズにたどり着けない ので注意です。

Node.js のマイクロタスク

  • Node.js でも Promise の then / catch / finally は マイクロタスクキュー に積まれる
  • Node には process.nextTick という「マイクロタスクよりさらに優先度が高い」キューもある
    • あまり多用するとイベントループが回らなくなるので注意、という点はマイクロタスクと似ている

「Node も基本は同じ:タスク処理の合間にマイクロタスク(Promiseなど)が挟まる」

というくらいを押さえておけば十分です。


<<前へ(npmとパッケージ管理)

>>次へ(コールバックとPromise)

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次