Skip to content

イベントループとマイクロタスク

JavaScript は「同期的に一本道で走るコード」と「あとで実行されるコールバック」の二層で動きます。Promise.then コールバック、setTimeout の満了、I/O の完了通知は、いずれも現在の実行がひと区切りついたあとに順序立てて処理されます。この順序を成立させる仕組みがイベントループとジョブ(ジョブキュー)です。ここで重要なのは、責務がエンジンとホストに分かれている点です。ECMAScript のエンジンは「ジョブ」という単位とその実行順序の一部だけを定め、ループそのもの — いつ次のタスクを取り出し、タイマーや I/O をどう供給するか — はホスト(ランタイム/組み込み側)が担います。この章では、この分担、マイクロタスクとマクロタスクの順序、Promise を実装する際の要点、そして再入とルート保持にまつわる実装上の罠を扱います。

責務の分担: エンジンとホスト

非同期処理の実装を語るには、まず「どこまでがエンジンの仕事で、どこからがホストの仕事か」を切り分ける必要があります。この境界を曖昧にすると、順序保証がどこで担保されているのか分からなくなります。

  • エンジン(ECMAScript)が定めるもの: 「ジョブ(Job)」という実行単位と、そのキュー。Promise の解決・拒否に反応して実行されるコールバック(リアクション)は、このジョブとしてキューに積まれます。エンジンはジョブをキューに入れる手続きと、キューを空になるまで実行する(ドレインする)手続きを提供します。
  • ホスト(ランタイム/組み込み側)が定めるもの: イベントループそのもの。すなわち「次に何を実行するか」を選ぶ大枠のループと、タイマー・I/O・UI イベントといった外部発生源(マクロタスクの供給源)です。ホストは外部イベントをタスクとしてスケジュールし、エンジンにジョブの実行を要求します。

仕様の言葉では、エンジンは HostEnqueuePromiseJob という抽象操作を通じてホストに「このジョブをいつか実行してほしい」と委ねます。ホストはそれを自分のキューに積み、イベントループの適切なタイミングで走らせます。つまりエンジンはジョブを生成し、ホストはジョブを駆動する、という分担です。

この設計には理由があります。ECMAScript 言語仕様は、ブラウザ・サーバランタイム・埋め込み用途など多様なホストで共有されます。タイマーの解像度や I/O モデル、UI レンダリングとの兼ね合いはホストごとにまったく異なるため、ループの駆動をホストに委ねておくことで、言語仕様は「Promise のコールバックが正しい順序で走る」という一点に集中できます。

flowchart TB
    subgraph engine["エンジン"]
        e1["Promise 解決"] --> e2["リアクションをジョブ化"] --> e3["HostEnqueuePromiseJob"]
    end
    subgraph host["ホスト"]
        h1["イベントループ: タスクを1つ取り出して実行"] --> h2["マイクロタスクを空になるまで実行"] --> h3["繰り返す"]
        h3 -. 次のループ .-> h1
    end
    e3 --> h1

タスク、ジョブ、そして二つのキュー

ホストのイベントループが回す単位には二つの粒度があります。ここで用語を整理しておきます。仕様やホスト仕様(WHATWG HTML)で名前が揺れるため、対応関係を押さえておくと混乱しません。

  • タスク(マクロタスク): イベントループが一巡ごとに一つ取り出す、比較的大きな作業単位。スクリプト全体の実行、タイマーの満了コールバック、I/O 完了コールバック、UI イベントの処理などがこれにあたります。ホストは種類ごとに複数のタスクキュー(タスクソース)を持つことがあります。
  • マイクロタスク(ジョブ): 現在のタスクが終わった直後、次のタスクに移る前に、まとめて処理される小さな作業単位。Promise のリアクションはこれです。ECMAScript が「ジョブ」と呼ぶものが、ホスト仕様の「マイクロタスク」に対応します。

「マクロタスク」という語は仕様上の正式な用語ではありませんが、マイクロタスクと対比するための通称として広く使われます。本書でもこの対比のために用います。

マイクロタスクとマクロタスクの順序

非同期処理の挙動を理解する鍵は、イベントループ一巡の中でのこの二層の順序です。ループの一巡は次のように進みます。

  1. タスクキューからタスクを一つ取り出し、最後まで同期実行する。
  2. そのタスクが終わったら、マイクロタスクキューが空になるまでマイクロタスクを順に実行する。
  3. マイクロタスク実行中に新たなマイクロタスクが積まれたら、それも同じドレインの中で続けて実行する(空になるまで繰り返す)。
  4. マイクロタスクが尽きたら、次のタスクへ進む(必要ならレンダリング等を挟む)。

