グリーンスレッドとユーザ空間スケジューラの原理
goroutineが数万本軽く回るのに、なぜOSスレッドは数千で頭打ちなのか。ランタイムがスタックとスケジューラを自前で持つ仕組みを、協調/プリエンプティブの境目まで原理から腑に落とせます。
- 1.グリーンスレッドはランタイムが管理する軽量スレッドで、生成・切替が関数呼び出し並みに軽い。多数をM:N的に少数のOSスレッドへ多重化し、ブロック点でランタイムが付け替える。
- 2.協調スケジューリングはyieldやI/O待ちでだけ切り替わるため割り込みが要らず軽いが、CPUを離さないループに弱い。プリエンプティブ化はランタイムが安全点やシグナルで強制切替を注入して解決する。
- 3.鍵はスタック管理。Goは増減する可変スタック、Erlangはヒープ割り当て、コルーチンは固定の小スタックを使い、いずれもOSスレッドの既定8MiBより桁違いに小さい。
グリーンスレッドとは──カーネルが知らない実行単位
グリーンスレッドとは、OS カーネルではなく 言語ランタイム(ユーザ空間) が生成・スケジュールする軽量な実行単位です。goroutine、Erlang プロセス、各種コルーチン、Java の仮想スレッドがこれにあたります。カーネルから見ると、走っているのは普段どおりの少数の OS スレッドだけで、その内側で何千本もの論理スレッドが多重化されていることをカーネルは一切知りません。
なぜ「軽い」のか。OS スレッドの生成・切替はシステムコールとモード遷移、カーネルのスケジューラ処理を伴います。これに対しグリーンスレッドの切替は ユーザ空間内のレジスタとスタックポインタの退避・復元だけ で済み、カーネルへ落ちません。生成も mmap での大きなスタック予約ではなく、小さなオブジェクト割り当てで足ります。この差が「OS スレッドは数千で頭打ち、goroutine は数十万」という桁違いの密度を生みます。
この多重化は本質的に M:N(M 本の論理スレッドを N 本の OS スレッドへ)であり、汎用 OS のスレッドライブラリでは破綻した方式です(スレッドモデル参照)。ランタイムが成功するのは、I/O を全面的に非ブロッキング化し、ブロック点を自分で管理できる からです。
2種類のスケジューリング──協調とプリエンプティブ
ユーザ空間スケジューラの中核は「いつ実行権を別の論理スレッドへ渡すか」です。
| 観点 | 協調的(cooperative) | プリエンプティブ(preemptive) |
|---|---|---|
| 切替の契機 | 自発的なyield・I/O待ち・チャネル操作 | ランタイムが安全点やシグナルで強制 |
| 割り込みの要否 | 不要(純粋にユーザ空間で完結) | 必要(タイマ/シグナル/安全点チェック) |
| 暴走ループへの耐性 | 弱い(CPUを離さないと止められない) | 強い(強制的に横取りできる) |
| 実装の単純さ | 単純 | 複雑(割込み点の安全性確保が要る) |
| 代表例 | 初期goroutine, 多くのコルーチン, Java仮想スレッド | 現行Go, Erlang BEAM |
協調的スケジューリング は、論理スレッドが自分で「ここで譲る」と宣言した点でだけ切り替わります。yield、チャネル送受信、I/O 待ち、ロック待ちなどが譲り点です。割り込み機構が要らず、譲り点では状態が静止しているため退避が安全──実装が単純で軽い。弱点は明確で、譲り点を一度も通らない CPU 密集ループ に入った論理スレッドは、他を一切走らせません。最悪、1 本の for {} ループが OS スレッド 1 本を占有し続けます。
プリエンプティブスケジューリング は、ランタイムがこの暴走を外から止めます。鍵は「どこで安全に切れるか」です。任意の機械語命令の途中で切ると、レジスタやスタックが中途半端でガベージコレクタやランタイムが状態を解釈できません。そこで 安全点(safepoint) という、状態が一貫している地点でだけ実際の切替を行います。
- 協調のみ(〜Go 1.13):コンパイラが関数の入口(プロローグ)に「スタック拡張が要るか」のチェックを挿入し、その分岐をスケジューラの譲り点として流用していた。関数呼び出しのない長いループはプリエンプトできず、GC が全 goroutine の停止(stop-the-world)を待てない問題があった。
- 非同期プリエンプション(Go 1.14〜):ランタイムが対象スレッドへ
SIGURGシグナルを送り、シグナルハンドラの中で「今のプログラムカウンタが安全点か」を判定して切替を注入する。これにより関数呼び出しを含まないタイトループでも強制的に横取りできるようになった。
ここで効くのが、OS のカーネルプリエンプションと同じ「安全に横取りできる点をどう作るか」という発想です(カーネルプリエンプションと同型の問題)。違いは、カーネルはハードウェアのタイマ割り込みを直接使えるのに対し、ユーザ空間ランタイムはシグナルやコンパイラが埋め込んだチェックという 間接的な仕掛け で割り込みを再現しなければならない点です。
スタック管理──軽さの源泉とトレードオフ
グリーンスレッドの密度を決定づけるのが スタックの持ち方 です。OS スレッドは既定で 1 本あたり 8MiB の仮想アドレスを予約します(実体はデマンドページングで触れた分だけ)。数十万本では仮想空間と管理コストが破綻します。ランタイムはこれを 3 つの方式で回避します。
| 方式 | 仕組み | 代表 | コスト/制約 |
|---|---|---|---|
| 可変スタック(連続) | 小さく開始し、不足時に大きい領域へコピーして全ポインタを書き換える | 現行Go(初期2KiB〜) | 移動時にポインタ調整が要る/GCと協調が必須 |
| 分割スタック | 不足時に別チャンクを連結する | Goの旧実装, 一部Rust初期 | 境界をまたぐ呼出が頻発するとhot/cold問題で性能が振動する |
| ヒープ割り当て | スタックフレーム相当を全てヒープに置く | Erlang/BEAM | GC前提・ポインタ追跡が容易だが生フレームより重い |
| 固定小スタック | 小さな固定サイズを最初に確保し増やさない | 多くのコルーチン | 深い再帰でオーバーフロー/サイズ見積りが要る |
Go が採る 可変連続スタック は、初期 2KiB 程度で始め、関数プロローグの「スタック不足チェック」が引っかかると倍程度の新領域を確保し、古いスタックの内容を丸ごとコピーして移動 します。スタックが動くため、スタック上を指すポインタを全て新アドレスへ書き換える必要があり、これはランタイムがオブジェクトのレイアウトを完全に把握している(型情報を持つ)からこそ可能です。C のような生ポインタ言語ではスタックを安全に動かせないため、この方式は採れません。
可変スタックでは、ローカル変数のアドレスがスタック拡張の前後で変わり得ます。だから Go では「スタック上のローカルを指すポインタを長生きさせる」と、コンパイラのエスケープ解析がそれを検知して ヒープへ退避(escape) させます。「なぜこの変数がヒープに乗ったのか」の多くは、この可変スタックとの整合性確保が理由です。
ブロッキングをどう隠すか──ランタイムの肝
最大の設計課題は、論理スレッドがブロッキングシステムコール(ディスク read、同期的ネットワーク I/O など)を呼んだときです。素朴に呼ぶと、それを載せていた OS スレッドごと眠り、同居する他の論理スレッドまで巻き添えで止まります。ランタイムはこれを 2 段で防ぎます。
第一に、ネットワーク I/O は非ブロッキング化 し、内部の I/O 多重化機構(epoll/kqueue)に肩代わりさせます。論理スレッドが read を呼ぶと、ランタイムは裏でノンブロッキング read を発行し、EAGAIN なら その論理スレッドだけを待機キューへ退避 して別の論理スレッドを走らせます。I/O が準備できたら多重化機構が通知し、ランタイムが当該論理スレッドを実行可能列へ戻します。論理スレッドからは普通の同期 read に見えるのに、OS スレッドはブロックされない──これが「同期的に書けて非同期に動く」の正体です。
第二に、避けられない真のブロッキング(一部のファイル I/O や C 呼び出し)には、Go なら新しい OS スレッドを補充して並列度を保ちます。Go ランタイムの G-M-P モデル(G=goroutine、M=OS スレッド/machine、P=論理プロセッサ=実行コンテキスト)では、システムコールに入った M は持っていた P を手放し、別の M が P を引き継いで他の G を走らせ続けます。
P の個数は既定で CPU コア数(GOMAXPROCS)に等しく、これが 真の並列度の上限 を決めます。各 P はローカルな実行可能 goroutine キューを持ち、自分のキューが空になった P は他の P のキューから半分を奪う ワークスティーリング で負荷を均します。ローカルキュー優先によりロック競合とキャッシュ移動を減らしつつ、全体の負荷分散を成立させる設計です。
Erlang/BEAM はさらに踏み込み、各スケジューラスレッドが リダクションカウント(関数呼び出し等の実行量カウンタ)を持ち、一定数を消費したら問答無用でプロセスを切り替えます。これによりどれか 1 プロセスが暴走しても全体の応答性が保たれ、ソフトリアルタイム性を担保します。
カーネルスレッドとの対比──適材適所
| 観点 | OS(カーネル)スレッド | グリーンスレッド |
|---|---|---|
| スケジューラ | カーネル(例 CFS/EEVDF) | 言語ランタイム(ユーザ空間) |
| 切替コスト | 中(システムコール+モード遷移) | 極小(レジスタ退避のみ) |
| 生成コスト/スタック | 重い・既定8MiB予約 | 軽い・数KiB〜可変 |
| 並列度 | コア数まで(カーネルが分散) | M:N、真の並列はOSスレッド数まで |
| ブロッキング | そのスレッドだけ眠る | ランタイムが付け替えて隠す |
| CPU密集ループ | タイマ割り込みで必ず横取り | プリエンプティブ実装が要る |
両者は競合ではなく 階層 です。グリーンスレッドは必ず下層の OS スレッド上で走り、真の並列度は OS スレッド数(≒コア数)で頭打ちになります。グリーンスレッドが効くのは I/O 待ちが多く待機本数が膨大 な領域(多数の同時接続をさばくサーバーなど)で、待機コストをほぼゼロにできます。逆に 純粋な CPU バウンド計算 では、論理スレッドを増やしても並列度は上がらず、OS スレッドやスレッドプールで十分です。
- グリーンスレッドは ランタイムが管理するM:N多重化。切替がユーザ空間で完結するため軽いが、真の並列度は下層OSスレッド数まで。
- スケジューリングは 協調(yield/I/O待ちで切替・暴走ループに弱い) と プリエンプティブ(安全点やシグナルで強制切替) の2系統。現行Go・Erlangは後者を実装、Java仮想スレッドはブロック点で譲る協調型。
- スタックは可変連続(Go・初期2KiB)/ヒープ(Erlang)/固定小(コルーチン)。OSスレッドの8MiBより桁違いに小さいことが高密度の源泉。
- ブロッキングは 非ブロッキングI/O+多重化(epoll等)への付け替え で隠す。GoのG-M-Pはシステムコール時にPを別Mへ引き継ぐ。
グリーンスレッドの本質は「スケジューラとスタック割り当てをカーネルから取り戻し、自分の都合のいい粒度で並行性を作る」ことです。文脈の退避・復元という機械的な仕組みはコンテキストスイッチと同じですが、それを 誰がどこで判断するか をユーザ空間へ移したことが、密度と書き味の両方を生んでいます。
OS Article
グリーンスレッドとユーザ空間スケジューラの原理を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
グリーンスレッド
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 6
導入後に効く点
協調スケジューリングはyieldやI/O待ちでだけ切り替わるため割り込みが要らず軽いが、CPUを離さないループに弱い。プリエンプティブ化はランタイムが安全点やシグナルで強制切替を注入して解決する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 6
判断チェックリスト
- 自社の用途が「グリーンスレッド / goroutine」に近いか確認する。
- 強みである「グリーンスレッドはランタイムが管理する軽量スレッドで、生成・切替が関数呼び出し並みに軽い。多数をM:N的に少数のOSスレッドへ多重化し、ブロック点でランタイムが付け替える。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。