TL

末尾呼び出しとスタックフルvsスタックレス

なぜGoroutineは数十万本でも軽く、async/awaitは関数を汚染するのか。OSスレッドからファイバーまでの切り替えコストとメモリを横並びで比べ、ランタイム選択の根拠が手に入ります。

応用並行処理コルーチングリーンスレッドファイバースタックフレーム最終更新: 2026-06-21
TL;DR要点だけ先に
  • 1.並行実行単位はOSスレッド・グリーンスレッド・ファイバー・コルーチンに分かれ、違いの本質は「誰がスケジュールし、スタックをどう持つか」にある。
  • 2.スタックフルは専用スタックを丸ごと持ち深い呼び出し先からでも中断できるが数KB〜を消費。スタックレスは状態機械化で関数1個ぶんに退避し軽量だが、awaitが呼び出し連鎖を上へ伝播する。
  • 3.切り替えコストはOSスレッド(カーネル介在)>スタックフル(レジスタ+スタック切替)>スタックレス(状態機械のresume)の順。I/O多重と組み合わせて選ぶ。

並行実行単位を分ける2つの軸

「並行に走らせる単位」と一口に言っても、OSスレッド・グリーンスレッド・ファイバー・コルーチンと種類があります。これらの違いは、たった2つの軸で整理できます。第一はスケジューリングの主体、すなわち「次に何を走らせるかを誰が決めるか」。OS(カーネル)が決めるならプリエンプティブ、ランタイムやコード自身が中断点を選ぶなら協調的(cooperative)です。第二はスタックの持ち方、すなわち中断中の「実行位置とローカル変数」をどこに退避するか。専用スタックを丸ごと持つスタックフルか、関数1個ぶんの状態に畳むスタックレスかです。

この2軸が、メモリ消費・切り替えコスト・中断できる場所のすべてを規定します。以下、軸ごとに原理を押さえます。

OSスレッド:カーネルが運ぶ重い単位

OSスレッドは、カーネルがスケジュールする実行単位です。各スレッドは固定的なカーネルスタックと、ユーザー空間に数百KB〜数MBのスタック(Linuxの既定は8MB予約・遅延コミット)を持ちます。切り替え(コンテキストスイッチ)はカーネルへの特権モード遷移を伴い、レジスタ群の退避・復元に加え、別アドレス空間へ移るならTLB(アドレス変換キャッシュ)のフラッシュまで起き得ます。1回あたり数百ns〜数µsで、これが数万本となるとスケジューラのオーバーヘッドとメモリ予約が効いてきます。

利点は明快です。カーネルがプリエンプティブに時間を割り当てるので、1本がCPUを握り続けても他が飢えません。マルチコアへ真に並列に配置でき、ブロッキングなシステムコールを呼んでも他スレッドは止まりません。詳しい棲み分けは並行性モデルを参照してください。

スタックは「予約」と「コミット」が別物

OSスレッドのスタックが数MBでも、起動直後から物理メモリを数MB食うわけではありません。仮想アドレス空間を予約し、実際に触れたページだけ物理メモリが割り当てられます(デマンドページング)。それでも仮想空間の予約自体が上限になり、32bit環境やスタック上限の厳しい設定では「数万スレッドを立てられない」という壁になります。

グリーンスレッドとファイバー:ユーザー空間でスケジュールする

カーネルの重さを避けるため、ランタイム(ユーザー空間)が自前でスケジュールする軽量スレッドが生まれました。Goのgoroutine、Javaの仮想スレッド、Erlangのプロセスなどが代表です。歴史的に「グリーンスレッド」はユーザー空間スケジューラ上の軽量スレッドを指し、ファイバーはそのうち協調的に明示譲渡するものを指す語として使われます。

実装面では、少数のOSスレッド(キャリアスレッド)の上に多数の軽量スレッドを載せるM:Nモデルを取ります。ランタイムスケジューラが軽量スレッドをキャリアスレッドへ割り付け、中断時には別の軽量スレッドへ載せ替えます。この載せ替えはカーネルを通らないため、コンテキストスイッチが特権遷移を伴わず、OSスレッドより桁で安くなります。空いたキャリアスレッドが他のキューからタスクを奪う作業窃取スケジューラと組み合わせるのが定石です。