ここで決定的に重要なのは、一つのタスクが終わるたびに、保留中のマイクロタスクをすべて処理してから次のタスクへ移るという点です。マイクロタスクは「割り込みで先に片付ける」層、マクロタスクは「一巡に一つずつ順番に消化する」層だと捉えると分かりやすいでしょう。

この順序が、次のよく知られた挙動を説明します。

setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
console.log("sync");
// 出力順: sync → promise → timeout

console.log("sync") は現在のタスク(スクリプト実行)の一部なので最初に出ます。Promise のコールバックはマイクロタスクなので、現在のタスクが終わった直後のドレインで実行されます。setTimeout(…, 0) のコールバックは遅延 0 でも新しいタスク(マクロタスク)であり、マイクロタスクをすべて片付けたあとの次の一巡でしか走りません。だから遅延 0 でも promise が先、timeout が後になります。

さらに、マイクロタスク実行中に積まれたマイクロタスクも同じドレインで消化されるため、Promise を連鎖させると次のタスクに移る前にすべて解決しきります。逆に言えば、マイクロタスクが尽きないと次のタスクへ進めないので、マイクロタスクを無限に積み続けると(例: then の中で常に新しい解決済み Promisethen する)、タイマーや I/O が永久に処理されない飢餓が起こりえます。

比較表

観点 マイクロタスク(ジョブ) マクロタスク(タスク)
代表例 Promise リアクション、queueMicrotaskawait の再開 setTimeout/setInterval、I/O 完了、UI イベント、スクリプト実行
定義する層 ECMAScript エンジン(ジョブ) ホスト(ランタイム/組み込み)
実行タイミング 現在のタスク終了直後、次のタスクの前 イベントループの一巡ごとに一つ
一度に処理する量 キューが空になるまで全部 一巡につき一つ
ドレイン中の追加分 同じドレイン内で続けて実行 次以降の一巡に回る
供給源 エンジン内部(Promise 解決など) 外部イベント(タイマー・I/O など)

ジョブキューの駆動

エンジン側の実装は、突き詰めれば二つの操作に集約されます。

  • enqueue(ジョブ): ジョブをキューの末尾に積む。Promise が解決・拒否されたとき、登録済みのリアクションごとに一つジョブを積みます(仕様の HostEnqueuePromiseJob)。
  • drain(): キューが空になるまで、先頭からジョブを取り出して実行する。

ドレインの骨格は次のように単純です。要点は「取り出して実行」を空になるまで繰り返すこと、そして実行中に積まれたジョブも同じループで拾うことです。

drain():
    while ジョブキューが空でない:
        job = キューの先頭を取り出す
        job を実行する          # この実行中に enqueue が呼ばれると同じキューに積まれ、次の反復で拾われる

ホストのイベントループは、タスクを一つ実行し終えるたびにこの drain() を呼びます。純粋にエンジンを組み込むだけの最小のホストであれば、「スクリプトを実行 → drain → タイマーやコールバックがあれば実行 → drain → …」という素朴なループになります。ブラウザのように多数のタスクソースとレンダリングを抱えるホストでは、この一巡がより精緻になりますが、「タスクの後にマイクロタスクを空にする」という核は変わりません。

Promise 実装の要点

マイクロタスクの主な供給源は Promise なので、その内部実装を押さえておくと全体像がつながります。Promise は次の三状態を持つオブジェクトです。

  • pending(保留): まだ解決も拒否もされていない初期状態。
  • fulfilled(履行): ある値で解決された状態。
  • rejected(拒否): ある理由で拒否された状態。

pending から fulfilled または rejected へは一度だけ遷移でき、確定後は二度と変わりません(settled)。

リアクションのジョブ化

then(および catch/finally)は、履行時・拒否時に呼ぶコールバック(リアクション)を Promise に登録します。ここでタイミングが二つに分かれます。

  • 登録時点で既に settled の場合: その場では呼ばず、対応するリアクションをジョブとしてキューに積む。同期的に即実行しないのが肝心で、これにより then のコールバックは常に「現在の同期実行が終わったあと」に走ります。
  • 登録時点で pending の場合: リアクションを Promise 内部のリストに溜めておき、後で settled になった瞬間に、溜めていたリアクションをまとめてジョブ化する。

いずれの経路でも、コールバックが同期的にその場で呼ばれることはない点が Promise の順序保証の中核です。Promise.resolve().then(f)f は、たとえ解決済みでも必ずマイクロタスクとして遅延実行されます。

resolve/reject の「既に解決済み」ガード

