Go スケジューラーの仕組み — G・M・P モデルを動く図で理解する
Go ランタイムスケジューラーの G (Goroutine)・M (Machine/OS Thread)・P (Processor) モデルを、ランタイムのソースとインタラクティブなアニメーションで説明します。
Go のランタイムスケジューラーは、G・M・P モデルで goroutine を OS スレッドに割り当てています。G(goroutine)は P(実行権)ごとのキューに入り、P を持つ M(OS スレッド)がそのキューから G を取り出して実行します。この流れを runtime パッケージの構造体と関数に沿って追い、同じ動きをアニメーションでも確かめられるようにしました。
文字起こしを表示
Goの軽い並行処理は、ランタイムスケジューラが支えています。G、M、Pという3つの役割で、goroutineがOSスレッド上をどう動くのかを見ていきます。
Gは実行したいgoroutine、MはOSスレッド、PはユーザーのGoコードを実行する権利です。MはPを持っている間だけユーザーgoroutineを実行できます。このモデルで、多数のGを少数のMへ多重化します。
runtime.gは、初期2KBのスタックと、再開用のgobufを持ちます。runtime.mには、スケジューラを動かすg0と、実行中のcurgがあります。runtime.pは、ローカル実行キューやrunnextを持つ、実行権そのものです。
各Pのrunqは、256エントリの固定長リングバッファです。直前に作られたGは、1スロットのrunnextに入り、キューより優先されます。Pごとのローカルキューとは別に、全てのPが共有するグローバルキューもあります。
Mはg0のスタックでschedule関数を呼び、次に走るGを探します。61回に1回はグローバルキューを確認し、その後でrunnextとローカルキューを見ます。まだ見つからなければ、より広い探索に入ります。
findRunnableは、GCやfinalizerの仕事、グローバルキュー、netpollを順に試します。それでも空なら、ランダムに選んだ別のPから仕事を盗みます。最後はtimerやnetpollを待ち、CPUを無駄に回し続けないようにします。
Gがsyscallに入ると、状態は_Gsyscallになり、MもOS側で止まります。Pまで止まると、そのPのキューで待つGが走れません。そこで監視役のsysmonが、retakeでPを取り戻します。syscall中のPに他の仕事があれば、handoffpで別のMへ渡します。
長く走り続けるGには、プリエンプションが必要です。協調的プリエンプションでは、10ミリ秒超の実行をsysmonが見つけます。sysmonがstackguard0に特別な値を入れると、Gは次の関数呼び出しの先頭で実行可能に戻ります。Go 1.14以降は、シグナルベースの非同期プリエンプションで、関数呼び出しのないループも中断できます。
ネットワークI/Oは、netpollerで非同期化されます。conn.Readで待つGは待機状態になり、fdはepollなどOSの仕組みで監視されます。I/Oが完了したGはnetpollで見つかり、実行キューへ戻されます。
Gは、実行可能な_Grunnableから、M上の_Grunningへ移ります。チャネル、mutex、I/Oで待つ時は_Gwaiting、syscall中は_Gsyscallです。終わったGの構造体は_Gdeadになり、フリーリストのgFreeに戻って再利用されます。
Gの切り替えでは、プログラムカウンタやスタックポインタなど数個のレジスタをgobufに保存するだけです。記事の目安では、Gの切り替えは約100から200ナノ秒です。OSスレッドの切り替えは約1から10マイクロ秒で、スタックも1から8MBです。
Pの数はGOMAXPROCSで決まり、Mの数はそれを超えることがあります。たとえばPが4つでも、syscallで止まったMが10本あれば、Mは合計14本になり得ます。Go 1.25以降は、Linuxのcgroup CPU制限を既定値に反映します。手動でGOMAXPROCSを設定すると、この自動調整は無効になります。
Goのスケジューラは、いくつもの仕組みを組み合わせます。ローカルキュー、work stealing、hand-off、netpoller、preemptionです。記事では、G・M・Pの動きをインタラクティブデモで一歩ずつ確かめられます。
G・M・P モデルの概要
G・M・P の内部構造
G(Goroutine)— runtime.g 構造体
Goroutine の実態は runtime/runtime2.go に定義された runtime.g 構造体です。
type g struct {
stack stack // lo, hi — 現在のスタック範囲
stackguard0 uintptr // プリエンプションチェックに使用
m *m // 現在この G を実行している M
sched gobuf // コンテキストスイッチ用の状態
atomicstatus atomic.Uint32 // G の状態フラグ
goid uint64 // goroutine ID
// ... 他多数
}重要なフィールド:
stack— 各 G のスタックは 2KB から始まります(OS スレッドは 1〜8MB)。Go 1.19 以降は、ランタイムが平均的なスタック使用量に合わせて、より大きな初期サイズを割り当てることもあります。足りなくなるとruntime.morestack()で拡張されます。sched(gobuf) — コンテキストスイッチ時に PC (Program Counter)、SP (Stack Pointer)、BP (Base Pointer) を保存する構造体です。G が中断・再開されるたびに読み書きされます。stackguard0— 通常はスタックの下限付近のアドレスです。プリエンプションを要求するときは、特別な値stackPreemptが設定されます。atomicstatus— G のライフサイクルを表す状態:_Gidle→_Grunnable→_Grunning→_Gsyscall/_Gwaiting→_Gdead
M(Machine)— runtime.m 構造体
M は OS スレッドを 1:1 で表す構造体です(runtime/runtime2.go)。
type m struct {
g0 *g // スケジューリング用の特殊 G(大きなスタック)
curg *g // 現在実行中のユーザー G
p *p // 紐付いている P
nextp *p // 起床時に紐付ける P
spinning bool // steal 先を探している状態
// ...
}g0— 各 M に 1 つ存在する特殊な goroutine。schedule()、findRunnable()、GC などのランタイムコードはg0のスタック上で実行されます。mcall()でユーザー G から g0 に切り替えます。curg— 現在 M 上で実行中のユーザー goroutine。execute()で設定され、goexit()やプリエンプションでクリアされます。spinning— M が work stealing のために実行可能な G を探し回っている状態を示すフラグです。ランタイムは spinning 状態の M の数を制限して、CPU の空回りを抑えます。
P(Processor)— runtime.p 構造体
P は「ユーザー Go コードを実行する権利」を表す論理的なリソースです(runtime/runtime2.go)。
type p struct {
status uint32 // _Pidle, _Prunning, _Pgcstop, _Pdead(_Psyscall は Go 1.26 で廃止)
runqhead uint32 // ローカルキューの先頭インデックス
runqtail uint32 // ローカルキューの末尾インデックス
runq [256]guintptr // ローカル実行キュー(リングバッファ)
runnext guintptr // 次に実行する G(キューよりも優先)
gFree struct { ... } // 再利用可能な G のフリーリスト
// ...
}runq— 256 エントリーの固定長リングバッファーで、ロックを取らずに操作できます。G を tail に追加するのは所有者の P だけで、取り出すときは所有者も steal する側も head をアトミックな CAS で進めます。runnext— 現在の G が直前に実行可能にした G(新しく生成した G など)を入れる 1 スロットの枠です。キューより先に実行されるので、直前のデータがキャッシュに残っているうちに処理できます。- P の数は現在の
GOMAXPROCS値に従います。runtime.GOMAXPROCSで実行中に変更でき、Go 1.25 以降では手動設定していなければ既定値も自動で再評価されます。
schedule() ループの詳細
全てのスケジューリング判断は、runtime/proc.go の runtime.schedule() 関数で行われます。M は g0 スタック上でこのループを回り続けます。
func schedule() {
// 公平性: 61回に1回はグローバルキューを優先
if schedtick%61 == 0 {
G = globrunqget(P) // グローバルキューから取得
}
if G == nil {
G = runqget(P) // ① runnext → ② ローカルキュー
}
if G == nil {
G = findRunnable(P) // ③ ブロッキング探索
// → globrunqget → netpoll → steal from random P
}
execute(G) // gobuf 復元 → ユーザーコード実行
}schedule() の周期的なグローバルキューの確認とローカルの runqget() で G が見つからなければ、findRunnable() がより広く探します。trace reader / GC worker の作業、グローバルキュー(2 度目の周期的な確認)、finalizer / cleanup goroutine、ローカルキュー、グローバルキューからのまとめ取り(min(len/GOMAXPROCS+1, len, 128) 個。128 はローカルキュー容量 256 の半分)、非ブロッキングの netpoll、work stealing、アイドル時の GC / trace 作業の順に調べ、最後に timer か netpoll でブロックして待ちます。正確な順序はリリースごとに変わるので、Go 1.27 時点の概略として読んでください。
ステップ実行で見るスケジューラーの動き
下のビジュアライゼーションでは、ここまでの動きを 1 ステップずつ実行できます。各ステップの説明には、そのとき呼ばれるランタイム関数の名前を入れています。
Go スケジューラー: G・M・P の動き
Go のランタイムスケジューラーが goroutine(G)を P(Processor)経由で OS スレッド(M)に割り当てて実行する流れを、1 ステップずつ確認できます。
プリエンプション(Preemption)の内部実装
協調的プリエンプション(Go 1.2〜)
Go コンパイラーはほとんどの関数の先頭にスタックチェックのプロローグを挿入します:
// 関数プロローグ(擬似コード)
MOV AX, [G.stackguard0]
CMP AX, SP
JBE morestack // スタック拡張 or プリエンプションsysmon が retake() で 10ms 以上実行中の G を検知すると、stackguard0 に stackPreempt(0xfffffade)を設定します。次の関数呼び出し時にプロローグでこの値が検出され、morestack() 経由で gopreempt_m() が呼ばれて G は _Grunnable に戻されます。
ただし、for {} のように関数呼び出しを含まないタイトループでは、プロローグを通らないのでプリエンプションが効きません。
非同期プリエンプション(Go 1.14〜)
Go 1.14 でシグナルベースの非同期プリエンプションが導入されました:
- sysmon が
preemptone()を呼ぶ - Unix 系では対象 M に
SIGURGシグナルを送る(他の OS ではプラットフォーム固有の仕組みを使う) - シグナルハンドラー
doSigPreempt()が実行され、現在の PC/SP を保存する - G のスタック上に
asyncPreemptフレームを注入する - G は中断され、
schedule()に制御が戻る
// runtime/signal_unix.go — https://github.com/golang/go/blob/master/src/runtime/signal_unix.go
func doSigPreempt(gp *g, ctxt *sigctxt) {
if wantAsyncPreempt(gp) && isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()) {
ctxt.pushCall(abi.FuncPCABI0(asyncPreempt), ctxt.rip())
}
}isAsyncSafePoint() は、GC のルートセットが正確に列挙できる安全なポイントでのみプリエンプションを許可します。
sysmon — 監視デーモン
sysmon はランタイムの system monitor 関数で、P を持たない専用の M / OS スレッド上でバックグラウンドループを回ります(runtime/proc.go)。
func sysmon() {
for {
usleep(delay) // 20μs 〜 10ms(適応的)
// 1. Netpoller の結果を回収
netpoll(0)
// 2. 長時間の syscall → P を hand-off
retake(now)
// 3. 長時間実行中の G をプリエンプト
// 4. GC の STW が必要な場合にシグナル送信
// 5. 強制 GC のチェック(2分以上 GC がない場合)
}
}retake() の判定ロジック
func retake(now int64) uint32 {
for _, pp := range allp {
if pp.status != _Prunning {
continue // Go 1.26 以降、syscall 中の P も _Prunning のまま
}
// 10ms 以上実行 → preemptone()
if pd.schedwhen + 10ms <= now {
preemptone(pp)
}
if inSyscall(pp) { // P の G が _Gsyscall
// 20μs 以上 syscall 中 & 他に仕事がある → handoffp()
if runqempty(pp) && sched.nmspinning + sched.npidle > 0 {
continue // すぐには hand-off しない
}
handoffp(pp) // P を M から切り離す
}
}
}Netpoller — 非同期 I/O 統合
Go の全てのネットワーク I/O(net.Conn.Read() など)は、netpoller を通じて非同期に処理されます。
- 接続の fd は、作成された時点で epoll/kqueue/IOCP に登録される
- G がネットワーク I/O を開始し、すぐに読み書きできなければ
runtime.pollWait()でgopark()して_Gwaitingになる findRunnable()やsysmonが定期的にnetpoll()を呼び出す- I/O 完了した G を
injectglist()で実行キューに戻す
// ネットワーク I/O の内部フロー
conn.Read(buf)
→ internal/poll.FD.Read()
→ runtime.pollWait()
→ gopark(netpollblockcommit) // G を _Gwaiting に
// ... I/O 完了を epoll_wait で検知 ...
runtime.netpoll()
→ G を _Grunnable に戻す
→ injectglist() でキューに投入syscall(read(2) 等)とは異なり、netpoller は M をブロックしません。そのため net/http サーバーは、大量の接続を少数の OS スレッドで処理できます。
G のライフサイクル — 状態遷移
_Gidle → _Gdead → _Grunnable → _Grunning → _Gdead
↑ ↓
← ← preempt ← ←
↑ ↓
← _Gwaiting ← (chan/mutex/IO)
↑ ↓
← _Gsyscall ← (syscall)_Grunnable— 実行可能だがまだ M に割り当てられていない_Grunning— M 上で実行中_Gwaiting— chan 受信、mutex、I/O 待ちなどでブロック中_Gsyscall— システムコール実行中(M もブロック)_Gdead— 完了。構造体はgFreeリストにプールされ再利用される
コンテキストスイッチのコスト
goroutine の切り替えは、OS のスレッド切り替えより一桁ほど安く済みます。表の時間は公開されている測定例で、CPU や OS によって変わります。
| Go goroutine | OS スレッド | |
|---|---|---|
| 切り替え対象 | PC, SP, BP, 数個のレジスター(gobuf) | より多くのレジスターとカーネルスケジューラー状態。キャッシュ / TLB への影響もあり得る(ページテーブル切替は主にプロセス切替時) |
| 切り替えコスト | 約 170ns(Bendersky の測定) | 約 1〜2μs(同じ測定。キャッシュへの影響を除く直接コスト) |
| スタックサイズ | 初期 2KB 以上(動的拡張) | 固定 1〜8MB |
| 生成コスト | 約 200ns(Go ランタイムのベンチマーク結果) | 約 5μs(pthread_create の測定) |
gobuf の構造:
type gobuf struct {
sp uintptr // スタックポインタ
pc uintptr // プログラムカウンタ
g guintptr
ctxt unsafe.Pointer
ret uintptr
lr uintptr // ARM のリンクレジスタ
bp uintptr // ベースポインタ(フレームポインタ)
}GOMAXPROCS と実際のスレッド数
現在の P の数は GOMAXPROCS で決まりますが、実際の M(OS スレッド)の数はそれより多くなることがあります。
- syscall でブロックされた M は P を手放し、その P を動かすために新しい M が生成される
- M の最大数はデフォルトで 10,000(
runtime/debug.SetMaxThreads) - アイドル M はすぐには破棄されず、
midleリストにプールされる
runtime.GOMAXPROCS(4) // P = 4 個
// でも M は 4 個以上存在しうる
// 例: 4つの P + syscall でブロック中の M が 10 個 = 合計 14 Mコンテナーを意識する既定値(Go 1.25〜)
長らく GOMAXPROCS の既定値は起動時の論理 CPU 数(runtime.NumCPU())でした。コンテナー環境では、これがよくある性能問題の原因になっていました。例えば 64 コアのノードで CPU limit を 2 コアに絞った Pod を動かすと、Go ランタイムは GOMAXPROCS=64 として 64 個の P を作っていました。その結果、cgroup の CPU 制限でスロットリングされ、余分なコンテキストスイッチも増えてレイテンシーが悪化していました。従来は automaxprocs のような外部ライブラリーで対処するのが一般的でした。
Go 1.25 でこの対処がランタイム本体に取り込まれました。
- Linux では cgroup の CPU 帯域制限を考慮する — 制限が論理 CPU 数より小さければ、そちらを
GOMAXPROCSの既定値にする。Kubernetes でいう CPU limit が対象で、CPU requests は考慮されない - 全 OS で定期的に再評価する — 利用可能な論理 CPU 数や cgroup 制限が変われば
GOMAXPROCSを追従させる
これらは GOMAXPROCS 環境変数や runtime.GOMAXPROCS() で手動設定した時点で自動的に無効化されます(GODEBUG の containermaxprocs=0 / updatemaxprocs=0 で個別に無効化することもできます)。逆に、一度手動設定した後で既定の振る舞いに戻したい場合は、新しい runtime.SetDefaultGOMAXPROCS() を使います。
まとめ
Go スケジューラーの G・M・P モデルを構成する仕組みは、次の 7 つです。
- M:N スケジューリング — 多数の G を少数の M に多重化する
- ロックフリーのローカルキュー — P ごとの 256 エントリーのリングバッファーで、ロックを取らずに G を出し入れする
- Work Stealing — 仕事のない P が他の P のキューから G を取ってきて負荷を分散する
- Hand-off — M が syscall でブロックされても、P を別の M に渡して実行を続ける
- Netpoller — ネットワーク I/O を epoll/kqueue で非同期に処理し、M をブロックしない
- 協調的 + 非同期プリエンプション — 関数プロローグのチェックとプラットフォーム固有の非同期プリエンプションで、1 つの G が CPU を占有し続けるのを防ぐ
- sysmon — P を持たない専用スレッドで、長時間の syscall や実行を検知して対処する