コルーチンとステートマシン変換の原理
async/awaitは魔法ではなく、コンパイラがコードを状態機械へ書き換える機械的変換です。スタックフルとスタックレスの違い、サスペンド/レジュームの実コストまで原理から腑に落とせます。
- 1.スタックフルコルーチンは専用スタックを丸ごと保持して任意の深さから中断でき、スタックレスコルーチンは中断点で生きる変数だけを構造体に畳み込むため軽量だが中断はその関数本体に限られる。
- 2.async/awaitはコンパイラがawait地点を区切りに関数を分割し、再開位置を表す状態番号と継続に必要な変数を1つの状態構造体に詰めた有限状態機械へ変換した結果である。
- 3.サスペンド/レジュームはスタック切り替えやレジスタ退避を伴わず、状態番号の読み書きとswitch分岐に近いため、カーネルのコンテキストスイッチより桁違いに安く、I/O待ちの多い高並行処理に向く。
コルーチンとは──中断と再開を持つ関数
通常の関数(サブルーチン)は呼ばれたら最後まで走り、return で一度だけ制御を返します。コルーチンはこの非対称を崩し、実行の途中で自発的に制御を手放し(サスペンド)、後でその続きから走り直せる(レジューム)関数です。鍵は「どこまで進んだか」と「途中の変数」を中断の間も保存することにあります。この保存方法の違いが、スタックフルとスタックレスという2系統を生みます。本記事は両者の原理と、async/await がコンパイラによって**有限状態機械(FSM)**へ変換される仕組み、そしてサスペンド/レジュームの実コストを解剖します。
スタックフルコルーチン──専用スタックを丸ごと持つ
スタックフルコルーチンは、各コルーチンに独立した実行スタックを割り当てます。中断時にはスタックポインタと一部レジスタを退避し、再開時に復元するだけ。スタックがそのまま残るので、a() が b() を呼び b() が c() を呼んだ深い入れ子の c() の中からでも、丸ごと中断できます。
- 利点:中断点に制約がない。ライブラリ関数の奥からでも
yieldでき、普通の同期コードをほぼそのまま並行化できる。Go のゴルーチン、各言語のファイバ/グリーンスレッドがこの系統。 - 欠点:スタックを事前に確保する必要があり、1コルーチンあたり数KB〜数十KBを占める。大量に作るとメモリ消費が膨らむため、可変長スタックや分割スタックで緩和する。
中断・再開はスタック切り替えに近く、コンテキストスイッチの軽量版と見なせます。ただしカーネルを介さずユーザー空間で完結する点が、後述のコスト差を生みます。
スタックフルコルーチンを協調的にスケジュールしたものが、いわゆるグリーンスレッド/ファイバです。カーネルスレッドへの対応付け(スレッドモデル)で言えば、多数のコルーチンを少数のカーネルスレッド上で回す M:N に近い構図になります。
スタックレスコルーチン──状態を構造体に畳み込む
スタックレスコルーチンは専用スタックを持ちません。代わりに中断点をまたいで生き残る変数だけを、ヒープ上の1つの状態オブジェクトに保存します。実行位置は「次にどこから再開するか」を表す状態番号で表現します。
この方式の制約は重要です。中断できるのはそのコルーチン関数の本体内だけで、そこから呼んだ普通の関数の途中では中断できません(呼ばれた側にはスタックが残らないため)。中断したい地点を含む関数は、すべて async として明示的に書く必要があります。これが「async は伝播する(colored function 問題)」と呼ばれる現象の正体です。
- 利点:保存するのは生存変数だけなので極めて軽量。1インスタンスが数十バイト〜で済み、数百万個でもメモリに収まる。
- 欠点:中断点が関数本体に限定され、呼び出しチェーン全体に async が波及する。
async/await はどうステートマシンになるか
async/await の正体は、コンパイラによる機械的なコード変換です。手順を原理から追います。
- 分割点の特定:関数内の各
awaitを境界に、コードを区切る。await の前後で制御が外に出て戻ってくるため、ここが状態の切れ目になる。 - 生存変数の抽出:ある await を越えて後で使われる変数(ローカル変数・引数)を割り出す。これらは中断中も保持が必要なので、状態構造体のフィールドにする。逆に await を越えない一時変数は構造体に入れず通常のスタックで済む。
- 状態番号の付与:各再開地点に整数の状態番号を割り当てる(0=開始、1=1つ目のawait後、…、最終=完了)。
変換後の resume(再開関数)は、おおむね「状態番号で分岐し、対応する区間を実行し、次の await で状態番号を更新して戻る」というループになります。擬似コードで示します。
async fn fetch_and_sum(url):
a = await get(url) # 中断点1
b = await parse(a) # 中断点2
return a.len + b
これは次のような状態機械へ変換されます。
struct State:
state: int = 0 # 再開位置
a, b: ... # 中断をまたぐ生存変数だけを保持
fn resume(self, input):
switch self.state:
case 0:
start get(url); self.state = 1; return Pending
case 1: # get 完了で再開
self.a = input
start parse(self.a); self.state = 2; return Pending
case 2: # parse 完了で再開
self.b = input
self.state = DONE
return Ready(self.a.len + self.b)
ポイントは、スタックを使わずに「続き」を表現していることです。再開に必要な情報がすべて State 構造体に入っているので、関数からいったん完全に抜けても(スタックが巻き戻っても)、構造体さえ保持していれば resume を呼び直すだけで続きから走ります。これがスタックレスたるゆえんです。状態番号は「次に飛ぶ case ラベル」、構造体は「畳んだスタックフレーム」に相当します。
どの変数を状態構造体に入れるかは、await をまたいだ生存区間解析(liveness analysis)で決まります。ここを誤ると、解放済みの値を再開後に読む・不要な値を抱え続けて状態が肥大する、といった問題が出ます。Rust のように await をまたぐ参照の生存を型システムで縛る言語があるのは、この正しさを保証するためです。
スタックフルとスタックレスの比較
| 観点 | スタックフル | スタックレス(async/await) |
|---|---|---|
| 状態の保持 | 専用スタックを丸ごと | 生存変数のみを構造体に |
| 中断できる場所 | 任意(呼び出しの奥からも) | async 関数の本体内のみ |
| 1インスタンスのメモリ | 数KB〜数十KB | 数十バイト〜 |
| async の伝播 | 不要(普通の関数を中断可) | 必要(呼び出し鎖に波及) |
| 実装の主体 | ランタイム/OS寄り | コンパイラの変換 |
| 代表例 | Go goroutine、ファイバ | Rust/C++/JS/Pythonのasync |
サスペンド/レジュームのコストと適用領域
スタックレスの中断・再開は、本質的には状態番号の読み書きと switch 分岐、構造体フィールドへのアクセスです。カーネルへの遷移もスタック切り替えもなく、関数呼び出し1回程度のコストに収まります。これがコンテキストスイッチ(レジスタ群の退避・復元、場合によりTLBやキャッシュの汚染、カーネル往復で数百ns〜マイクロ秒級)と比べて桁違いに安い理由です。スタックフルでも、ユーザー空間で完結すればカーネルのスイッチより十分安価です。
この安さが効くのは、待ちが多く計算が少ないワークロード——典型的にはネットワークI/Oの多い高並行サーバーです。1接続=1スレッドではスレッド数ぶんのスタックとスイッチコストが積み上がりますが、1接続=1コルーチンなら、I/O待ちのたびに安価に他のコルーチンへ譲れます。コルーチンの中断と完了通知を結びつけるのがイベント駆動I/Oモデルで、I/Oが完了したら対応する状態機械の resume を呼ぶ、という形で噛み合います。
コルーチンが速いのは「待ち時間を別の仕事で埋められる」からで、計算そのものを速くするわけではありません。中断点が無いCPUバウンドな長い計算は、協調的スケジュールでは譲るタイミングが無く他のコルーチンを飢えさせます。並列計算が目的なら、複数のカーネルスレッド(やスレッドプール)へ分散させる必要があります。
頻出は「async/await はどう実装されるか」。核心は3点——(1) コンパイラが await を境界に関数を分割し、状態番号+生存変数の構造体からなる有限状態機械へ変換する、(2) スタックを使わず構造体に状態を畳むため軽量だが中断点が async 本体に限られる(colored function)、(3) サスペンド/レジュームはswitch分岐相当でカーネルのコンテキストスイッチより遥かに安い。スタックフル(任意地点で中断・専用スタック・重い)との対比で語れると強いです。
まとめ
コルーチンは「中断点と途中状態をどう保存するか」で二分されます。スタックフルは専用スタックを丸ごと保持して任意の深さから中断できる代わりにメモリを食い、スタックレスは中断をまたぐ生存変数だけを構造体に畳むため軽量だが中断点が async 本体に限られます。async/await の正体は、コンパイラが await を境界にコードを分割し、再開位置を表す状態番号と継続に必要な変数を1つの状態構造体へ詰めた有限状態機械への変換です。サスペンド/レジュームはswitch分岐相当でカーネルのコンテキストスイッチより桁違いに安く、I/O待ちの多い高並行処理に最適です。イベント駆動I/Oやスレッドモデルと合わせて理解すると、現代の並行プログラミング基盤が一枚の絵としてつながります。
OS Article
コルーチンとステートマシン変換の原理を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
コルーチン
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 6
導入後に効く点
async/awaitはコンパイラがawait地点を区切りに関数を分割し、再開位置を表す状態番号と継続に必要な変数を1つの状態構造体に詰めた有限状態機械へ変換した結果である。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 6
判断チェックリスト
- 自社の用途が「コルーチン / async/await」に近いか確認する。
- 強みである「スタックフルコルーチンは専用スタックを丸ごと保持して任意の深さから中断でき、スタックレスコルーチンは中断点で生きる変数だけを構造体に畳み込むため軽量だが中断はその関数本体に限られる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。