Skip to content

ジェネレータと async/await

普通の関数は、呼ばれたら最後まで走り切って値を返し、その実行状態は返却とともに消えます。ジェネレータはこの前提を崩します。yield に達すると実行をその場で中断し、呼び出し側へ制御を返し、後で next() が呼ばれると中断した箇所から再開する。しかもローカル変数・評価途中のスタック・どの yield で止まったかといった実行状態を、中断のあいだずっと保持し続けます。async/await はこの中断・再開の仕組みを Promise で駆動したものにほかなりません。この章では、関数の実行を途中で止めて後から続けるという能力を、エンジン内部でどう実現するか、その代表的な実装戦略とトレードオフ、中断中のフレームと GC の関係、そして実装で陥りやすい罠を扱います。

ジェネレータの何が特別か

通常の関数呼び出しは、前章「関数呼び出し」で見たように、呼び出しのたびにフレーム(ローカル変数と評価スタックの領域)をスタックに積み、関数が返ると同時にそのフレームを捨てます。フレームの寿命は呼び出しの寿命と完全に一致します。

ジェネレータはこの一致を断ち切ります。ジェネレータ関数を呼んでもすぐには本体は実行されず、ジェネレータオブジェクト(イテレータ)が返るだけです。本体が動き出すのは最初の next() からで、以降は次のような対話になります。

  • yield expr に達すると expr を評価し、その値を next() の戻り値として呼び出し側へ返して中断する。
  • 呼び出し側が再び next(v) を呼ぶと、中断していた yield 式そのものが値 v に評価される形で再開する。つまり値が関数の内側へ双方向に流れる。
  • return() は中断点であたかも return が実行されたかのように扱い、throw() は中断点で例外を投げたかのように扱う。
  • yield* iterable は、別のイテラブルへ反復を委譲する。委譲先が尽きるまでその要素を素通しし、next/return/throw の入力も委譲先へ転送する。

要点は、中断をまたいで実行状態が保存されることです。ローカル変数の値、yield がループの内側にあればそのループカウンタ、try の内側にあればどの try 節にいるか。これらすべてが、次の next() まで生き残らなければなりません。この「途中で止まって状態ごと後で続けられる関数」を一般にコルーチンと呼び、ジェネレータはそれを言語機能として露出したものと見なせます。実装上の中心課題は一つ、呼び出しの寿命より長生きする実行状態を、どこにどう置くかです。

実装戦略1: ステートマシンへのコンパイル

一つ目の戦略は、ジェネレータ関数の本体を、コンパイル時に明示的なステートマシンへ変換してしまう方法です。各 yield を「状態」とみなし、関数全体を「現在の状態に応じて処理を分岐し、yield のたびに次の状態を記録して抜ける」一つの再入可能な関数へ書き換えます。

  • ローカル変数は、スタック上の一時領域ではなく、ヒープに置いたジェネレータオブジェクトのフィールドにする。中断してもフィールドは残るので状態が保存される。
  • 「どこまで進んだか」を表す状態番号もフィールドに持つ。再開時はまずこの番号で分岐し、対応する続きへ飛ぶ。

概念的には、次のようなループとその中の yield が、

function* g() {
  let i = 0;
  while (i < n) {
    yield i;
    i++;
  }
}

おおよそ「状態番号で分岐し、yield の前で状態を保存して抜け、再開時はその続きへ飛ぶ」構造へ変換されます。

resume(state, sent):
    switch state.pc:
      case 0:  state.i = 0
               goto loop
      case 1:  # yield の直後から再開
               state.i = state.i + 1
               goto loop
    loop:
      if not (state.i < state.n): return done
      state.pc = 1
      return { value: state.i, done: false }   # ここで中断
  • 利点: 中断中の状態が普通のヒープオブジェクト一つに収まる。専用の実行スタックを別に確保・保存・復元する必要がなく、再開は関数呼び出し一つで済むため、ランタイムの仕組みは単純で移植性が高い。フィールド化された状態はそのまま GC の走査対象になる。
  • 欠点: コンパイラ側の変換が複雑になる。yieldtry/finally・ループ・条件式の深い内側にあると、制御フローを平坦化して状態番号へ落とし込む変換(いわゆる CPS 変換や制御フローの再構成)が一気に難しくなる。式の途中(たとえば a + (yield b))で中断する場合、評価途中の中間値もフィールドへ退避しなければならない。

