JavaScript | 非同期処理:非同期の基礎概念 – マイクロタスクキュー

JavaScript JavaScript
スポンサーリンク

マイクロタスクキューとは何か(まずざっくりイメージ)

マイクロタスクキューは、
「とても優先度の高い“あとで実行する処理”が並ぶ、特別レーンの待ち行列」 です。

前回の「タスクキュー」は、setTimeout や DOM イベントなどのコールバックが並ぶ、普通の待ち行列でした。
マイクロタスクキューは、それよりも “先に必ず片付けたい用事” を入れておくための、別枠のキュー だと思ってください。

ここが重要です。
Promise の thenasync/await の「await の先の処理」は、
普通のタスクキューではなく、この マイクロタスクキューに積まれ、次のタスクに進む前に必ず全部実行されます。


まずイベントループ全体の流れをもう一度ざっくり

大枠:イベントループがやっていること

JavaScript のイベントループは、ざっくり次のように動き続けています。

  1. コールスタックが空いていたら、タスクキューから 1 件取り出して実行する
  2. そのタスクが終わったら、
    「次のタスクに行く前に、マイクロタスクキューの中身を全部処理する」
  3. それが終わったら、また 1 に戻る

つまり、

  • 通常のタスク(setTimeout やイベントハンドラ)は「1 回に 1 つ」
  • マイクロタスク(Promise.then など)は、「その都度、溜まっているものを全部」

というルールで実行されます。

ここが重要です。
「タスク → マイクロタスク全部 → 次のタスク → マイクロタスク全部 → …」
このサイクルが、イベントループの基本パターンです。


マイクロタスクに乗る代表選手は何か

いちばん大事なのは Promise 関連

マイクロタスクキューに積まれる代表的なものは次の通りです。

Promise の then, catch, finally のコールバック
async/await の「await の先の処理」(内部的には Promise.then 相当)
queueMicrotask で登録された処理

逆に、setTimeout や DOM イベントのコールバックは 通常のタスクキュー に入ります。

この違いが、
「なぜ then の中の処理が、setTimeout(..., 0) より先に実行されるのか?」
という不思議を生みますが、マイクロタスクキューを知るときれいに説明できます。


具体例1:Promise と setTimeout の順番比較

コードと結果

次のコードを見てください。

console.log("A");

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

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

console.log("D");
JavaScript

実行結果は、おおよそこの順になります。

A
D
C: promise
B: timeout

「0ミリ秒の setTimeout より、Promise.then が先に出る」
ここにマイクロタスクキューの存在が効いています。

裏側で何が起きているか(丁寧に分解)

  1. "A" を出力(同期)
  2. setTimeout(..., 0) を呼び、Web API に「すぐにこのコールバックをタスクキューに入れてね」と依頼
    → コールバックは 通常のタスクキュー 行き候補
  3. Promise.resolve().then(...) を呼ぶ
    → Promise はすぐに解決され、then のコールバックが マイクロタスクキュー に追加される
  4. "D" を出力(同期)
  5. グローバルコード(このスクリプトの同期部分)が終わり、コールスタックが空になる
  6. イベントループは、「次の通常タスクに行く前に、マイクロタスクキューを全部処理する」
    → マイクロタスクキューの C: promise を実行
  7. マイクロタスクが終わったら、次に通常のタスクキューを見て、B: timeout を実行

この「マイクロタスクが通常タスクより先に全部処理される」ルールがあるので、
CB より先に出るのです。

ここが重要です。
Promise.then(マイクロタスク)は「今の処理が終わったら、次のタスクに行く前に必ずやること」として扱われる。
setTimeout(通常タスク)は「次のサイクルでやること」として後回しになる。


具体例2:マイクロタスクがたくさん積まれたらどうなるか

Promise をループでつなげる例

console.log("start");

Promise.resolve().then(function step1() {
  console.log("step1");
  return Promise.resolve();
}).then(function step2() {
  console.log("step2");
}).then(function step3() {
  console.log("step3");
});

console.log("end");
JavaScript

このときの出力順は、

start
end
step1
step2
step3

のイメージになります。

Promise の then をチェーンすると、それぞれの then のコールバックは「マイクロタスク」として次々に積まれ、
「1つ実行 → その中で新しいマイクロタスクを登録 → 実行可能なマイクロタスクを全部処理」
…という流れで、一気に順番に実行されます。

同じことを setTimeout で書いたときとの違い

もし全部を setTimeout(..., 0) で書いたら、
タスクキューで 1 回ずつ後回しになるので、間に他の処理が割り込む余地が増えます。

マイクロタスクは「今やっているタスクが終わったら、一気に処理される」ので、
Promise チェーンは、同期コードとかなり近い感覚のまとまりとして実行されやすい、という特徴があります。


具体例3:マイクロタスクが UI 更新を遅らせるケース(少しだけ応用)

