GoroutineのM:Nスケジューリング|GMPモデルと切り替えコストの実測
goroutineを数十万本立ててもプロセスが破綻しないのは、Goランタイムが多数のgoroutineを少数のOSスレッドへ載せ替えるM:Nスケジューリングを採っているからです。この記事では、1:1/N:1モデルとの対応関係、GMPモデルの実行キュー構造、コンテキストスイッチのコストの実測値、コルーチンとの決定的な違い、コンテナ環境でのGOMAXPROCSの決まり方、スケジューラの観測手段、そしてgoroutineプールの要否までを、Go 1.26.5のランタイムソースと手元での測定にもとづいて扱います。goroutineの定義やgo文の基本的な書き方はゴルーチン(Goroutine)とは?Go言語の並行処理を支える軽量スレッドの仕組みと使い方で解説しているため、ここではスケジューラの内部動作だけを掘ります。
まとめ:M:Nスケジューリングとコンテキストスイッチの要点
M:Nスケジューリングは、M個のgoroutineをN個のOSスレッドに多対多で対応づける方式です。1本のgoroutineが必ず1本のOSスレッドを占有する1:1モデルと違い、goroutineが待ち状態に入ってもOSスレッドは別のgoroutineの実行に回されます。
実行主体はG(goroutine)・M(OSスレッド)・P(実行に必要な論理プロセッサ)の3つで、GはPが持つ256要素のローカル実行キューに積まれます。キューが空になったPは他のPから仕事を盗みます(ワークスティーリング)。
切り替えが安いのはカーネルをまたがないためです。手元の測定では、goroutine間のチャネル往復が約1.1マイクロ秒だったのに対し、システムコールを経由するパイプ往復は約80マイクロ秒で、約70倍の開きがありました。goroutine 1本の固定コストは初期スタック2,048バイトと実測できます。
Go 1.14以降は協調的な譲り合いだけでなくシグナルによる非同期プリエンプションが働くため、関数呼び出しのないループがスケジューラを止めることはありません。ここがコルーチンとの決定的な違いです。
M:Nスケジューリングとは:1:1・N:1モデルとの対応関係
スレッドモデルは、アプリケーション側の実行単位とOSスレッドの対応関係で3つに分かれます。M:Nはその折衷案で、GoのgoroutineのほかにErlangのプロセスが同じ系統に属します。
| モデル | 対応関係 | 切り替えの主体 | 弱点 | 採用例 |
|---|---|---|---|---|
| 1:1 | 実行単位1つにOSスレッド1本 | OSカーネル | 生成コストとメモリが重く数万本は非現実的 | Java(仮想スレッド以前)、pthread |
| N:1 | 複数の実行単位を1本のOSスレッドに集約 | ユーザ空間のランタイム | 1本がブロックすると全体が止まる/マルチコアを使えない | 初期のグリーンスレッド |
| M:N | M個の実行単位をN本のOSスレッドに分散 | ランタイムとOSの併用 | ランタイムの実装が複雑 | Goのgoroutine |
N:1の致命的な弱点は、1つの実行単位がシステムコールでブロックすると他の全員が巻き添えを食う点でした。Goはブロックが起きたOSスレッドからPを切り離し、別のスレッドへ渡すことでこれを回避しています。ユーザ空間で管理する軽量スレッドの系譜についてはグリーンスレッドとは何か?基本的な定義とその誕生背景を解説を参照してください。
GMPモデルの構成要素と実行キューの構造
G・M・Pの役割分担
runtime/proc.goの冒頭コメントにある定義は、G(goroutine)、M(worker thread, or machine)、P(processor、Goコードの実行に必要な資源)の3つです。Goコードを実行するMは必ずPを1つ持ち、逆にシステムコール中やブロック中のMはPを持たないことがあります。Pの数はGOMAXPROCSと一致し、これが同時にGoコードを走らせられる並列度の上限です。
Mの本数はPより多くなります。ブロックするシステムコールの分だけスレッドが要るためで、上限はsched.maxmcount = 10000(runtime/proc.go)。この上限に達するとプログラムはクラッシュします。runtime/debug.SetMaxThreadsで変更はできるものの、必要になる時点で設計側にgoroutineの止め忘れがあると考えたほうが妥当でしょう。
ローカル実行キューとワークスティーリング
各Pはrunq [256]guintptrという固定長のローカル実行キューを持ちます(runtime/runtime2.go)。あふれた分はグローバルキューへ移されます。さらにPにはrunnextという単一スロットがあり、いま走っているGが起こした(ready状態にした)Gはキュー末尾ではなくここへ入って次に実行されます。ソースのコメントによれば、これは「通信して待つ」パターンのgoroutine群をひとまとまりとしてスケジュールし、スケジューリング遅延を消すための仕組みです。
ローカルキューが空になったPは、stealWork()で他のPのキューから半分を奪います(runqsteal())。ただしローカルキューだけを見ていると、互いを起こし合う2本のgoroutineがPを占有し続けてグローバルキューの実行が飢えます。そのためスケジューラはpp.schedtick%61 == 0のとき、つまり61回に1回はグローバルキューを先に確認します。この61という値はソースにハードコードされた公平性のための定数です。
システムコールでMが止まったときのPの引き渡し
goroutineがブロックするシステムコールへ入ると、そのMはカーネルの中で止まります。このときランタイムはhandoffp()でPを切り離し、別のM(必要なら新規に作る)へ渡します。Pさえ空いていれば、止まったスレッドとは無関係に残りのgoroutineが走り続けられるわけです。
長く走り続けるGを止める役目はsysmonという専用スレッドが担います。forcePreemptNS = 10 * 1000 * 1000、すなわち同じGが10ミリ秒を超えて同じPを握っていると、sysmonがプリエンプト要求を出します(runtime/proc.goのretake関数)。この10ミリ秒がGoにおける実質的なタイムスライスです。
コンテキストスイッチのオーバーヘッド:チャネル往復とパイプ往復の実測
測定環境と測定コード
「goroutineの切り替えは軽い」という説明は多いものの、比較対象と数値が示されないことがほとんどです。そこで、ユーザ空間で完結するチャネル往復と、カーネルを経由するパイプ往復を同じマシンで測りました。測定環境はmacOS 26.6/Intel Core i9-9880H(16論理CPU)/go1.26.5 darwin/amd64、GOMAXPROCSは既定の16です。測定に使った4本のベンチマークを全文掲載します。
func BenchmarkChanPingPong(b *testing.B) {
ping := make(chan struct{})
pong := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for range ping {
pong <- struct{}{}
}
}()
b.ResetTimer()
for i := 0; i < b.N; i++ {
ping <- struct{}{}
<-pong
}
b.StopTimer()
close(ping)
wg.Wait()
}
func BenchmarkPipePingPong(b *testing.B) {
r1, w1, _ := os.Pipe()
r2, w2, _ := os.Pipe()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
runtime.LockOSThread()
buf := make([]byte, 1)
for {
if _, err := r1.Read(buf); err != nil {
return
}
w2.Write(buf)
}
}()
runtime.LockOSThread()
buf := make([]byte, 1)
b.ResetTimer()
for i := 0; i < b.N; i++ {
w1.Write(buf)
r2.Read(buf)
}
b.StopTimer()
w1.Close()
wg.Wait()
runtime.UnlockOSThread()
}
func BenchmarkGoroutineSpawn(b *testing.B) {
var wg sync.WaitGroup
b.ResetTimer()
for i := 0; i < b.N; i++ {
wg.Add(1)
go func() { wg.Done() }()
}
wg.Wait()
}
func BenchmarkGosched(b *testing.B) {
for i := 0; i < b.N; i++ {
runtime.Gosched()
}
}
往復1回あたりのコスト比較
go test -bench . -benchtime=500000x -count=5で実行した結果が次の値です。往復1回には2回の切り替えが含まれ、goroutine起動の値にはsync.WaitGroupのAddとDoneが含まれます。
| 測定対象 | 実測値(ns/op) | 経路 |
|---|---|---|
| チャネル往復(goroutine間) | 1,093〜1,196 | ユーザ空間のみ |
| パイプ往復(システムコール経由) | 77,639〜88,209 | write/readとカーネルによる起床 |
| runtime.Gosched()1回 | 419〜472 | スケジューラを1周 |
| 空のgoroutineを1本起動して完了 | 714〜758 | 生成・実行・破棄まで |
差は約70倍でした。この絶対値はCPUとOSに依存し、特にパイプ側はdarwinのイベント通知の遅さを含むため、Linuxで測れば比率は縮みます。それでも、goroutineの切り替えがカーネルモードへの遷移を伴わない一方、スレッド間の受け渡しはシステムコールとカーネルの起床処理を必ず通るという構造の差は変わりません。goroutine 1本の起動が0.7マイクロ秒台という点も、後述するプール設計の判断に直結します。
goroutine 1本あたりのメモリ実測
切り替えコストと並ぶもう一方の軸がメモリです。チャネル受信で待たせたgoroutineを10万本作り、runtime.ReadMemStatsの差分を取りました。
const n = 100000
runtime.GC()
var a, c runtime.MemStats
runtime.ReadMemStats(&a)
stop := make(chan struct{})
done := make(chan struct{})
for i := 0; i < n; i++ {
go func() { <-stop; done <- struct{}{} }()
}
runtime.Gosched()
runtime.ReadMemStats(&c)
fmt.Printf("goroutines=%d heap_delta=%d B (%.0f B/goroutine) stack_inuse_delta=%d B (%.0f B/goroutine)\n",
runtime.NumGoroutine(),
c.HeapAlloc-a.HeapAlloc, float64(c.HeapAlloc-a.HeapAlloc)/n,
c.StackInuse-a.StackInuse, float64(c.StackInuse-a.StackInuse)/n)
goroutines=100002 heap_delta=63509688 B (635 B/goroutine) stack_inuse_delta=204832768 B (2048 B/goroutine)
スタックは1本あたりちょうど2,048バイトで、これはruntime/stack.goのstackMin = 2048と一致します。加えてg構造体などのヒープ確保が635バイトあり、待機中のgoroutine 1本の実コストは約2.7キロバイトでした。10万本で約270メガバイト。「いくらでも作れる」わけではないことも同時に分かります。
コルーチンとの違い:協調的スケジューリングと非同期プリエンプション
コルーチンは、処理の途中で自ら制御を呼び出し側へ返せる(中断と再開ができる)実行単位を指します。中断点を書き手が明示する協調的スケジューリングが前提で、譲らないコードが1つあると他は動けません。goroutineも当初は同じ弱点を抱えていました。関数呼び出しを含まない計算ループがあると、スケジューラが割り込む余地がなかったためです。
Go 1.14のリリースノートは「Goroutines are now asynchronously preemptible.(goroutineは非同期にプリエンプト可能になった)」と述べ、その結果として関数呼び出しのないループがスケジューラをデッドロックさせたりGCを大幅に遅らせたりしなくなったと明記しています(windows/arm、darwin/arm、js/wasm、plan9系を除く全プラットフォームが対象)。実装はシグナルによる割り込みで、GODEBUGのasyncpreemptoff=1で無効化できます。無効化するとループが長時間プリエンプト不能になる旨がruntime/extern.goに書かれています。つまり現在のgoroutineは、書き手の善意に依存しないプリエンプティブな実行単位です。
コンテナでGOMAXPROCSが過大になる問題とGo 1.25の既定変更
Kubernetes上でGoのプロセスがスロットリングされる典型的な原因が、GOMAXPROCSとCPU limitの不一致でした。Go 1.24以前のGOMAXPROCSは起動時の論理CPU数(runtime.NumCPU)を既定値としており、64コアのノードでCPU limitが1コアのPodでもPを64個作ります。並列に走らせようとした分だけcgroupの帯域を使い切り、周期の残りを待たされます。
Go 1.25で、この既定が2点変わりました。1つはLinuxでプロセスが属するcgroupのCPU帯域制限を考慮し、論理CPU数より低ければそちらをGOMAXPROCSの既定にすること。もう1つは、論理CPU数やcgroupの制限が変わった場合に全OSでランタイムが定期的にGOMAXPROCSを更新することです。リリースノートはCPU limitに対応する一方でCPU requestsは考慮しないと明示しています。環境変数やruntime.GOMAXPROCSで明示指定するとどちらも無効になり、GODEBUGのcontainermaxprocs=0とupdatemaxprocs=0でも個別に無効化できます。
実務上の判断は単純で、Go 1.25以降へ上げてGOMAXPROCSを明示的に設定していないなら、コンテナ向けに手動で値を注入する必要はなくなりました。逆に古い運用でGOMAXPROCSを環境変数へ固定したままだと、この自動調整が丸ごと無効になる点に注意してください。Go 1.25のその他の変更点はGo1.25リリースの新機能概要:ツール、ランタイム、言語仕様、ライブラリにおける主要な変更点を徹底解説にまとめています。
スケジューラの挙動を観測する手段:schedtraceとruntime/metrics
GODEBUG=schedtraceによる状態の定期出力
schedtrace=Xを設定すると、Xミリ秒ごとにスケジューラの状態が標準エラーへ1行出力されます。go runではツールチェーン自身の行が混ざるため、必ずバイナリをビルドしてから実行してください。ブロッキングなread(2)で待つgoroutineを50本作るプログラムをgo build -o app main.goしたうえでGODEBUG=schedtrace=1000 ./appで動かした実出力が次の行です。
SCHED 1042ms: gomaxprocs=16 idleprocs=16 threads=53 spinningthreads=0 needspinning=0 idlethreads=1 runqueue=0 [ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 ] schedticks=[ 6 3 2 2 3 3 3 3 3 3 5 3 3 3 3 6 ]
runqueueがグローバルキューの長さ、1つ目の角括弧に並ぶ16個の数値が各Pのローカルキューの長さ、2つ目のschedticksが各Pがスケジュールを行った回数です。ここで注目したいのはgomaxprocs=16に対してthreads=53という値で、ブロッキングシステムコールに入った50本のぶんだけMが増えています。Pの数とOSスレッドの数は一致しません。ローカルキューが慢性的に積み上がっていればPが足りず、idleprocsが多いのに処理が進まないならgoroutineがどこかで待っています。
runtime/metricsの/sched系メトリクス(Go 1.26)
Go 1.26(2026年2月)でスケジューラのメトリクスが追加されました。上と同じプログラムからmetrics.Readで取得した値が次のとおりです。
/sched/gomaxprocs:threads 16
/sched/threads/total:threads 53
/sched/goroutines:goroutines 56
/sched/goroutines/running:goroutines 1
/sched/goroutines/runnable:goroutines 0
/sched/goroutines/waiting:goroutines 5
/sched/goroutines/not-in-go:goroutines 50
/sched/goroutines-created:goroutines 56
schedtraceのthreads=53と/sched/threads/total:threadsが一致し、システムコールで待っている50本がnot-in-goとして数えられています。なおリリースノートはOSスレッド数のメトリクスを/sched/threads:threadsと表記していますが、go1.26.5が実際に公開している名前は/sched/threads/total:threadsです。名前を直書きする前にmetrics.All()で確認してください。runnableが積み上がったままならCPU不足、waitingばかりならブロック待ちと、schedtraceより機械的に監視へ載せられます。同じくGo 1.26では、GCの到達可能性解析を使ってリークしたgoroutineを検出するgoroutineleakプロファイルがGOEXPERIMENT=goroutineleakprofile付きのビルドで試せるようになりました。
goroutineプールを使うべき場面・避けるべき場面
goroutineを使い回すためのプールは、ほとんどの場合に不要です。上の実測どおり空のgoroutineの起動から完了までが0.7マイクロ秒台であり、プールが持ち込む状態管理(使い回しに伴う変数の残留、パニック時のワーカー復旧、停止処理)に見合いません。プールで速くなるのは、1リクエストあたりの処理がマイクロ秒オーダーで、かつ秒間数十万回起動するような限られたケースだけです。
一方で必要なのは「再利用」ではなく「同時実行数の制限」です。外部APIやデータベースのように同時接続数に上限がある相手へ無制限にgoroutineを投げると、相手を落とします。この用途にはバッファ付きチャネルをセマフォとして使うか、golang.org/x/sync/semaphoreを使えば足り、ワーカーを常駐させる必要はありません。チャネルによる制御の書き方はGo言語のChannelとは?その特徴と用途について徹底解説で扱っています。
常駐ワーカー型のプールを選ぶ判断基準は1つだけです。ワーカーごとに使い回したい高価な資源(専用の接続、確保済みバッファ、CGO側のハンドルなど)がある場合に限ります。それ以外は、必要な数だけgoroutineを起動し、Go言語のcontext入門|BackgroundとTODOの使い分け、キャンセルとタイムアウトの実装で解説しているキャンセル経路を必ず渡す構成のほうが、リークの調査もはるかに容易です。
よくある質問
GoはなぜM:Nスケジューリングを採っているのですか?
1:1モデルでは実行単位1つにOSスレッド1本が必要で、生成コストとメモリの両面から数万本の同時実行が現実的ではないためです。かといってN:1モデルでは1本のブロックが全体を止め、マルチコアも使えません。M個のgoroutineをN本のOSスレッド上で動かすM:Nなら、待ちに入ったgoroutineの裏でスレッドを別の仕事に回せます。
goroutineの切り替えはOSスレッドより速いのですか?
本記事の測定環境(macOS 26.6/Intel Core i9-9880H/go1.26.5、GOMAXPROCS=16)では、goroutine間のチャネル往復とシステムコール経由のパイプ往復で約70倍の差がありました。絶対値も比率もOSとCPUで変わりますが、goroutineの切り替えがカーネルモードへの遷移を含まない点は環境によらず共通です。
コルーチンとgoroutineは何が違いますか?
コルーチンは中断点を書き手が明示する協調的な実行単位です。goroutineはGo 1.14以降シグナルによる非同期プリエンプションが働くため、関数呼び出しのないループでもランタイムが割り込めます。加えてgoroutineは複数のOSスレッドへ分散されるので、単一スレッドに閉じるコルーチンと違いマルチコアで並列に走ります。
OSスレッド(M)は何本まで作られますか?
既定の上限は10,000本です(runtime/proc.goのsched.maxmcount = 10000)。cgo経由でC側が作ったスレッドはこの上限から除外されます。値はruntime/debug.SetMaxThreadsで変更できますが、上限に達するのは通常ブロックするシステムコールを抱えたgoroutineが滞留しているサインなので、先に滞留の原因を潰すのが筋です。
GOMAXPROCSはCPUコア数より大きくすべきですか?
大きくする理由はほぼありません。ブロックするシステムコール中のMはPを手放すため、I/O待ちの本数はGOMAXPROCSに縛られないからです。Go 1.25以降はcgroupのCPU帯域制限も既定値へ反映されるので、コンテナでも手動設定なしで妥当な値になります。