この方式は、専用スタックを持たない軽量なランタイムと相性が良く、変換の複雑さをコンパイラが引き受ける代わりに実行時機構を薄く保てます。

実装戦略2: 独立した実行コンテキストの退避・復元

二つ目は、ジェネレータに自分専用の実行コンテキスト(スタック)を与え、中断時にはそれを丸ごと「駐車」し、再開時に「呼び戻す」方法です。本体は普通の関数と同じように書かれたまま、yield で実行が別のスタックへ切り替わります。

  • ジェネレータ開始時に、本体を実行するための独立したスタック(あるいはスタックセグメント)を用意する。
  • yield は、現在のスタックポインタ・命令ポインタなどの実行位置を保存し、呼び出し側のコンテキストへ制御を戻す。
  • next() は保存した実行位置を復元し、中断点の直後から実行を続ける。

  • 利点: 本体のコンパイルは普通の関数とほぼ同じでよい。制御フローを平坦化する必要がないため、try/finally や深い入れ子の yield も特別扱いが要らず、コンパイラの変換は最小限で済む。概念的にもっとも素直。

  • 欠点: 実行スタックの保存・復元という機構が要る。スタックを丸ごと切り替える実装は低レベルで移植性に注意が要り、ジェネレータごとにスタック領域を確保するとメモリ・確保コストがかさむ。中断中のスタックは、その上に載っているすべての値を GC が走査できる形で保持する必要がある。

この方式は、コンパイラの単純さと引き換えにランタイムへ複雑さを寄せます。ネイティブスタックそのものを切り替える実装は環境依存が強く、後述するフレームオブジェクト方式は、この「専用コンテキスト」の考え方をヒープ上のデータ構造として飼い慣らした中間形と見なせます。

実装戦略3: ヒープ確保したフレームオブジェクト

三つ目は、バイトコードインタプリタと自然に噛み合う方式で、ジェネレータのフレームそのものをヒープに確保し、中断中も生かしておく方法です。方式1と2の中間に位置します。

通常の関数フレーム(ローカルスロットと評価スタックを含む)はネイティブスタック上に置かれ、関数が返ると消えます。ジェネレータでは、このフレームを最初からヒープ上のオブジェクトとして確保します。本体はバイトコードとして普通にコンパイルされ、インタプリタはそのバイトコードを、ネイティブスタックではなくヒープ上のフレームを作業領域として実行します。

  • yield オペコードに達すると、インタプリタは現在の命令位置(バイトコードのオフセット)をフレームに書き込み、ヒープ上のフレームをそのまま残して実行ループから抜ける。
  • next() は、そのフレームを作業領域に据え直し、保存しておいた命令位置からインタプリタループを再開する。ローカルスロットも評価スタックもフレーム内にあるので、そのまま続きを実行できる。

  • 利点: 本体は普通のバイトコードのままでよく、方式1のような制御フローの平坦化が不要。専用のネイティブスタックを切り替える必要もないため方式2の低レベルな移植性問題も避けられる。フレームは単一のヒープオブジェクトなので、GC 走査も寿命管理も他のオブジェクトと同じ扱いにできる。

  • 欠点: フレームを必ずヒープに確保するため、通常のスタックフレームより確保・回収コストがかかる。評価スタックが空でない状態(式の途中)で yield すると、その中間値もフレームの評価スタック領域に含めて保存する必要がある。インタプリタは「今どのフレームで実行中か」を切り替えられる構造でなければならない。

