async/awaitとコルーチンの内部実装
async/awaitが裏で何をしているか分かれば、性能の勘所もハマりどころも見通せる。状態機械への変換とイベントループの連携を原理から押さえます。
- 1.async/awaitの正体は、関数を中断・再開できる「状態機械」へコンパイル時に変換する仕組み。awaitごとに状態番号が割り当てられる。
- 2.コルーチンはスタックレス(C#/Rust/JS)とスタックフル(Goのgoroutineなど)に大別され、再開できる範囲とコストが異なる。
- 3.awaitは「呼び出し元へ制御を返す」だけでスレッドを止めない。完了通知でスケジューラが続きを再開し、イベントループが回す。
async/awaitは「中断できる関数」への糖衣
同期処理と非同期処理で見たとおり、async/awaitは非同期コードを同期のように書ける構文です。しかし「同期っぽく読める」のは表層で、実態は関数を任意の地点で中断し、後で同じ場所から再開する機能、すなわちコルーチンです。普通の関数(サブルーチン)は呼ばれたら最後まで走って戻りますが、コルーチンはawaitの地点で呼び出し元へいったん制御を返し、後で続きから走り直せます。
鍵は「ローカル変数や実行位置をどう保存するか」です。普通の関数はこれをコールスタック上に置きますが、中断するとスタックフレームは巻き戻ってしまう。だから別の場所に状態を退避する必要があります。その実現方式が、内部実装の核心です。
状態機械への変換
C#、Rust、JavaScriptなどの主流処理系は、async関数をコンパイル時に状態機械(state machine)へ変換します。awaitのたびに「ここまで進んだ」を表す状態番号を割り当て、関数全体を「現在の状態に応じて続きを実行する1つのループ」に書き換えるのです。
// 元のコード
async function load(id) {
const user = await getUser(id);
const posts = await getPosts(user);
return render(user, posts);
}
これは概念的に、次のような構造体+resume関数へ変換されます。
struct LoadState {
state: int // 0=開始, 1=getUser待ち, 2=getPosts待ち
id, user, posts // awaitをまたいで生きるローカル変数
}
resume(s):
switch s.state:
case 0:
f = getUser(s.id); s.state = 1
if f未完了: f.onComplete(() => resume(s)); return // 中断
case 1:
s.user = f.結果
g = getPosts(s.user); s.state = 2
if g未完了: g.onComplete(() => resume(s)); return // 中断
case 2:
s.posts = g.結果
complete(render(s.user, s.posts)) // 完了
ポイントは3つあります。第一に、awaitをまたいで使われるローカル変数はスタックではなく状態オブジェクト(ヒープ上)に格納される。第二に、await地点は「未完了なら完了コールバックを登録してreturnする」中断点になる。第三に、再開は同じresumeを再度呼び、switchが状態番号を見て続きへジャンプする。元の関数の見た目は手続き的でも、内部はこの「ジャンプ表」で制御構文を再構成しているわけです。
状態機械化は、ソースを別の形に書き換える一種のコンパイル処理です(コンパイルとインタプリタ)。C#ではasync修飾子を見たコンパイラが裏でIAsyncStateMachine実装クラスを生成し、Rustではasync fnがFutureトレイトを実装した匿名の状態機械型へ落ちます。JavaScriptエンジンも内部で同等の継続管理を行います。手書きのコールバックと違い、ローカル変数の退避漏れが起きないのが利点です。
なぜ「状態の退避」が必要か
中断をまたいで生き残る変数は、なぜわざわざヒープへ移すのか。awaitで呼び出し元へ戻ると、そのasync関数のスタックフレームは解放されて消えるからです。再開時には別の呼び出し経路(完了コールバック)から入ってくるため、元のフレームはもう存在しません。状態オブジェクトに変数を保持しておけば、フレームが消えても値は残り、再開時に復元できます。これはクロージャが外側変数を閉じ込めて生かし続けるのと同じ発想で、コルーチンは「実行位置+ローカル変数」をまとめてヒープに閉じ込めていると言えます。
スタックレス vs スタックフル
コルーチンの実装は、退避する範囲によって2系統に分かれます。
| 観点 | スタックレス | スタックフル |
|---|---|---|
| 保存するもの | 1つの関数の状態(変数+状態番号) | 専用スタック全体 |
| 中断できる場所 | async関数の直接の本体のみ | 呼び出した先の深い関数からでも可 |
| メモリ | 状態オブジェクト1個ぶん(小) | スタック領域を確保(やや大) |
| 伝播 | await が呼び出し連鎖を上へ伝わる必要あり | どこからでも中断でき伝播不要 |
| 代表例 | C#/Rust/JS の async, Python の async | Go の goroutine, Lua の coroutine |
スタックレスコルーチンは、前述のとおり1つのasync関数を状態機械に変換するだけなので軽量です。ただし中断できるのはasync関数の本体に直接書いたawaitだけ。普通の関数から中断したければ、その関数もasyncにしてawaitを上へ伝播させる必要があります。これがいわゆる「関数の色(async汚染)」問題で、非同期は呼び出し連鎖を上まで侵食します。
スタックフルコルーチンは、コルーチンごとに独立したスタックを丸ごと持ちます。だから何段深い関数の中からでも中断でき、awaitのような印を全段に付ける必要がありません(Goで普通の関数呼び出しがそのままgoroutine内で止まれるのはこのため)。代償として、各コルーチンにスタック領域(数KB〜)を割り当てるコストが生じます。Goは初期数KBの小さなスタックを使い、必要に応じて拡張する方式でこれを抑えています。
「スタックレス=スタックを使わない」ではありません。コルーチン専用の独立したスタックを持つかどうかの違いです。スタックレスでも、中断していない間は通常のコールスタックを使って実行します。試験などで「スタックフルは深い呼び出し先から中断できる/スタックレスは中断点を関数境界で明示する」という対比を問われやすい点を押さえましょう。
イベントループとの連携
状態機械は「中断・再開できる」だけで、いつ再開するかを決めるのは別の主体です。それがイベントループ/スケジューラです。流れを追います。
async関数がawait fに到達。f(Promise/Future)が未完了なら、fに「完了したらresumeを呼べ」という継続を登録し、呼び出し元へreturnする。スレッドはブロックしない。- 実I/Oは処理系の外側(OSのepoll/IOCP、ブラウザのネットワーク層など)に依頼されており、完了するとイベントとして通知される。
- イベントループが完了を検知し、登録された継続をタスクキューへ積む。
- ループはコールスタックが空になった隙に、キューから継続を取り出して
resumeを実行。状態機械は保存済みの状態番号から続きを走らせる。
つまりawaitは「待つ」のではなく「いったん譲って、完了したら起こしてもらう」仕組みです。JavaScriptでは完了継続はマイクロタスクとして扱われ、setTimeoutなどのマクロタスクより優先して実行されます。1本のスレッドでも固まらないのは、待ち時間にスレッドを解放し、その間に他のタスクを回せるからです。
async function a() {
console.log('1');
await null; // ここで一旦譲る(マイクロタスクに分割)
console.log('3');
}
console.log('start');
a();
console.log('2');
// 出力: start → 1 → 2 → 3
await nullの直後がそのまま動かず2が先に出るのは、await地点で関数が中断して呼び出し元(同期コード)へ制御を返し、続き(3)は現在の同期処理が終わってからマイクロタスクとして再開されるためです。
awaitが解放するのは「I/Oの待ち時間」だけです。async関数の中で重い計算をループで回すと、その計算が走る間はスレッドが占有され、他のタスクも、状態機械の再開も止まります。状態機械化は処理を並列にする魔法ではなく、中断点(await)で初めて譲れることに注意してください。CPUバウンドな処理は別スレッド/別プロセスへ逃がすのが原則です。
中断中、状態オブジェクトはローカル変数への参照を保持し続けます。awaitをまたいで大きなバッファや接続を握ったまま長時間待つと、その間メモリは解放されません。Rustではこの「awaitをまたいで生きる参照」がライフタイムやSendの制約として厳格にチェックされます。また、再開が呼ばれない(完了通知が来ない)コルーチンは中断したまま永久に宙吊りになり、リーク源になります。
まとめ
async/awaitの内部実装は、(1)awaitごとに状態番号を振り、ローカル変数をヒープの状態オブジェクトへ退避する状態機械変換、(2)中断範囲が関数本体か専用スタック全体かで分かれるスタックレス/スタックフルの選択、(3)完了通知を受けてresumeを再投入するイベントループとの連携、という3層で成り立ちます。awaitは「待つ」のではなく「譲って後で起こしてもらう」中断点だと捉えると、関数の色問題、マイクロタスクの順序、CPUバウンド処理が詰まる理由まで一貫して説明できます。挙動の全体像は同期処理と非同期処理と合わせて押さえると盤石です。
プログラミング Article
async/awaitとコルーチンの内部実装を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
async/await
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
コルーチンはスタックレス(C#/Rust/JS)とスタックフル(Goのgoroutineなど)に大別され、再開できる範囲とコストが異なる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「async/await / コルーチン」に近いか確認する。
- 強みである「async/awaitの正体は、関数を中断・再開できる「状態機械」へコンパイル時に変換する仕組み。awaitごとに状態番号が割り当てられる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。