Goが数十万本のgoroutineを扱いやすい理由の一つは、ランタイムが多数のgoroutineを少数のOSスレッドへ載せ替えるM:Nスケジューリングを採っているからです。この記事では、その中身であるGMPモデルを、実行キューの構造、次に走らせるgoroutineの探し方、ブロックしたときのスレッドの増え方、10ミリ秒のプリエンプションの順に、Go 1.27.3のランタイムソースと手元の測定で確かめます。goroutineの定義やgo文の書き方はゴルーチン(goroutine)とは?仕組み・使い方と同時実行数の制限をGo 1.27で解説で扱っているため、ここではスケジューラの内部に絞ります。
まとめ:GMPモデルとM:Nスケジューリングの要点
M:Nスケジューリングは、M本のgoroutineをN本のOSスレッドに多対多で割り当てる方式です。Goではこれを、G(goroutine)・M(OSスレッド)・P(Goコードの実行権)の3つで実装しています。Pの数がGOMAXPROCSで、同時にGoコードを走らせられる並列度の上限になります。
各Pは256枠のローカル実行キューを持ち、空になると他のPから半分を盗みます。チャネル待ち・ネットワーク待ち・time.SleepはOSスレッドを消費しません。測定では、Sleep・TCP・os.Pipeで待機goroutineを200本作ってもスレッドの増加は0〜2本で、生のsyscall.Readで待たせた場合は5本から203本へ、198本増えました。
関数呼び出しのないループも、Go 1.14以降はシグナルで割り込まれます。Linuxでは、1ミリ秒のつもりのSleepが15〜20ミリ秒で戻りました(10ミリ秒のタイムスライスとsysmonの周期の合計)。今回のチャネル往復測定では、通常版はOSスレッド固定版よりmacOSで約25〜75倍、Linux VMで約600〜800倍速く、goroutine 1本の待機コストは約2.7キロバイトです。
M:Nスケジューリングと1:1・N:1モデルの違い
スレッドモデルは、プログラム側の実行単位とOSスレッドの対応のさせ方で3つに分かれます。
| モデル | 対応 | 切り替えの主体 | 弱点 | 例 |
|---|---|---|---|---|
| 1:1 | 実行単位1つにOSスレッド1本 | カーネル | 数万本で生成・メモリが重い | pthread、Javaのプラットフォームスレッド |
| N:1 | 全実行単位を1本に集約 | ユーザ空間 | 1本のブロックで全停止・1コアのみ | 初期のグリーンスレッド |
| M:N | M本をN本に分散 | ランタイム+カーネル | ランタイムが複雑 | goroutine、Javaの仮想スレッド |
N:1の弱点は、1つの実行単位がシステムコールで止まると全員が巻き添えになる点でした(グリーンスレッドとは?OSスレッドとの違いとN:1モデルの制約を実測で解説)。Goは止まったOSスレッドからPを外して別のスレッドへ渡すことでこれを避けています。JDK 21で正式化された仮想スレッドも、JEP 444が「M:N scheduling」と書く同じ系統で、比較するならJava 仮想スレッド(Virtual Thread)とProject Loom入門|作り方・仕組み・Spring Boot設定をJDK 25で実測が参考になります。
GMPモデルの3要素と実行キューの構造
G・M・Pの役割とGの状態
runtime/proc.goの冒頭コメントは、Gを「goroutine」、Mを「worker thread, or machine」、Pを「a resource that is required to execute Go code」と定義しています。Goコードを実行するMは必ずPを1つ握り、システムコール中やブロック中のMはPを持たないことがあります。Pを導入した設計は、同じコメントが参照するGo 1.1のスケジューラ設計文書(go11sched)にさかのぼります。
Gはruntime2.goで定義された状態を行き来します。スケジューラの話で押さえるのは次の4つです。
| 状態 | 意味 | Pとの関係 |
|---|---|---|
| _Grunnable | 実行キューで順番待ち | ローカルまたはグローバルキューにいる |
| _Grunning | Goコードを実行中 | MとPを握っている |
| _Gsyscall | システムコール中 | Pを奪われることがある |
| _Gwaiting | チャネル・ロック・I/O・Sleepで待機 | Pを手放している |
Mの本数はPより多くなりえます。上限はsched.maxmcount = 10000で、超えるとプロセスはクラッシュします。runtime/debug.SetMaxThreadsで変えられますが、そこに達する時点でブロックしたままのgoroutineが溜まっていると見るべきです。
ローカルキュー256枠・runnext・あふれたときの退避
各Pはrunq [256]guintptrという固定長のリングバッファを持ちます。満杯のときに新しいGを積もうとすると、runqputslowがローカルキューの半分(128本)と新しいGの計129本をまとめてグローバルキューへ移します。
キューとは別にrunnextという1本だけの枠があります。チャネル操作などで起こされたGはrunnextに優先配置されることがあります。ただし、その後の起床による上書きや他のPによる取得もあり、必ず次に実行される保証はありません。それでもこの枠があるので、チャネルでやり取りする2本は同じPの上で交互に走りやすくなり、受け渡しが速くなります。
次に走らせるGの探し方:findRunnableの順序とワークスティーリング
手が空いたMはfindRunnableで次のGを探します。Go 1.27.3で通常の実行可能Gを探す主要な順序は次のとおりです。タイマー確認、トレース用G、GCワーカーの選択などは省略しています。
- 61回に1回(
pp.schedtick%61 == 0)は、グローバルキューを先に1本だけ見る - 自分のローカルキュー(runnextが先)
- グローバルキューから最大128本をまとめて取る
- netpoller(ネットワークI/Oの完了待ち)をブロックせずに確認
- 他のPから盗む(
stealWork、最大4巡)
1の「61回に1回」は公平性のための定数で、互いを起こし合う2本がローカルキューを占有し続けてグローバルキューが飢えるのを防ぎます。5で盗む量はrunqgrabのn = n - n/2、つまり相手のキューの半分(端数切り上げ)です。盗みに回る「スピニング」状態のMは、忙しいPの数の半分までに制限されます。GOMAXPROCSが大きいのに並列度が低いプログラムで、空回りにCPUを使い切らないための上限です。
ブロック方式によるOSスレッド数の違い
システムコール中のPの回収とGo 1.26の変更
Gがブロックするシステムコールに入ると、そのMはカーネルの中で止まります。Pを握ったままでは他のGが走れないため、専用スレッドのsysmonがretakeでPを取り上げ、handoffpで別のMへ渡します。ソースのコメントでは、sysmonの1周期(最短20マイクロ秒)を超えてシステムコールに入っているPが回収の対象です。ただし、自分のローカルキューが空で、手の空いたPやスピニング中のMがあるときは、10ミリ秒まで回収を待ちます。
Go 1.26でこの部分の実装が変わりました。Pの状態_Psyscallは使われなくなり、Go 1.27.3のソースでは_Psyscall_unusedとして「a now-defunct state」と書かれています。システムコール中かどうかはGの状態(_Gsyscall)で判定します。同じGo 1.26のリリースノートには「The baseline runtime overhead of cgo calls has been reduced by ~30%」とあります。Pの状態遷移で説明している解説は1.25以前の実装です。
netpollerで待つI/Oとsyscall.Readの実測
ブロックするシステムコールはMが増える代表的な要因です。LockOSThreadによるスレッド固定やcgo呼び出しでも、別のGを実行するために追加のMが必要になることがあります。ネットワークI/Oやパイプの読み取りは、netpoller(Linuxはepoll、macOSはkqueue)に登録してGだけを_Gwaitingで眠らせるので、OSスレッドを消費しません。この差を、待機goroutineを200本作って/sched/threads/total:threadsで測りました(Go 1.27.3。macOSはIntel Core i9-9880H・16論理CPU、Linuxはcolima上のVM・4 vCPU)。
| 200本を何で待たせたか | macOSのスレッド数 | Linuxのスレッド数 |
|---|---|---|
| 開始前 | 5 | 5 |
| time.Sleep | 7 | 5 |
| TCP接続のconn.Read | 6 | 6 |
| os.Pipeのr.Read | 6 | 5 |
| syscall.Pipe+syscall.Read | 203 | 203 |
同じパイプでも、os.Pipeが返す*os.Fileはnetpollerで待つためスレッドが増えず、syscall.Readで生のファイル記述子を読むと1本につきMが1本占有されました。このとき/sched/goroutines/not-in-go:goroutinesは200です。cgoで呼んだCの関数がブロックする場合も同じ扱いになります。スレッド数が上限の10,000本に近づくのは、この種のブロックを大量に抱えたときです。
sysmonと10ミリ秒のプリエンプション
sysmonの周期とforcePreemptNS
sysmonはPを持たずに動く監視スレッドです。sysmonの通常の監視ループは20マイクロ秒から始まり、起床処理のない周期が50回を超えると待機間隔を倍増させ、10ミリ秒を上限とします。ただし、全Pがアイドルの場合などは、さらに長い待機に入ります。周期ごとに、各Pのスケジュール回数(schedtick)がforcePreemptNS = 10 * 1000 * 1000(10ミリ秒)を超えて変わっていないかを確かめ、変わっていなければプリエンプト要求を出します。見ているのは1本のGの実行時間ではなくPのschedtickなので、runnext経由で交互に走る複数のGは1つのタイムスライスを共有します。この10ミリ秒が、Goの実質的なタイムスライスです。
非同期プリエンプションの実測とコルーチンとの違い
コルーチンは、書き手が明示した中断点でしか制御を譲らない協調的な実行単位です(Kotlinの例はKotlin Coroutinesとは?suspendの仕組みと構造化並行性・採用判断)。goroutineも以前は関数呼び出しの入口でしか割り込めませんでした。Go 1.14のリリースノートは「Goroutines are now asynchronously preemptible.」と書いています。これにより、関数呼び出しのないループがスケジューラを止めたりGCを大幅に遅らせたりしなくなりました(windows/arm、darwin/arm、js/wasm、plan9を除く)。
実際に何ミリ秒で割り込まれるかを、GOMAXPROCS=1で関数呼び出しのない無限ループを走らせ、別のgoroutineからtime.Sleep(1ms)が戻るまでの時間で測りました。
package main
import (
"fmt"
"runtime"
"time"
)
var sink uint64
func spin() {
for {
sink++
}
}
func main() {
runtime.GOMAXPROCS(1)
go spin()
runtime.Gosched()
for i := 0; i < 5; i++ {
start := time.Now()
time.Sleep(time.Millisecond)
fmt.Printf("Sleep(1ms) から戻るまで %v\n", time.Since(start).Round(100*time.Microsecond))
}
}
| 環境 | Sleep(1ms)が戻るまで |
|---|---|
| Linux(colima VM・5回) | 15.7〜20.5ms |
| macOS 26.6(5回) | 15.4〜98.8ms |
| Linux・GODEBUG=asyncpreemptoff=1 | 5秒で1行も出ずtimeoutで終了 |
Linuxの15〜20ミリ秒は、10ミリ秒のタイムスライスにsysmonの周期(最長10ミリ秒)を足した範囲に収まります。macOSでは同じコードが最大98.8ミリ秒かかり、ばらつきも大きく出ました。asyncpreemptoff=1で非同期プリエンプションを切ると、ループがPを手放さないためmainは二度と走りません。runtime/extern.goも「This makes some loops non-preemptible for long periods」と注意しています。書き手が譲らなくても割り込める点が、コルーチンとgoroutineの違いです。とはいえ、レイテンシに厳しい処理の横でCPUを回し続けるループを置けば、10ミリ秒単位の遅れは今でも出ます。
goroutineの切り替えコストとメモリの実測
OSスレッドを固定したチャネル往復との比較
「goroutineの切り替えは軽い」を数字で確かめるため、同じチャネル往復を2通りで測りました。普通のgoroutine同士と、両側をruntime.LockOSThread()で別々のOSスレッドに固定した場合です。後者は往復のたびにOSスレッドの起床と切り替えが起きます。測り方の注意点はマイクロベンチマークとは?正しい測り方と落とし穴をJMH・Goの実測で解説にまとめています。
package bench
import (
"runtime"
"sync"
"testing"
)
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 BenchmarkChanPingPongLocked(b *testing.B) {
ping := make(chan struct{})
pong := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
runtime.LockOSThread()
for range ping {
pong <- struct{}{}
}
}()
runtime.LockOSThread()
defer runtime.UnlockOSThread()
b.ResetTimer()
for i := 0; i < b.N; i++ {
ping <- struct{}{}
<-pong
}
b.StopTimer()
close(ping)
wg.Wait()
}
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()
}
}
go test -bench . -benchtime=500000x -count=5の結果です(Go 1.27.3)。往復1回には切り替えが2回含まれます。macOSの機は測定中も他のプロセスが動いており、通常のチャネル往復は最大値が最小値の約1.34倍、OSスレッド固定版は約2.36倍でした。
| 測定対象 | macOS(ns/op) | Linux VM(ns/op) |
|---|---|---|
| チャネル往復 | 885〜1,183 | 412〜465 |
| チャネル往復(OSスレッド固定) | 27,850〜65,610 | 281,442〜338,982 |
| 空のgoroutineを起動して完了 | 370〜524 | 257〜356 |
| runtime.Gosched()1回 | 272〜303 | 167〜183 |
同じ処理でも、OSスレッドをまたぐだけでmacOSでは約25〜75倍、Linux VMでは約600〜800倍遅くなりました(OSスレッド固定のLinux VMの値は3回分)。今回のLinux VMでは差が大きく出ましたが、測定だけでは原因を仮想化に特定できません。絶対値はOS・CPU・仮想化環境・負荷条件によって変わります。それでも、goroutine同士の受け渡しはユーザ空間で済み、OSスレッド間の受け渡しはカーネルの起床処理を必ず通る、という構造の差は変わりません。
待機goroutine 1本あたりのメモリ
チャネル受信で待たせたgoroutineを10万本作り、runtime.ReadMemStatsの差分を取りました。
goroutines=100001 heap_delta=63229304 B (632 B/goroutine) stack_inuse_delta=205094912 B (2051 B/goroutine)
スタックは1本あたり約2,048バイトで、stackMin = 2048と一致します。ヒープ側のg構造体などが625〜632バイトで、合計は約2.7キロバイト、10万本で約270メガバイトです。ただし初期スタックは常に2キロバイトとは限りません。GODEBUGのadaptivestackstart(既定1)により、ランタイムはGCのたびに平均のスタック使用量から初期サイズを調整します。今回の値は起動直後に測ったものです。
コンテナのGOMAXPROCS:Go 1.25以降の既定とgo.modのgo行
Go 1.24以前のGOMAXPROCSは、起動時の論理CPU数を既定にしていました。64コアのノードでCPU limitが1のPodでもPを64個作り、cgroupの帯域を使い切ってスロットリングを受けます。Go 1.25のリリースノートによれば、Linuxでは論理CPU数・CPU affinity・cgroupのCPU帯域制限を基に既定値を決めます。帯域制限の端数は切り上げ、論理CPU数とaffinityがともに2以上なら既定値は2未満になりません。自動更新が有効なら、全OSで定期的に再評価します。考慮するのはCPU limitで、CPU requestsは見ません。
Go 1.25以降のツールチェーンでは、この既定動作はgo.modのgo行に応じて選ばれます。ただし、GODEBUGなどによる明示設定で変更できます。同じGo 1.27.3でビルドしたバイナリをdocker run --cpus=2(VMは4 vCPU)で動かした結果です。
| ビルド条件 | NumCPU | GOMAXPROCS |
|---|---|---|
| go.modが go 1.27 | 4 | 2 |
| go.modが go 1.24 | 4 | 4 |
| go 1.27+GODEBUG=containermaxprocs=0 | 4 | 4 |
go 1.24・--cpuset-cpus=0-1 |
2 | 2 |
新しいツールチェーンに上げても、go.modのgo行が1.24のままなら帯域制限は反映されません。--cpuset-cpusで使えるCPU自体を絞った場合は、go行に関係なくNumCPUが下がります。環境変数GOMAXPROCSを固定している運用では自動調整が丸ごと無効になる点も、移行時に確認してください。1.25の変更全体はGo 1.25の新機能と変更点|サポート終了後の脆弱性と1.24から上げると変わる箇所を実測、現行版はGo 1.27の新機能・変更点まとめ|Goの最新バージョンは1.27、確認・更新方法と移行の注意点にあります。
スケジューラの観測:schedtraceとruntime/metrics
GODEBUG=schedtraceの読み方
GODEBUG=schedtrace=1000で、1,000ミリ秒ごとにスケジューラの状態が標準エラーへ出ます。go runではgoコマンド自身の行が混ざるため、ビルドしたバイナリで実行します。syscall.Readで待つgoroutineを50本作ったときの出力です。
SCHED 1021ms: 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=[ 5 2 3 3 2 2 2 3 3 4 3 3 3 6 5 7 ]
runqueueがグローバルキューの長さ、1つ目の角括弧が各Pのローカルキューの長さ、schedticksが各Pのスケジュール回数です。gomaxprocs=16に対してthreads=53で、ブロックした50本の分だけMが増えています。ローカルキューが積み上がり続けるならPが足りず、idleprocsが多いのに進まないならgoroutineがどこかで待っています。
/sched系メトリクスの値と監視時の解釈
Go 1.26でスケジューラのメトリクスが増えました。同じプログラムでmetrics.Readした値です。
/sched/gomaxprocs:threads 16
/sched/goroutines-created:goroutines 56
/sched/goroutines/not-in-go:goroutines 50
/sched/goroutines/runnable:goroutines 0
/sched/goroutines/running:goroutines 1
/sched/goroutines/waiting:goroutines 5
/sched/goroutines:goroutines 56
/sched/threads/total:threads 53
schedtraceのthreads=53と/sched/threads/total:threadsが一致し、システムコールで止まった50本はnot-in-goに数えられています。Go 1.26のリリースノートはスレッド数を/sched/threads:threadsと表記していますが、Go 1.27.3が公開している名前は/sched/threads/total:threadsです。監視に載せる前にmetrics.All()で名前を確かめてください。runnableの増加は実行待ちの増加、not-in-goの増加はシステムコールまたはcgo内で実行中・待機中のGの増加を示します。どちらも数値で取れるのでschedtraceより監視に載せやすい一方、CPU不足かブロックかの判断はCPU使用率やプロファイルと併せて行います。Go 1.27では、到達不能になったチャネルなどで止まったgoroutineを報告するgoroutineleakプロファイルも正式版になりました。
goroutineプールは要るか:再利用より同時実行数の上限
goroutineを使い回すためのプールは、ほとんどの場合に不要です。上の測定では、大量の空のgoroutineを起動して全件の完了を待つ総時間を起動数で割ると、1本あたり約0.3〜0.5マイクロ秒でした。これは償却コストであり、1本の起動から完了までの遅延ではありません。プールが持ち込む状態管理(変数の残留、パニックしたワーカーの復旧、停止処理)のほうが高くつきます。
必要なのは再利用ではなく同時実行数の上限です。外部APIやデータベースのように同時接続に限りがある相手へ無制限にgoroutineを投げると、相手を落とします。バッファ付きチャネル・errgroup.SetLimit・ワーカープールの3方式の書き方と実測は、4632のゴルーチン(goroutine)とは?仕組み・使い方と同時実行数の制限をGo 1.27で解説で比べています。常駐ワーカーは、ワーカーごとの高価な資源を再利用する場合や、固定数のワーカーと有界キューで負荷を制御したい場合に選びます。それ以外は必要な数だけ起動し、Goのcontext入門|context.Background()とTODOの違い、WithCancel・WithTimeoutの実装【Go 1.27】で扱うキャンセル経路を渡すほうが、リークを追いやすくなります。高負荷なGoのバックエンドを設計から任せたい場合は、一創のフルスクラッチ開発でも相談を受けています。
よくある質問
GoはなぜM:Nスケジューリングを採っているのですか?
1:1モデルでは実行単位1つにOSスレッド1本が要り、数万本の同時実行がメモリと生成コストの面で現実的ではないためです。N:1モデルでは1本のブロックが全体を止め、マルチコアも使えません。M:Nなら、待ちに入ったgoroutineの裏でOSスレッドを別のgoroutineに回せます。
GMPモデルのPとは何ですか?
Goコードを実行する権利で、ローカル実行キューを持つ単位です。Mは、Pを1つ握っているときだけGoコードを実行できます。Pの数はGOMAXPROCSと同じで、同時にGoコードが走るOSスレッドの上限になります。システムコールでMが止まると、Pは別のMへ渡されます。
goroutineの切り替えはOSスレッドより速いのですか?
速いです。本記事の測定(Go 1.27.3)では、同じチャネル往復がOSスレッドをまたぐとmacOSで約25〜75倍、Linux VMで約600〜800倍遅くなりました。絶対値はOS・CPU・仮想化の有無で大きく変わります。
コルーチンとgoroutineは何が違いますか?
コルーチンは書き手が明示した中断点でしか譲りません。goroutineはGo 1.14以降、関数呼び出しのないループでもシグナルで割り込まれます。マルチコアでの並列実行はgoroutineだけの特徴ではありません。KotlinのコルーチンもDispatchers.Defaultなどを使えば複数スレッドで並列に動作します。
OSスレッド(M)は何本まで作られますか?
既定の上限は10,000本です(sched.maxmcount)。チャネル・ネットワークI/O・Sleepで待つgoroutineはスレッドを消費しないので、上限に近づくのはブロックするシステムコールやcgo呼び出しが滞留したときです。runtime/debug.SetMaxThreadsで変えられますが、先に滞留の原因を調べてください。