多くのバイトコード VM にとって、これがもっとも実装しやすい落とし所です。すでにあるインタプリタとバイトコードをほぼ流用でき、「フレームをスタックに積むかヒープに置くか」という一点だけをジェネレータ用に差し替えれば済むためです。

async/await は同じ機構の上に建つ

async 関数は、その yieldawait になったジェネレータに、Promise で回す駆動役(ドライバ)を組み合わせたものとして実現できます。async/await のために中断・再開の仕組みを新たに作る必要はなく、ジェネレータのために用意した機構をそのまま流用できるのが要点です。

対応関係は次の通りです。

  • async 関数の本体を、await expr を「expr を待つための中断点」とするコルーチンとして実装する。中断・再開の仕組みはジェネレータと同一。
  • await expr は、expr を Promise として解決対象にし、その Promise が確定するまで中断する。ジェネレータの yield が値を呼び出し側へ返して止まるのに対し、await は待つべき Promise をドライバへ渡して止まる。
  • ドライバは、渡された Promise に「解決したら中断点へ値を持って再開、拒否されたら中断点で例外を投げて再開」という継続を登録する。Promise が確定すると、その継続がマイクロタスクとして走り、コルーチンを再開する。
  • コルーチンが最終的に return すると、async 関数が返していた Promise がその値で解決される。本体が例外で終われば、その Promise が拒否される。

擬似的にはドライバは次のように「再開して、また await に当たったらその Promise に自分自身を継続として繋ぐ」ループになります。

step(mode, input):
    result = coroutine.resume(mode, input)   # 中断点から再開。resume は yield/return/throw を返す
    if result は完了(return):
        outerPromise を result.value で解決
    else:                                     # await で中断した = 待つべき Promise を受け取った
        awaited = Promise.resolve(result.value)
        awaited.then(
            v  => step(resume-normal, v),      # 解決 → 値を入れて再開
            e  => step(resume-throw,  e))       # 拒否 → 例外を入れて再開

この「ジェネレータ + ドライバ」モデルで見ると、async/await のセマンティクスがそのまま説明できます。await 一つごとに一度中断してマイクロタスクを介して再開するため、await の後続コードは必ず現在の同期実行が終わってから走ります(マイクロタスクの順序そのものは次章「イベントループとマイクロタスク」の題材です)。エンジン実装の観点では、ジェネレータの中断・再開機構を持っていれば、async 関数はその上に薄いドライバを載せるだけで実現できる、という点が最大の設計上の含意です。

中断中のフレームと GC

どの実装戦略を選んでも共通して立ち現れるのが、中断中のフレームの寿命という問題です。前章「関数呼び出し」の通常フレームは呼び出しの寿命で消えますが、中断したジェネレータや async 関数のフレームは、呼び出しが返った後も生き続けなければなりません。次の next() や Promise の確定がいつ来るか分からない以上、その間ずっと保持が必要です。

したがってフレームはネイティブスタックには置けず、ヒープに確保しなければなりません(方式1ではジェネレータオブジェクトのフィールド、方式3ではフレームオブジェクトそのもの、方式2では駐車された専用スタック)。そしてそのフレームは、中断しているあいだ GC のルート、あるいはルートから到達可能な生きたオブジェクトでなければなりません。第III部「ルート集合と到達可能性」で見たように、GC はルートから辿れないものを回収します。中断中のフレームが到達不能になれば、再開に必要なローカル変数もろとも回収され、再開時に解放済みメモリを触ることになります。

具体的には次のものが、中断中も生存を保証されなければなりません。

  • フレーム内のローカル変数すべて(それらが参照するオブジェクトも芋づる式に生きる)。
  • 式の途中で中断した場合の、評価スタック上の中間値。
  • arguments オブジェクトを実体化している場合の、そこにマップされた実引数。arguments はフレームのローカルと連動するため、フレームと一緒にヒープ側で生かす必要がある。
  • 再開に必要な命令位置(状態番号やバイトコードオフセット)。これ自体はポインタではないが、フレームの一部として保存する。