Promise を作る際に渡される resolve / reject 関数は、最初の一回だけ有効でなければなりません。二度目以降の呼び出しは黙って無視する必要があります。このため、内部に「既に解決処理へ入ったか」を示すフラグ(alreadyResolved)を一つ持ち、resolve と reject で共有します。どちらか一方が呼ばれた時点でフラグを立て、以後の resolve / reject 呼び出しは何もしません。

このガードがないと、new Promise((resolve, reject) => { resolve(1); reject(2); }) のようなコードで状態が二重に確定してしまい、リアクションが二度ジョブ化される、あるいは確定後に状態が書き換わるといった破綻が起きます。フラグは resolve と reject で共有する一つの状態であり、「resolve 済みなら reject も無効」でなければならない点に注意します。

thenable の同化

resolve(x) に渡された x が別の Promise、あるいは then メソッドを持つオブジェクト(thenable)だった場合、その Promisexそのまま値として抱えるのではなく、x の解決に追従します。これを thenable の同化(assimilation)と呼びます。

同化は次の手順で行われます。x が thenable なら、x.then を呼び出して自分の resolve / reject を渡し、x が settled になったらそれに合わせて自分も settle する。ただしこの then 呼び出し自体も同期的に行うのではなく、専用のジョブ(仕様の PromiseResolveThenableJob)として遅延させます。これにより、ユーザ定義の then を持つ悪意ある/複雑なオブジェクトを渡されても、順序と再入の規律が保たれます。

同化の実装で注意すべき点は二つあります。第一に、x.then の取得(プロパティ読み取り)がユーザ定義の getter を起動しうること。第二に、then の呼び出しがユーザコードを走らせ、その中で resolve / reject が複数回呼ばれうること。後者は前述の「既に解決済み」ガードで吸収されます。前者はプロパティ読み取りが任意の JS を起動するという、ネイティブ境界一般の注意(第III部「ネイティブコードとの相互作用と GC 安全性」参照)がそのまま当てはまります。

ホスト駆動のタイマーと I/O

setTimeout や I/O 完了は、エンジンの Promise とはまったく別の経路です。これらはホストが提供するマクロタスクの供給源です。

流れはこうです。スクリプトが setTimeout(cb, delay) を呼ぶと、ホストは「delay 経過後に cb を呼ぶタスクを登録する」という予約をします。時間が来たら、ホストは cb の呼び出しを自分のタスクキューに積みます。イベントループが次の一巡でそのタスクを取り出し、エンジンに cb の実行を依頼します。エンジンは cb を普通の関数呼び出しとして実行し、それが終わればホストはいつも通りマイクロタスクをドレインします。

エンジンの視点から見れば、タイマーや I/O のコールバックは「ホストから渡された、実行してほしい関数」にすぎません。エンジンはそれを実行し、その後ジョブキューを空にするだけです。逆に言えば、タイマーの精度・最小遅延(遅延 0 でも実際には最小値にクランプされること)・I/O の多重化といった話は、すべてホスト側の関心事であり、言語仕様は関知しません。この切り分けが、同じエンジンをブラウザにもサーバランタイムにも組み込める理由です。

未処理拒否の追跡

Promise が rejected になったのに、その拒否を受け取るハンドラ(then の第二引数や catch)が一つも登録されなかった場合、そのエラーは握りつぶされて発見が遅れます。これを検知して報告するのが未処理拒否(unhandled rejection)の追跡です。多くのホストはこれを検知してログや unhandledrejection イベントとして通知します。

検知の考え方はこうです。Promise が rejected になった時点で、ハンドラがまだ付いていなければ「未処理候補」として印を付ける。その後ハンドラが then/catch で付けば、印を取り消す。問題はいつ報告するかです。

報告のタイミングは、ジョブ/ターンの境界でなければなりません。すなわち、マイクロタスクをすべてドレインし終えた、真のジョブ境界で候補を点検します。ドレインの途中で報告してはいけません。というのも、拒否された直後は候補に見えても、同じドレインの中で少しあとに実行されるジョブが catch を付けるかもしれないからです。次の例がまさにそれです。

const p = Promise.reject(new Error("boom"));
Promise.resolve().then(() => p.catch(() => {}));  // 少し後のマイクロタスクでハンドラが付く

p は拒否された瞬間はハンドラ皆無ですが、同じドレイン内の後続マイクロタスクで catch が付きます。もし拒否の瞬間に(ドレイン途中で)報告してしまうと、実際には処理されるこの拒否を「未処理」と誤報します。ドレインを最後まで走らせ、ジョブ境界で改めて「まだ候補のままか」を点検すれば、この偽陽性を避けられます。境界での点検で候補が残っていたものだけを、本物の未処理拒否として報告します。

実装上の罠

罠1: ドレインの再入

