async/awaitの脱糖とPromiseジョブキューの実行順序
awaitの後がいつ・どの順で再開するかを内部から理解でき、実行順のバグを根本から読み解けるようになる。async関数の脱糖とマイクロタスクの並び方を仕様レベルで解説します。
- 1.async関数はステートマシン化され、awaitは「Promiseにthenを付けて中断、結果が来たらマイクロタスクで再開」へ脱糖される。awaitは同期的に止まるのではなく、関数を抜けて呼び出し元へ制御を返す。
- 2.await xの後続コードは必ずマイクロタスク(Promiseジョブ)として再開されるため、同じ反復のmacrotask(setTimeout等)より先に走る。複数のawaitは1個ずつ別々のマイクロタスクへ分割される。
- 3.値がPromiseでもawaitは少なくとも1ティック遅延する。await非Promiseは1マイクロタスク、await Promiseは仕様改定後で1ホップに最適化され、ネストやPromise.resolveの違いでティック数がずれる。
await は「止まる」のではなく「抜ける」
async/await は同期コードのように読めますが、実体は Promise とジェネレータ的なステートマシンの組み合わせです。await で「処理が止まって待つ」というイメージは、実行順を読むときに誤解を生みます。正確には、await は その関数の実行を中断して呼び出し元へ制御を返し、待っている値が解決したら マイクロタスク(Promise ジョブ)として続きを再開します。この一文がすべての実行順の根拠です。前提として イベントループの内部構造 のタスク/マイクロタスクの区別を押さえておくと、本記事の順序はそのまま導けます。
async 関数は何に脱糖されるか
ECMAScript 仕様上、async 関数は内部的に 中断・再開できるステートマシンへ変換されます。各 await が「中断点」になり、関数のローカル状態(変数や次に進むべき位置)が保存されます。概念的には、ジェネレータ(yield で中断する関数)を Promise で自動的に駆動するドライバで包んだものに等しい、と理解すると正確です。
// 元のコード
async function f() {
const a = await g();
return a + 1;
}
// 概念的な脱糖(擬似コード:実際の生成物ではない)
function f() {
return new Promise((resolve, reject) => {
let state = 0, a;
function step(value) {
try {
if (state === 0) {
state = 1;
// await g(): g() を Promise 化し、then で続きを予約して return(=抜ける)
Promise.resolve(g()).then(step, reject);
return;
}
if (state === 1) { // 再開点:a に解決値が入る
a = value;
resolve(a + 1); // return a + 1 に相当
}
} catch (e) { reject(e); }
}
step(); // 初回起動:最初の await まで同期実行
});
}
ここで決定的に重要なのは、await g() の箇所で then を登録した直後に return してドライバ関数を抜けている点です。a + 1 の行は 別の呼び出し(再開)として後から走るのであって、その場で待つわけではありません。再開を予約する手段が Promise の then なので、続きは マイクロタスクキューに積まれます。
脱糖の正体は「ジェネレータ+自動ドライバ」です。yield が await に対応し、yield が返した Promise が解決するたびにドライバが gen.next(value) を呼んで次の中断点まで進めます。TypeScript や Babel が古い環境向けに async をトランスパイルすると、実際にこの形(__awaiter + ジェネレータ)に近いコードが出力されます。エンジン内部ではジェネレータを介さない専用のステートマシンですが、観測できる実行順は同じです。
await の後続が「いつ」走るか
await の後続が 必ずマイクロタスクとして再開されることから、setTimeout などの macrotask より先に走ることが確定します。
async function f() {
console.log('2: await の前(同期)');
await null; // ここで中断 → 呼び出し元へ戻る
console.log('4: await の後(マイクロタスク)');
}
console.log('1: 呼び出し前');
f(); // f は await まで同期実行して return
setTimeout(() => console.log('6: setTimeout(macrotask)'), 0);
Promise.resolve().then(() => console.log('5: 外側の then'));
console.log('3: 同期の末尾');
// 出力順: 1 → 2 → 3 → 4 → 5 → 6
f() を呼ぶと 最初の await までは同期的に走り(2)、await null の時点で f の続き(4)が マイクロタスクとして登録され、関数を抜けて呼び出し元に制御が戻ります。続く同期コード(setTimeout 登録、then 登録、そして 3)が走り、現在のタスク(スクリプト)が終了。その直後にマイクロタスクが 登録順 に消化されます。f の続き(4)は外側の then(5)より先に登録されているため、4 → 5 の順で出ます。6(macrotask)は次の反復までお預けです。ここがつまずきやすい点で、「外側の then が先に書いてあるから先に走る」のではなく、await null が f() 呼び出しの時点ですでにマイクロタスクを積んでいるため f の続きが先になります。
1つの async 関数に await が n 個あれば、中断・再開が n 回起き、後続は n 個のマイクロタスクに分割されます。つまり await a; await b; の b の後続は、a の後続より少なくとも1ティック後ろです。ループ内の await(for の各反復で await)も同様に1反復ごとに1マイクロタスク分ずれるため、大量のシリアル await はマイクロタスクの長い連鎖を生みます。並行化できる箇所は Promise.all でまとめ、ティック数を減らすのが定石です。
await が消費する「ティック数」
await は 値が即解決でも最低1ティック遅延します。何ティック遅れるかは、await 対象が Promise か否か、また仕様改定の前後で変わります。
| await の対象 | 挙動 | 遅延ティック(現行仕様) |
|---|---|---|
| await null / await 42(非Promise/非thenable) | 値をそのまま採用し、1回マイクロタスクを挟んで再開 | 1 |
| await p(pは普通のPromise) | p の解決を直接 then で待ち、続きを1ホップで再開 | 1 |
| await thenable(thenを持つ独自オブジェクト) | then をジョブ経由で呼び、さらに解決を待つ | 2以上 |
旧仕様では await p が「内部で Promise.resolve(p) を作り、それを待つ」ため 余分なティックを消費していました。2019年の仕様改定(Await の最適化)と V8 の対応で、await p は本来の Promise を直接待つ1ホップに短縮されました。一方で 自前の thenable を await すると、then の呼び出し自体がジョブ化されるため、なお余分なティックがかかります。Promise.resolve(既存Promise) は同一 Promise を返す(ラップしない)ため、ここで二重に包む心配は不要です。
「await を1個増やせば順番が後ろになる」のは事実ですが、ティック数で実行順を制御する設計は壊れやすいです。仕様改定で await Promise のティック数が実際に変わった歴史があり、thenable か素の Promise かでも結果が動きます。順序が意味を持つ処理は、ティックの偶然に頼らず 明示的な依存(await x してから次へ) や Promise.all の順序保証で表現してください。順序の根拠を「マイクロタスクは登録順に消化される」以上に細かく仮定すると、環境差で破綻します。
エラーは reject、try/catch は then の第2引数
脱糖を見ると、await 中に投げられた例外がどう伝わるかも明確です。await p で p が reject すると、再開時に その中断点で例外として再スローされます。脱糖コードでは then(step, reject) の第2引数(onRejected)や step 内の try/catch がこれを担います。つまり async 関数内の try { await p } catch (e) {} は、p の拒否理由を同期例外のように捕捉できます。
async function h() {
try {
await Promise.reject(new Error('boom'));
console.log('ここには来ない');
} catch (e) {
console.log('捕捉:', e.message); // 捕捉: boom
}
}
逆に、async 関数が投げた(または await した Promise が reject した)まま catch しなければ、関数が返す Promise が reject 状態になります。呼び出し側が .catch も await もしなければ、未処理拒否(unhandledrejection)になります。await を「同期的な throw/return」と読めるのは、この reject ↔ throw の対応が脱糖で保証されているからです。
なぜこの仕組みが必要か(直接 then との違い)
async/await は単なる構文糖ですが、人間がマイクロタスクの分割点を意識せずに逐次フローを書ける点に価値があります。手書きの then チェーンは、各 then が新しいマイクロタスクと新しいスコープを作り、変数の引き回しやエラーハンドリングが煩雑になります。async/await はステートマシン化によって 1つの関数スコープ内に中断点を埋め込むため、ローカル変数・try/catch・ループがそのまま使えます。実行順という観点では then と等価でありながら、可読性が大きく違うわけです。JavaScript エンジンが async 関数をどう最適化するかは JavaScriptエンジンの内部 のステートマシン生成と合わせて読むと、コストの実態がつかめます。
❶ await の後続は マイクロタスクとして再開(=setTimeout より先)。❷ await は 最初の await まで同期実行してから呼び出し元に return する。❸ await n 個 = マイクロタスク n 分割で、各 await は最低1ティック遅延。❹ await reject は再開点で throw 相当になり try/catch で捕まる。❺ 脱糖の正体は ジェネレータ+Promise ドライバ。この5点で実行順とエラー伝播の問題はほぼ解けます。
まとめ
async 関数は 中断・再開できるステートマシンへ脱糖され、await x は「x を Promise 化して then で続きを予約し、いったん呼び出し元へ抜ける」へ変換されます。続きは マイクロタスク(Promise ジョブ)として再開されるので、setTimeout などの macrotask より必ず先に走ります。await は値が即解決でも 最低1ティック遅延し、await の個数だけマイクロタスクが分割されます。拒否は再開点で throw 相当となり try/catch で捕捉可能です。実行順を読むコツは「await = then の糖衣、続きはマイクロタスク」と捉えること。詳しい順序規則は イベントループの内部構造 を、言語そのものの基礎は JavaScript を参照してください。
Web/フロントエンド Article
async/awaitの脱糖とPromiseジョブキューの実行順序を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
JavaScript
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
await xの後続コードは必ずマイクロタスク(Promiseジョブ)として再開されるため、同じ反復のmacrotask(setTimeout等)より先に走る。複数のawaitは1個ずつ別々のマイクロタスクへ分割される。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「JavaScript / async/await」に近いか確認する。
- 強みである「async関数はステートマシン化され、awaitは「Promiseにthenを付けて中断、結果が来たらマイクロタスクで再開」へ脱糖される。awaitは同期的に止まるのではなく、関数を抜けて呼び出し元へ制御を返す。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。