保持の設計は素直です。ジェネレータオブジェクト(イテレータ)がフレームを参照として保持し、ジェネレータオブジェクト自体が到達可能である限りフレームも到達可能になる、という連鎖を作ります。async 関数の場合は、ドライバが登録した継続(Promise の解決ハンドラ)がコルーチンを捕捉するため、Promise が生きている限りフレームも生きます。逆に、誰も参照しなくなった中断中のジェネレータ(イテレータへの参照が全て失われ、二度と next() されえないもの)は、正しく到達不能となって回収されるべきで、これが漏れるとメモリリークになります。中断中のフレームを不用意にグローバルなテーブルへ登録して抜き忘れる、といった実装は、このリークを生みます。

実装上の罠

罠1: 再入 — 実行中のジェネレータへの再突入

ジェネレータの中断・再開機構でもっとも重要な不変条件は、同じジェネレータを二重に走らせないことです。あるジェネレータが実行中(next()/return()/throw() の処理が進行中でまだ中断点に達していない状態)に、その同じジェネレータに対してもう一度 next() などが呼ばれると、一つのフレームを二つの実行が同時に触ることになり、状態が壊れます。

これは絵空事ではなく、ごく普通に起こりえます。もっとも典型的なのは、ジェネレータの本体が、自分自身のイテレータの next を(直接、あるいはコールバック越しに)呼んでしまう場合です。たとえば本体の中で、まだ中断していない自分のイテレータを別の反復処理へ渡してしまうと、実行中に再突入が起きます。

仕様はこれを、実行状態(内部スロット)で管理して明確に禁じています。再開系の操作は、まず対象のジェネレータが「実行中(executing)」でないことを検証し、実行中であれば TypeError("generator is already executing" に相当)を投げて処理を中断させます。決して黙って進んではいけません。進めれば、二つの実行が同じローカルスロットと評価スタックを踏み合い、最良でも意味不明な結果、最悪ではメモリ破壊に至ります。

flowchart LR
    start(["生成直後<br/>suspendedStart"]) -. "最初の next" .-> exec["実行中<br/>executing"]
    exec -. "yield で中断" .-> susp["中断中<br/>suspendedYield"]
    susp -. "next / return / throw" .-> exec
    exec -. "return または例外" .-> done(["完了<br/>completed"])
    exec -. "実行中の next 等は TypeError で拒否" .-> exec

ジェネレータの実行状態遷移。executing 中の再開要求は状態を変えず TypeError で弾かれる(この不変条件は遅延イテレータ実装にも同じく必要)。

resume(gen, mode, input):
    if gen.state == executing:
        throw TypeError("generator is already executing")   # ここで必ず弾く
    gen.state = executing
    ... 本体を中断点まで進める ...
    gen.state = suspended  (または completed)

同じ落とし穴は、ジェネレータを土台に実装される遅延イテレータ(いわゆるイテレータヘルパの map/filter/take のような、要素を必要になった時点で一つずつ生成する仕組み)でも顔を出します。これらを内部でジェネレータやステートマシンとして実装する場合、下流の消費者が上流の next を再入的に呼ぶ経路が生まれえます。素朴に実装すると、実行中フラグの検査を欠いたまま再突入を許し、内部状態を壊してしまいます。ジェネレータと同じく「実行中なら弾く」ガードを、各ヘルパの next にも一貫して置くことが必要です。中断・再開を提供する仕組みには、例外なくこの再入ガードが要る、と考えるのが安全です。

罠2: async ドライバは同期プロローグのエラーを取りこぼしてはならない

async 関数はどんな場合でも Promise を返し、本体で起きた失敗はその Promise の拒否として観測される、というのが約束です。この約束は、本体がまだ最初の await に達する前の同期区間(プロローグ)で失敗した場合にも成り立たなければなりません。