肝心なのは、これら軽量スレッドがスタックフルだという点です。goroutineは各自が独立したスタックを持ち、何段深い関数の中からでも中断・再開できます。だからGoでは普通の関数呼び出しがそのまま中断点になり得て、awaitのような印を全段に付ける必要がありません。

Goの可変長スタックが「数十万本」を可能にする

goroutineの初期スタックはわずか2KB程度で、足りなくなると拡張(grow)、使い終われば縮小します。OSスレッドのように最初から大きく予約しないので、1本あたりのメモリが小さく、数十万本を同時に走らせられます。初期のセグメント方式は境界で伸縮が頻発する「スタック分割問題(hot split)」を抱えていましたが、現在は連続スタックを丸ごと別領域へコピーして拡張する方式で解消しています。

スタックフル vs スタックレス:何を退避するか

軽量実行単位の実装は、中断時に何を退避するかで2系統に分かれます。これが本記事の核心です。

観点スタックフルスタックレス
退避するもの専用スタック全体(数KB〜)+レジスタ関数1個の状態(生存変数+状態番号)
状態の置き場所確保した独立スタック領域ヒープ上の状態オブジェクト
中断できる場所呼び出した先の深い関数からでも可コルーチン本体に直接書いた中断点のみ
伝播の要否不要(どこからでも譲れる)await等が呼び出し連鎖を上へ伝播
メモリ1本あたり数KB〜(伸縮あり)状態1個ぶん(数十〜数百B、最小)
代表例Go goroutine / Lua coroutine / Java仮想スレッドC#・Rust・JS・Pythonのasync

スタックフルは、コルーチンごとに独立したスタックを丸ごと持ちます。中断とは「現在のレジスタ(特にスタックポインタとプログラムカウンタ)を退避し、別コルーチンのスタックへ切り替える」操作です。スタックがそのまま生き残るので、何段深い関数からでも中断でき、再開すれば全フレームがそっくり復元されます。代償は1本ぶんのスタック領域です。

スタックレスは、コルーチンを状態機械へコンパイル時変換します。中断点(await/yield)ごとに状態番号を振り、中断をまたいで生きるローカル変数だけをヒープの状態オブジェクトへ退避し、関数全体を「状態番号で続きへジャンプする1つのresume」に書き換えます。退避するのは関数1個ぶんの最小限なので極めて軽量です。ただし退避できるのはその関数の本体だけで、呼んだ先の関数の途中状態は畳めません。詳しい変換はasync/awaitの内部実装で扱っています。

「スタックレス=スタックを使わない」ではない

スタックレスでも、中断していない実行中は通常のコールスタックを普通に使います。違いは中断状態を保存する専用スタックを持つかどうかだけです。試験では「スタックフルは深い呼び出し先から中断可・スタックレスは中断点を関数境界で明示」「スタックフルは伝播不要・スタックレスはawaitが上へ伝播」という対比が頻出です。スタックの基礎はスタックとヒープの実態で確認できます。

関数の色(async汚染)が生じる理由

スタックレスの「呼び先の途中状態を畳めない」制約は、実コードで関数の色問題として現れます。普通の関数の途中で中断したくても、状態機械化はその関数自身をasyncにしないと適用できません。すると呼び出し元も結果をawaitせざるを得ず、asyncが呼び出し連鎖を上へ上へと伝播します。これが「同期関数から非同期関数を素直に呼べない(色が混ざらない)」現象の正体です。

スタックフルにはこの問題がありません。スタックを丸ごと退避できるので、どんな普通の関数の途中からでも譲れ、印は不要です。Goでnetパッケージの関数を呼ぶだけでブロックせず他のgoroutineへ切り替わるのは、ランタイムがスタックフルな中断を裏で行っているからです。

協調的スケジューリングの落とし穴:譲らなければ止まる

グリーンスレッドやファイバーの多くは協調的で、中断点でしか他へ切り替わりません。中断点を一切含まない重いCPUループを回すと、そのキャリアスレッドは譲るタイミングを失い、同じスレッドに載った他のタスクが飢えます。Goは関数呼び出し時やループのバックエッジに非同期プリエンプションのチェックを差し込んでこれを緩和しますが、原則としてCPUバウンドな処理は別スレッドへ逃がすのが安全です。

切り替えコストの大小と、選択の指針