もっとも間違えやすいのが、マイクロタスクのドレインが再入的に起動されてしまう状況です。ドレインは「一度始めたら空になるまで走り、終わったところがジョブ境界」という前提で組まれています。ところがドレインの途中でホストのコールバックや同期 API が別のドレインを呼び出すと、この前提が崩れます。

典型的なのは、あるジョブの実行中にホスト側のコード(たとえばネイティブ組み込みや埋め込み API のコールバック)が、独立にイベントループを一巡させようとしたり、明示的にジョブキューを流そうとしたりする場合です。すると、まだ外側のドレインが終わっていないのに内側のドレインが走り出し、ジョブ境界の意味が壊れます。

この破綻は、とくに前節の未処理拒否の点検で表面化します。未処理拒否の点検は「真のジョブ境界」でだけ行うべきものですが、再入した内側のドレインがその境界処理を実行してしまうと、次の二種類の誤りが起こります。

  • 偽陽性の報告: 外側のドレインにまだ後続ジョブが残っており、そのジョブでハンドラが付く予定の拒否を、内側の境界処理が「未処理」と早まって報告する。
  • 点検の取りこぼし: 逆に、内側の境界処理が「まだ確定していない候補」を消化・クリアしてしまい、本来外側の境界で報告されるべきだった拒否が報告されなくなる。

対策は、ドレインは同時に一つしか走らないようガードを置くことです。ドレイン中であることを示すフラグを持ち、既にドレイン中なら再入要求では新たなドレインを開始せず、実行中のループにジョブを積むだけにとどめます。そして未処理拒否の点検のような「ジョブ境界でのみ行う処理」は、もっとも外側のドレインが完全に終わった一点でだけ実行します。ネストしたドレインの内側では決して境界処理を走らせません。こうして「ジョブ境界」を一巡につき厳密に一回に保つことで、順序と未処理拒否の判定の両方が正しくなります。

罠2: キュー内のジョブは GC ルート

もう一つの見落としやすい罠が、ジョブキューに積まれた値を GC ルートに含めることです。

ジョブは通常、実行すべきコールバック関数と、それに渡す値(解決値や拒否理由)を抱えています。これらは「まだ実行されていないが、これから確実に実行される」ものであり、その間、他のどこからも参照されていないことがありえます。たとえば Promise.resolve(obj).then(f)obj を包む Promise への参照をスクリプトが手放しても、ジョブキューには fobj を抱えたジョブが残っています。

もしジョブキューをルート集合に含めていないと、キュー内でしか参照されていない fobj が、実行を待っている間に回収されてしまいます。実行のためにジョブを取り出した時点で、コールバックや値は解放済みメモリを指すことになり、use-after-free に陥ります。第III部「ルート集合と到達可能性」で述べたとおり、キューに積まれている間、ジョブとそれが抱える値は到達可能でなければならず、そのためにジョブキューそのものを GC のルートとして走査する必要があります。ドレインで取り出して実行し終えた後は参照を外し、以後の到達可能性は実行結果が作る通常のオブジェクトグラフに委ねます。

同じ注意は、pending な Promise が内部に溜めているリアクションのリストにも当てはまります。まだ settled でない Promise に登録されたコールバックは、その Promise が生きている限り生存させる必要があります。

まとめ

  • 非同期処理の責務は分かれている。エンジンはジョブ(マイクロタスク)とその生成・ドレインを定め、ホストはイベントループとマクロタスク供給源(タイマー・I/O)を駆動する。
  • イベントループは一巡ごとにタスクを一つ実行し、その後マイクロタスクをキューが空になるまで(ドレイン中に積まれた分も含めて)処理してから次のタスクへ進む。この順序が「Promise コールバックは setTimeout(…,0) より先」を説明する。
  • Promise は pending/fulfilled/rejected の三状態を持ち、リアクションは settle 時にジョブ化される。コールバックは決して同期実行されない。resolve/reject は「既に解決済み」ガードで一度きりにし、thenable は専用ジョブで同化する。
  • タイマーや I/O はホストがタスクとして供給し、エンジンはそれを普通の関数呼び出しとして実行するだけ。精度や最小遅延はホストの関心事。
  • 未処理拒否は、拒否時に候補として印を付け、ハンドラが付けば取り消す。報告は真のジョブ境界でのみ行い、ドレイン途中では行わない(偽陽性を避けるため)。
  • ドレインの再入にはガードが要る。境界処理(未処理拒否の点検など)はもっとも外側のドレインが終わる一点でのみ行う。ネストした内側で走らせると誤報や取りこぼしが生じる。
  • ジョブキューに積まれた値と、pending な Promise が溜めるリアクションは、実行を待つ間 GC ルートとして生存させなければならない。

参考文献