罠は、ドライバがコルーチンを起動する最初の一歩の扱いにあります。async 関数が呼ばれると、ドライバはまず本体を最初の await まで(あるいは最初の return/throw まで)同期的に走らせます。もしこの最初の区間で例外が投げられたら――たとえば引数のデストラクチャリングで失敗した、await に達する前の同期処理が投げた――ドライバはその例外を捕まえ、返すべき Promise を拒否として確定させて呼び出し側へ返さなければなりません。

callAsync(...args):
    outerPromise = new Promise()
    try:
        step(start, ...)          # コルーチンを起動し、最初の await/return/throw まで進める
    catch e:
        outerPromise を e で拒否   # 同期プロローグの失敗も必ず Promise の拒否に写す
    return outerPromise

ここで起動処理を素の try で囲い忘れると、同期プロローグで投げられた例外がドライバの外へそのまま漏れます。すると async 関数の呼び出しが Promise を返す代わりに同期例外を投げてしまい、「async 関数は必ず Promise を返す」という前提が崩れます。呼び出し側が .catch() で待ち構えていても、同期例外はそこに届かず、握りつぶされるか予期しない場所でクラッシュします。起動の一歩目を含め、コルーチンを進めるあらゆる区間の例外を漏れなく Promise の確定へ写像することが、ドライバの責務です。

方式の比較

観点 ステートマシンへのコンパイル 独立実行コンテキストの退避 ヒープ確保フレーム
中断状態の置き場所 ジェネレータオブジェクトのフィールド 駐車した専用スタック ヒープ上のフレームオブジェクト
コンパイラの複雑さ 高い(制御フロー平坦化・中間値退避) 低い(通常関数とほぼ同じ) 低い(通常のバイトコードを流用)
ランタイム機構 薄い(関数呼び出しで再開) 厚い(スタック保存・復元) 中(フレーム切り替え)
移植性 高い 低め(スタック操作が環境依存) 高い
メモリ/確保コスト 状態オブジェクト1つ スタック領域(やや重い) フレーム1つ(通常フレームより重い)
try/深い入れ子の yield 変換が難しい 特別扱い不要 特別扱い不要
GC 走査 フィールドを走査 駐車スタック全体を走査 フレームを通常オブジェクトとして走査

いずれの方式でも、async/await はこの中断・再開機構の上に Promise ドライバを載せる形で実現でき、中断中のフレームをヒープに置いて GC ルートから到達可能に保つ、という要件は共通します。バイトコードインタプリタを持つエンジンでは、既存のフレームとバイトコードをほぼ流用できるヒープ確保フレーム方式が、実装コストと移植性のバランスに優れます。

まとめ

  • ジェネレータは、yield で実行を中断し next() で状態ごと再開できる関数であり、その本質は「呼び出しの寿命より長生きする実行状態をどこに置くか」という問題である。
  • 実装戦略は、本体をステートマシンへコンパイルする方式、専用の実行コンテキストを退避・復元する方式、フレームをヒープに確保して中断中も残す方式の三つに大別され、コンパイラの複雑さとランタイム機構の重さのどちらを引き受けるかでトレードオフが分かれる。
  • async 関数は、yieldawait になったジェネレータに Promise で駆動するドライバを組み合わせたものとして、中断・再開機構を再利用して実現できる。
  • 中断中のフレーム(ローカル・評価スタックの中間値・マップされた実引数を含む)は呼び出しの後も生き続けるため、ヒープに確保し、中断しているあいだ GC ルートから到達可能に保たなければならない。参照が絶えた中断ジェネレータは正しく回収されるべきで、抱え込むとリークになる。
  • 実装上の罠として、実行中のジェネレータへの再入は必ず TypeError で弾く(遅延イテレータ実装も同じガードが要る)こと、async ドライバは同期プロローグの例外も漏れなく Promise の拒否へ写すことが挙げられる。

参考文献