連続して大量のマイクロタスクを登録すると

マイクロタスクキューは「各タスクが終わるたびに全部処理し切る」という性質があるので、
Promise.then や queueMicrotask を使ってひたすらマイクロタスクを追加し続けると、
次の通常タスクや描画がなかなか来ない、という状態を作れてしまいます。

極端なイメージですが、例えば:

function spamMicrotask() {
  queueMicrotask(() => {
    spamMicrotask();
  });
}

spamMicrotask();
JavaScript

のようなコードを書くと、イベントループが
「タスク1 → マイクロタスク無限 → …」という感じで、通常タスクに戻れなくなりかねません。

現実のコードでこんなことはあまりしませんが、

ここが重要です。
マイクロタスクは便利で優先度も高い一方、「次のタスクに行く前に全部処理される」というルールのせいで、使い方を誤ると他の処理を“詰まらせる”こともある
ということは頭の片隅に置いておくといいです。


なぜ「Promise はマイクロタスク」である必要があるのか

一貫した順序と予測可能性のため

Promise / async/await は、「非同期だけど同期っぽく書ける」ことが売りです。

例えば、

console.log("A");

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

console.log("C");
JavaScript

これで

A
C
B

という順になるのは、
「Promise の then は、必ず同期処理が全部終わったあとに実行される」
という保証があるからです。

もしこれが通常タスク扱いだと、

  • 他の setTimeout やイベントの影響で順番が前後する
  • 同じイベントループのサイクル内で実行されない可能性が出る

など、挙動が不安定になります。

マイクロタスクとして扱うことで、

  • 「今のタスクが終わった瞬間」に
  • 「他の通常タスクより先に」
  • 「キューに積まれた順に全部」

実行されるので、
Promise ベースの非同期処理はとても予測しやすくなります。

async/await の「読みやすさ」とも直結している

async function main() {
  console.log("1");
  await Promise.resolve();
  console.log("2");
}

main();
console.log("3");
JavaScript

これが

1
3
2

という順になるのは、

  • await の行でいったん関数を抜ける
  • Promise が解決されたときに、「続き」がマイクロタスクとしてスケジュールされる
  • 同期コード(”3″)がすべて終わったあと、マイクロタスクとして “2” が実行される

という流れのおかげです。

async/await を「同期っぽく見える非同期」として安心して使えるのは、
裏でマイクロタスクキューがきちんと約束を守ってくれているからです。


初心者として「どこまで理解しておくといいか」

最低限押さえておきたいポイント

マイクロタスクキューについて、今の段階でしっかり持っておいてほしいのは次の感覚です。

1つめに、JavaScript には「通常のタスクキュー」と「マイクロタスクキュー」という2種類の待ち行列がある。

2つめに、setTimeout やイベントハンドラは通常タスクキューに、Promise.thenasync/await の続きはマイクロタスクキューに積まれる。

みっつめに、イベントループは
「タスクを 1 つ処理するたびに、次のタスクに行く前にマイクロタスクキューを全部処理する」
という優先ルールで動いている。

その結果として、
Promise.then の中の処理は setTimeout(..., 0) より早く実行されることが多い。

余裕が出てきたら試してほしいこと

小さな実験として、

  • console.log, setTimeout, Promise.resolve().then を混ぜたコードを書いて、実行順を予想してから実際に見る
  • then の中でさらに then を返してみて、「マイクロタスクが連鎖していく」感じを味わう
  • queueMicrotask を1回だけ使って、setTimeout(..., 0) と順番比較してみる

などをしてみると、
マイクロタスクキューが「ただの概念」ではなく、「挙動の裏側でちゃんと動いている仕組み」として掴めてきます。


まとめ

マイクロタスクキューの本質は、
「Promise などの“優先的に片付けたい非同期処理”を、次のタスクに進む前にまとめて処理するための特別な待ち行列」
です。

押さえておきたい要点をもう一度整理すると、

マイクロタスクキューには、主に Promise.then, catch, finallyasync/await の続き、queueMicrotask で登録した処理が入る。
イベントループは、「通常のタスクを 1 つ実行 → マイクロタスクキューを全部処理 → 次のタスクへ…」という順で動いている。
そのため、Promise.then の処理は、setTimeout(..., 0) よりも先に実行されることが多い。
マイクロタスクを使いすぎると、通常タスクや描画が後回しになり、逆に詰まりの原因にもなりうる。

まずは、
「Promise の then や async/await の続きは、“すぐ後で必ず実行されるマイクロタスク”としてキューに積まれる」
というイメージをしっかり握っておいてください。

この「タスクキュー」と「マイクロタスクキュー」の違いが腑に落ちると、
JavaScript の非同期コードの実行順は、ほとんどのケースで「なるほどそういうことか」と説明できるようになります。

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