3方式の切り替えコストは、関与する層の深さで決まります。OSスレッドはカーネルを介し特権遷移とスケジューラ実行を伴うため最も重い。スタックフルコルーチンはユーザー空間でレジスタとスタックポインタを差し替えるだけで、カーネルを通らないぶん桁で安い。スタックレスは状態機械のresume呼び出し、すなわちほぼ「関数呼び出し1回+状態分岐」なので最軽量です。メモリも同順で、スレッドの数MB予約に対し、スタックフルは数KB、スタックレスは状態1個ぶんに収まります。

選択の指針は次のとおりです。

状況・要件向く方式理由
数万〜数十万の並行I/O接続スタックフル軽量スレッド / スタックレスasync1本あたりのメモリが小さく、ブロッキング相当の記述が安全に多重化できる
呼び先の深い箇所からも中断したいスタックフル専用スタックごと退避でき、関数の色が出ない
極限までメモリ・割当を削りたいスタックレス状態オブジェクトが最小・専用スタック不要で組込みにも乗る
真の並列でCPUを使い切りたいOSスレッド(+上に軽量単位)カーネルがコアへ並列配置しプリエンプトする
FFI/Cスタックを多用するOSスレッド寄り可変長・コピー式スタックはネイティブスタック前提のCと相性問題が出やすい

実際のランタイムは折衷です。Goは「M:Nのスタックフルgoroutine+ランタイム内蔵のI/O多重化」で、ブロッキング風に書いて高並行を得ます。Rust/C#/JSは「スタックレスasync+イベントループ」で、ゼロコスト寄りの状態機械を選びました。Javaの仮想スレッド(Project Loom)は後発でスタックフルを選び、既存のブロッキングAPIを書き換えずに数十万本へスケールさせる路線です。どれも「スケジュール主体×スタックの持ち方」という同じ2軸の上で、トレードオフの置き所を変えているにすぎません。

中断中はローカル状態を握り続ける

スタックレス・スタックフルいずれも、中断中はローカル変数(バッファ・接続・ロック)を保持し続けます。中断点をまたいでロックを握ったまま長く待つと、その間ずっとロックが占有され、デッドロックや停滞の温床になります。Rustのasyncが「awaitをまたぐ参照」をSend/ライフタイム制約で厳格に検査するのはこのためです。再開通知が来ないコルーチンは中断したまま宙吊りになり、リーク源にもなります。

まとめ

並行実行単位の違いは、**スケジュール主体(プリエンプティブ/協調的)スタックの持ち方(スタックフル/スタックレス)**という2軸に集約されます。OSスレッドはカーネルがプリエンプトする重い単位、グリーンスレッド・ファイバーはユーザー空間がM:Nで運ぶ軽量単位で、その多くはスタックフルゆえ深い呼び出し先からでも中断でき関数の色が出ません。スタックレスは状態機械化で関数1個ぶんに畳むため最軽量ですが、awaitが呼び出し連鎖を上へ伝播します。切り替えコストとメモリはOSスレッド>スタックフル>スタックレスの順で、各ランタイムはこの2軸の上にトレードオフを置いて設計されています。中断点で「実行の続きと変数」をどこへ退避するか、という一点を起点に見れば、goroutineの軽さからasyncの色問題まで一本の線でつながります。関連する継続の視点は末尾呼び出し最適化と継続(CPS)も併せて押さえると盤石です。

プログラミング Article

末尾呼び出しとスタックフルvsスタックレスを実務で読む

TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。

解決すること

並行処理

比較で見る軸

難易度: advanced / カテゴリ: プログラミング / タグ数: 5

導入後に効く点

スタックフルは専用スタックを丸ごと持ち深い呼び出し先からでも中断できるが数KB〜を消費。スタックレスは状態機械化で関数1個ぶんに退避し軽量だが、awaitが呼び出し連鎖を上へ伝播する。

先に潰すリスク

用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。

数字・仕様の読み方
難易度
advanced
カテゴリ
プログラミング
タグ数
5

判断チェックリスト

  • 自社の用途が「並行処理 / コルーチン」に近いか確認する。
  • 強みである「並行実行単位はOSスレッド・グリーンスレッド・ファイバー・コルーチンに分かれ、違いの本質は「誰がスケジュールし、スタックをどう持つか」にある。」が本当に評価軸になるか確認する。
  • 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
  • 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
  • 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
  • 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。

次に確認する観点

並行処理コルーチングリーンスレッドファイバースタックフレーム並行処理コルーチングリーンスレッド