Goランタイム進化史と実務で効く制約

スケジューラ・GC・スタックをバージョン軸で俯瞰する


Posted on 2026年 8月 2日 (日)
Tags golang, runtime, gc, scheduler, performance, cowork-with-llm
golang, runtime, gc, scheduler, performance, cowork-with-llm

Goランタイム進化史と実務で効く制約

Goは言語仕様が小さい代わりにランタイムが厚い言語だ。goroutine が軽いのも、GCの停止時間が短いのも、コンパイラではなくランタイムの実装が支えている。

そしてランタイムはGoのバージョンごとに大きく変わってきた。Go 1.0 のGCと Go 1.26 のGCは別物であり、当時の常識を引きずったまま書くと、いまのランタイムでは無意味な最適化をしたり、逆に踏まなくてよい落とし穴を踏んだりする。

この記事では、コードがバイナリになってOS上で動き出すまでを追ったうえで、Goランタイムの構成要素を整理し、バージョンごとの変更を時系列で並べる。さらにランタイムがCPU・メモリ・ストレージ・NICをどう使っているかまで降りて、「だからコードを書くときに何へ気をつけるのか」という制約13個に落とす。図はすべてブラウザ上で動かせるので、手を動かしながら読んでほしい。

まとめ

先に要点だけ挙げる。

  • Goランタイムの中核は スケジューラ(G-P-M)GCメモリアロケータスタック管理netpoller/タイマー の5つ
  • ランタイムはバイナリへ静的に同梱され、main.main に到達した時点でスケジューラとGCはすでに動いている
  • ランタイムの設計思想は「システムコールをできるだけ避け、避けられない場面では影響を1スレッドに閉じ込める」と「共有をやめて資源ごとにローカル化する」の2つに集約できる
  • 進化の方向は一貫して「停止時間の削減」から「予測可能性の確保」、そして「コンテナ環境への適合」へと動いてきた
  • 実務で効く分岐点は Go 1.5(並行GC)、Go 1.8(ハイブリッドライトバリア)、Go 1.14(非同期プリエンプション)、Go 1.19(GOMEMLIMIT)、Go 1.25 と 1.26(cgroup対応とGreen Tea GC)
  • 現在のGoで注意すべき制約は、GC停止時間ではなく 割り当て量・ライブヒープ・goroutineのライフサイクル に移っている
  • 検証環境:Go 1.26.5(linux/amd64)。バージョン依存の記述はその都度明示する

1. Goランタイムとは何を指すか

Goの実行ファイルには、ユーザーが書いたコードに加えて runtime パッケージの実装が静的にリンクされる。これが一般に言うGoランタイムで、JVMのような別プロセスの仮想マシンではない。担当範囲は次のとおり。

構成要素責務主に影響する症状
スケジューラ(G-P-M)goroutine をOSスレッドへ割り当てるレイテンシのばらつき、CPUを使い切れない
ガベージコレクタ到達不能なオブジェクトの回収CPU使用率、メモリ使用量、テールレイテンシ
メモリアロケータサイズクラス単位のヒープ確保割り当て速度、断片化、RSS
スタック管理goroutine ごとの可変長スタックgoroutine 生成コスト、stack overflow
netpoller とタイマーI/O多重化と時間イベント接続数のスケール、タイマー精度
実行トレースとメトリクス内部状態の可視化障害解析のしやすさ

Goを書く側から見ると、これらは勝手にやってくれるものだが、勝手にやってくれる範囲には仕様と限界がある。その限界がバージョンとともにどう動いたかを追うのがこの記事の主題だ。

2. コンパイルから実行まで

ランタイムがいつバイナリへ入り、どの順序で動き始めるかを先に押さえておくと、以降の話が繋がりやすい。段階を選ぶと、その工程でランタイムが何をしているかと、手元で確認するコマンドが出る。

この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。

要点は3つある。

1. コンパイラとランタイムは対で設計されている

go f()m[k] = v のような構文は、対応するランタイム関数の呼び出しへ変換される。GCのライトバリアやスタック拡張の検査も、コンパイラが各関数へ埋め込む。実際に確認できる。

1
$ go tool objdump -s "main\.spawn$" ./app | grep -oE "runtime\.[a-zA-Z0-9_]+" | sort -u
1
2
runtime.morestack_noctxt
runtime.newproc

runtime.newprocgo f() の実体で、runtime.morestack_noctxtすべての関数の先頭に入るスタック拡張の検査だ。1行も書いていないのに呼び出しが現れる。

2. ランタイムはバイナリへ静的に同梱される

Goの実行ファイルは、ユーザーのコード、標準ライブラリ、ランタイムを1つにまとめた単一のELFになる。別途インストールする処理系が要らず、コンテナイメージを FROM scratch で組めるのはこのためだ。

同時に、これはバイナリが大きくなる理由でもある。上の図の「実行ファイル」でセクション構成を見ると、.gopclntab.text より大きいことが分かる。スタックトレース、パニック時の行番号、GCのスタックスキャンはすべてこのテーブルを引く。

3. main.main に着く時点でランタイムはもう動いている

エントリポイントが main.main ではない点に注意したい。_rt0_amd64_linux から runtime.rt0_goruntime.schedinitruntime.main と進み、パッケージの init() を実行してから、ようやく main.main が呼ばれる。この間に GOMAXPROCS の決定、ヒープアリーナの予約、sysmon スレッドの起動が終わっている。

1
$ GODEBUG=inittrace=1 ./app 2>&1 | head -3
1
2
3
init internal/bytealg @0 ms, 0 ms clock, 0 bytes, 0 allocs
init internal/runtime/gc/scan @0.009 ms, 0 ms clock, 0 bytes, 0 allocs
init runtime @0.014 ms, 0.10 ms clock, 0 bytes, 0 allocs

3. 進化の全体像

まずは年表で全体を眺める。バージョンをクリックすると、そのリリースで入った変更と書く側への影響が表示される。カテゴリで絞り込むと、スケジューラだけ、GCだけ、といった追い方もできる。

この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。

大きな流れは5つの時期に分けられる。

  1. Cランタイム期(1.0〜1.3):ランタイム中核はC言語。全停止GCとセグメントスタック
  2. Go化と正確GC期(1.4〜1.5):ランタイムをGoへ書き換え、並行GCと GOMAXPROCS の自動設定
  3. 低レイテンシ期(1.6〜1.13):ハイブリッドライトバリアでGC停止をマイクロ秒へ
  4. 予測可能性期(1.14〜1.21):非同期プリエンプション、GOMEMLIMIT、PGO
  5. コンテナ最適化期(1.22〜1.27):Swiss Tables、cgroup対応、Green Tea GC

以降、構成要素ごとに掘り下げる。

4. スケジューラ(G-P-M)

4.1 モデルの構造

Goのスケジューラは3つの実体で構成される。

  • G(goroutine):実行単位。スタックとプログラムカウンタを持つ
  • M(machine):OSスレッド。実際にCPUで走るもの
  • P(processor):Goコードを実行する権利。GOMAXPROCS の数だけ存在し、ローカル実行キューを持つ

GがCPUで走るには、MがPを保持している必要がある。これがGoの並行性を理解する要になる。Pの数が同時にGoコードを走らせられる本数の上限であり、Mの数はそれとは独立に増える。syscall でブロックした分だけMは追加される。

下の図で動かして確認できる。go f() で goroutine を積み、ローカルキューが空になったPが他のPから半分を奪う work stealing、ブロッキング syscall でMが詰まったときのP切り離し(handoff)、for{} に対する非同期プリエンプションが、それぞれ起きた瞬間にログへ理由が出る。

この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。

図の中で再現している実際のランタイム挙動は次のとおり。

  • ローカルキューは256本まで。あふれると半分がグローバルキューへ移る(図では16本に縮めている)
  • 61回に1回はグローバルキューを先に見る。ローカルキューだけを見続けるとグローバルキューの goroutine が餓死するため
  • work stealing では他のPのローカルキューから半分を奪う
  • syscall でブロックしたMからはPが切り離される。1本の goroutine がファイルI/Oで止まっても他は動く
  • ネットワークI/Oは netpoller が担当するので、そもそもMをブロックしない

4.2 スケジューラの変遷

バージョン変更影響
1.0GOMAXPROCS の既定は1明示指定しないと並列実行されなかった
1.1G-P-Mモデルと work stealing を導入グローバルロック競合が解消。現在の原型
1.2関数呼び出し時に協調的プリエンプションのチェック関数呼び出しのないループは依然として剥がせない
1.5GOMAXPROCS の既定を論理CPU数へ何もしなくてもマルチコアを使うようになった
1.14非同期プリエンプション(SIGURG)、内部タイマー刷新ループがGCを止める問題が解消
1.23TimerTicker の刷新参照されなくなったタイマーが即回収される
1.24ランタイム内部ミューテックスの新実装高競合時の挙動が改善
1.25cgroup のCPU制限を認識する GOMAXPROCSコンテナで論理CPU数を誤認しなくなった
1.26cgo呼び出しのオーバーヘッドを約30%削減cgoを多用するコードの下限が下がった

4.3 書く側の制約

制約1:goroutine は自動では止まらないし、自動では終わらない

非同期プリエンプションでCPUを譲らない問題は解決したが、終わらない goroutine は別の話だ。チャネル受信で永久にブロックした goroutine はメモリを保持し続ける。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 悪い例:ctx が終了してもこの goroutine は返らない
go func() {
    for v := range ch { // ch が閉じられないと永久に生き続ける
        process(v)
    }
}()

// 良い例:終了経路を必ず用意する
go func() {
    for {
        select {
        case <-ctx.Done():
            return
        case v, ok := <-ch:
            if !ok {
                return
            }
            process(v)
        }
    }
}()

Go 1.27 では、到達不能な同期プリミティブでブロックした goroutine を検出する goroutineleak プロファイルが正式機能化される見込みだ。GCの到達可能性解析を使うため誤検出が出にくい。それまでは goroutine 数の監視で代替する。

1
2
# goroutine 数の推移を見る(現行バージョンでも使える)
curl -s http://localhost:6060/debug/pprof/goroutine?debug=1 | head -20

制約2:GOMAXPROCS はコンテナで誤認される場合がある

Go 1.24 以前は、cgroup でCPU制限をかけていてもホストの論理CPU数を見ていた。32コアのノードで cpu limit: 2 のPodを動かすと GOMAXPROCS=32 になり、Pが過剰に作られてスロットリングとコンテキストスイッチが増える。

1
2
3
# 実際の値を確認する
$ go version
$ GODEBUG=containermaxprocs=0 ./app   # Go 1.25以降で旧挙動へ戻す場合

Go 1.25 以降は cgroup のCPU帯域制限を読んで自動設定し、実行中の変更にも追随する。Go 1.24 以前を使い続けるなら automaxprocs 相当の対策が要る。なお環境変数や runtime.GOMAXPROCS で明示的に設定すると自動追随は無効になるため、明示設定は本当に必要な場合だけにしたい。

制約3:cgo と syscall はスケジューラの外に出る

cgo 呼び出しの間、そのMはGoスケジューラの管理下から外れる。長時間の cgo 呼び出しを大量の goroutine から行うと、Mが増え続けてスレッド数が膨らむ。既定の上限は10000スレッドで、超えると runtime: program exceeds 10000-thread limit で落ちる。

1
2
3
4
// cgo やブロッキング syscall を扱う箇所は同時実行数を絞る
sem := make(chan struct{}, 16)
sem <- struct{}{}
defer func() { <-sem }()

5. ガベージコレクタ

5.1 三色マーキングのアルゴリズム

GoのGCは 並行マーク&スイープ で、三色抽象で説明される。三色とは、ヒープ上のオブジェクトを次の3状態に分類することを指す。

  • :まだ到達していない。マーク終了時点で白なら回収対象
  • :到達したが、そのオブジェクトが持つポインタをまだ辿っていない
  • :到達し、辿り終えた。生存確定

アルゴリズムそのものは、灰の集合(ワークリスト)が空になるまで次を繰り返すだけだ。

  1. ルート(グローバル変数と各 goroutine のスタック)を灰にしてワークリストへ積む
  2. ワークリストから1つ取り出し、その参照先のうち白のものを灰にしてワークリストへ積む
  3. 取り出したオブジェクト自身を黒にする
  4. ワークリストが空になったら、白のまま残ったものを回収する

ここで重要なのは、GoのGCが判定しているのが「参照されているか」ではなく 「ルートから到達できるか」 である点だ。互いを参照し合うだけのオブジェクト群は参照カウントでは回収できないが、到達可能性で判定すればまとめて回収できる。

下の図はこの探索そのものを可視化したものだ。「1ステップ進める」を押すと上の手順2と3が1回進み、ワークリストの中身が変化する。右側の破線で囲った部分が循環参照のゴミで、探索が終わっても白のまま残る。

この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。

5.2 なぜライトバリアが必要か

問題は、マークがアプリケーションと 並行して 動く点にある。走査済みの黒オブジェクトが未到達の白オブジェクトを指すよう書き換えられると、黒はもう再走査されないため、生きている白が誰からも辿られずに回収されてしまう。

この状況が成立する条件は次の2つで、片方でも崩せば安全になる。

  1. 黒から白への参照が作られる
  2. その白へ至る灰からの経路が失われる

これを防ぐのが ライトバリア、つまりポインタ書き込みのたびにランタイムが挟む小さなフックだ。条件1を潰すのがDijkstra方式(書き込み先を灰にする)、条件2を潰すのがYuasa方式(上書きされる古い参照先を灰にする)で、Go 1.8 以降はこの両方を組み合わせた ハイブリッドライトバリア を使う。

上の図で「マーク中にポインタを書き換える」を押すと、黒から白へ赤い矢印が引かれ、その白が灰へ引き上げられる様子を確認できる。「Go 1.4以前(全停止GC)と比較」に切り替えると、全停止方式ではそもそも並行に書き換えられないためライトバリアも要らなかったこと、その代わりに停止時間が長かったことを確認できる。

ハイブリッドライトバリアの実務的な意味は、マーク終了時に全 goroutine のスタックを再スキャンする必要がなくなった ことにある。Go 1.7 以前は goroutine の数とスタックの深さに比例して停止時間が伸びたが、Go 1.8 以降は goroutine が何十万本あっても停止時間はほぼ一定になった。

実測すると、確かにマーク終了の停止は極めて短い。

1
$ GODEBUG=gctrace=1 go run .
1
gc 12 @0.168s 4%: 0.071+1.0+0.091 ms clock, 0.28+0.042/0.67/1.1+0.36 ms cpu, 4->5->1 MB, 5 MB goal, 0 MB stacks, 0 MB globals, 4 P

読み方は次のとおり。

  • 0.071+1.0+0.091 ms clockマーク開始のSTW、並行マーク、マーク終了のSTW。止まるのは両端の0.07msと0.09msだけ
  • 4->5->1 MB:GC開始時のヒープ、終了時のヒープ、生存(ライブ)ヒープ
  • 5 MB goal:次のGCを始める目標ヒープサイズ
  • 4%:プログラム開始からの累積で、GCがCPU時間の4%を使っている

5.3 GOGC と GOMEMLIMIT

GCがいつ走るかは GOGC が決める。既定値は100で、意味は「前回のGC後に生き残った量の100%分だけ増えたら次のGCを始める」だ。

1
次のGCの目標ヒープ = ライブヒープ × (1 + GOGC/100)

ライブヒープが100MiBなら、既定では200MiBまで増えた時点で次のGCが始まる。GOGC を上げるとGC回数は減るがメモリは増え、下げると逆になる。

問題は、この式が ライブヒープに対する比率 でしか上限を決めない点だ。ライブヒープが想定外に増えると目標ヒープも比例して増え、コンテナのメモリ制限を突き抜けてOOM Killされる。これを解決したのが Go 1.19 の GOMEMLIMIT で、ヒープ以外のスタックやランタイム内部構造も含めた総メモリに上限を与える。

この節の図の「GOGC と GOMEMLIMIT」タブでスライダを動かすと、両者がどう目標ヒープを決めるかが見える。「ライブヒープを増やし続ける」を押すと、制限に張り付いてGCが回り続けるデススパイラルの状態も再現できる。

実務での定番設定は次の2つ。

1
2
3
4
5
# パターンA:GOGC を既定のまま、上限だけ与える(多くのサーバ向け)
GOGC=100 GOMEMLIMIT=1800MiB ./app

# パターンB:GCを事実上止め、上限に近づいたときだけ回す(CPU優先のバッチ向け)
GOGC=off GOMEMLIMIT=3GiB ./app

コンテナのメモリ制限に対しては、制限そのものではなく 少し余裕を持たせた値 を設定する。Goのヒープ以外にも、実行ファイル自体、cgo のメモリ、OSページキャッシュが乗るためだ。

1
2
3
4
5
6
7
8
// コンテナ制限の 90% 程度を目安にする例
import "runtime/debug"

func init() {
    if limit := readCgroupMemoryLimit(); limit > 0 {
        debug.SetMemoryLimit(int64(float64(limit) * 0.9))
    }
}

5.4 Green Tea GC(Go 1.25 実験、Go 1.26 既定)

Go 1.26 で既定になった Green Tea GC は、小さなオブジェクトのマーク処理をオブジェクト単位ではなくメモリのまとまり(スパン)単位で処理する 方式だ。従来はポインタを辿るたびにキャッシュミスが起きやすかったが、同じスパン内のオブジェクトをまとめて走査すると局所性が改善する。

公式の案内ではGC負荷の高いアプリケーションでGCオーバーヘッドが10〜40%削減されるとされ、新しめのamd64(Intel Ice Lake、AMD Zen 4以降)ではベクタ命令によるスキャンでさらに約10%上乗せされる。

移行時の注意点は次のとおり。

  • GOEXPERIMENT=nogreenteagc で旧GCへ戻せるが、この退避策は Go 1.27 で削除される予定とされている
  • 効果はワークロード依存が大きい。ポインタの多い小さなオブジェクトを大量に扱うほど効く
  • 判断は /gc/cycles/total:gc-cycles/cpu/classes/gc/total:cpu-seconds を新旧で比較する
1
2
3
4
5
6
// runtime/metrics でGCのCPU時間を取る
samples := []metrics.Sample{
    {Name: "/cpu/classes/gc/total:cpu-seconds"},
    {Name: "/gc/heap/live:bytes"},
}
metrics.Read(samples)

5.5 書く側の制約

制約4:GCのコストは停止時間ではなく割り当て量で決まる

Go 1.8 以降、停止時間はマイクロ秒オーダーで、ほとんどの用途では問題にならない。いま効いてくるのはGCが使うCPU時間で、これは 割り当て回数とライブヒープのサイズ にほぼ比例する。

つまり最適化の対象はGCのチューニングより、割り当てを減らすことになる。

1
2
3
# 割り当ての多い箇所を特定する
$ go test -bench=. -benchmem ./...
$ go tool pprof -alloc_objects http://localhost:6060/debug/pprof/allocs

制約5:runtime.SetFinalizer に後処理を頼らない

ファイナライザは実行タイミングが保証されず、循環参照があると呼ばれない。Go 1.24 で追加された runtime.AddCleanup のほうが制約は少ないが、いずれにせよ Close() を明示的に呼ぶ設計 が正解だ。

制約6:sync.Pool はGCで空になる前提で使う

sync.Pool の中身はGCのたびに捨てられる。Go 1.13 で victim cache が入り、1サイクル分は猶予されるようになったが、長期保存はできない。プールへ入れるのは再利用したいバッファであって、キャッシュではない

6. スタックとエスケープ解析

6.1 goroutine が軽い理由

goroutine の初期スタックは 2KiB(Go 1.4 以降)。OSスレッドの既定スタックが数MiBであることを考えると3桁小さい。これが goroutine を10万本立てても大丈夫という話の根拠になっている。

足りなくなったらどうするか。Go 1.3 以降は 連続スタック方式 で、2倍のサイズの領域を新しく確保し、既存のフレームを丸ごとコピーする。スタック上のポインタも書き換える。この操作を morestack と呼ぶ。

下の図の左側で「関数を呼ぶ」を何度か押すと、容量を超えた時点でコピーが発生する様子を確認できる。「GCでスタック縮小」を押すと、逆にGCがスタックを縮める動きも確認できる。

この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。

Go 1.19 以降は初期スタックサイズを過去の実績平均から決めるようになったため、深いスタックを常用するワークロードではコピー回数が減っている。

6.2 エスケープ解析

変数がスタックに載るかヒープへ移るかは、コンパイラのエスケープ解析が決める。判定は go build -gcflags=-m で確認できる。

1
$ go build -gcflags=-m . 2>&1 | grep -E "escapes|moved to heap|does not escape"
1
2
3
4
5
6
./main.go:9:26: moved to heap: p
./main.go:11:33: n escapes to heap
./main.go:13:32: make([]byte, 64) does not escape
./main.go:15:35: make([]byte, n) escapes to heap
./main.go:18:2: moved to heap: n
./main.go:19:9: func literal escapes to heap

上の図の右側で、代表的なケースを切り替えて確認できる。判定の傾向は次のとおり。

ケース結果理由
ローカル値を返すスタック関数の外へ漏れない
ポインタを返すヒープアドレスが関数の外へ出る
interface{} へ変換ヒープになる場合がある動的型の情報とともに保持する必要がある
make([]byte, 64)スタックサイズがコンパイル時に確定
make([]byte, n)ヒープサイズが実行時に決まる
クロージャで捕捉ヒープクロージャが変数を持ち越す

6.3 書く側の制約

制約7:ポインタを返すコンストラクタは割り当てを生む

func New() *T は必ずヒープ割り当てになる。ホットパスで大量に作るなら、値を返す形(func New() T)や、呼び出し側でスライスをまとめて確保する形を検討する。ただし これは計測してから行う最適化 であり、先に可読性を落とす理由はない。

制約8:サイズが実行時に決まるスライスは逃げる

上限が分かっている場合、固定長配列を取ってスライスすると割り当てを避けられる場合がある。

1
2
3
4
5
6
// 逃げる
buf := make([]byte, n)

// 上限が分かっているなら逃げない場合がある
var arr [256]byte
buf := arr[:n]

制約9:スタックには上限がある

1本の goroutine のスタック上限は64bit環境で既定1GiBだ。深い再帰でこれを超えると goroutine stack exceeds 1000000000-byte limit とともにプロセスが落ちる。debug.SetMaxStack で変更できるが、まず再帰を反復へ書き換えるほうが正しい。

7. メモリアロケータとその他

7.1 アロケータの構造

Goのアロケータは TCMalloc 系の設計で、サイズクラス を持つ。

  • mcache:Pごとのキャッシュ。ロックなしで確保できる
  • mcentral:サイズクラスごとの共有プール
  • mheap:OSから取得したページの管理

小さなオブジェクト(32KiB未満)はサイズクラスに丸められる。17バイトの構造体は24バイト分を消費する といった内部断片化がここで生じる。構造体のフィールド順を整えてパディングを減らすと効く場合があるのはこのためだ。

1
2
# 構造体のアラインメントを確認する
$ go run honnef.co/go/tools/cmd/staticcheck@latest ./...

Go 1.26 ではサイズ特化した割り当てのファストパスが入り、512バイト未満の小さな割り当てが最大30%高速化した。小さなオブジェクトを大量に作るコードほど、バージョンを上げる効果が出やすい。

7.2 map の実装変更(Go 1.24)

Go 1.24 で組み込み map が Swiss Tables ベースの実装に置き換わった。大きなマップでの探索と挿入が速くなり、メモリ効率も改善している。

注意点は 反復順序 だ。Goの map は元から順序が未定義でランダム化されているが、実装が変わったことで実際の並びも変わる。テストが偶然の順序に依存していると、この変更で壊れる。

7.3 タイマーと netpoller

Go 1.14 で内部タイマーがPごとの管理へ移り、ロック競合とコンテキストスイッチが減った。Go 1.23 ではさらに TimerTicker が刷新され、参照されなくなったタイマーは Stop() を呼ばなくても即座に回収される

1
2
3
// Go 1.23 以降は Stop 忘れでもリークしにくい。ただし明示は依然として推奨
t := time.NewTimer(time.Second)
defer t.Stop()

ただしタイマーのチャネルがバッファなしに変わったため、古い挙動に依存したコードは GODEBUG=asynctimerchan=1 が必要な場合がある。この挙動差は go.mod の go ディレクティブで制御される。

7.4 メモリのOS返却

メモリを解放したのにRSSが下がらない、という問い合わせは定番だ。原因はバージョンによって異なる。

バージョン方式症状
1.11以前MADV_DONTNEED返却が遅い
1.12〜1.15MADV_FREEカーネルが回収するまでRSSが下がらない
1.16以降MADV_DONTNEEDRSSが実態を反映する

Go 1.16 以降なら通常は問題にならないが、それでも即座には返らない。強制的に返したい場合は debug.FreeOSMemory() があるが、STWを伴うため常用は避ける。

8. ランタイムとOS・ハードウェアの境界

ここまでランタイム内部を見てきたが、ランタイムは最終的にOSの上で動く。Goランタイムの設計思想は「システムコールをできるだけ避け、避けられない場面ではその影響を1スレッドに閉じ込める」 と要約できる。

8.1 どの操作がどこまで落ちるか

Goのコードを1行書いたとき、ランタイムのどこを通り、カーネルのどの機能へ落ちるかを図にした。操作を選ぶと経路が光る。灰色で止まる経路は、システムコールに到達せずユーザ空間で完結することを意味する。

この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。

対応関係を表にすると次のようになる。プラットフォームによって使う機能は変わるが、役割は同じだ。

ランタイムの機能LinuxmacOS / BSDWindows
OSスレッドの生成clone(2)pthread_createCreateThread
スレッドの待機と起床futex(2)pthread_cond / __ulockイベントオブジェクト
I/O多重化(netpoller)epoll(7)kqueue(2)IOCP
仮想メモリの確保と返却mmap / madvisemmap / madviseVirtualAlloc
プリエンプションtgkill + SIGURGpthread_kill + SIGURGSuspendThread
時刻取得vDSO clock_gettimecommpageQueryPerformanceCounter

8.2 うまく使っている点

1. ブロックの単位をスレッドからgoroutineへずらした

チャネル待ちやミューテックス待ちは gopark でGを実行キューから外すだけで、Mはそのまま別のGを実行する。カーネルから見ると、スレッドはずっと走り続けているだけで「待っている」という状態が存在しない。待機のコストがコンテキストスイッチではなく関数呼び出し数回分に収まっている。

2. 待ち合わせ点を1か所に集約している

netpoller の epoll_wait は、ネットワークI/Oの待ちとタイマーの待ちを同時に扱う。次に発火するタイマーまでの時間を epoll_wait のタイムアウトへ渡すことで、タイマー専用スレッドや timerfd を増やさずに済ませている。

3. カーネルからは大きく借り、細かく配る

mmap で確保するのは64MiB単位のアリーナで、実際の割り当ては P ごとの mcache から切り出す。割り当て回数とシステムコール回数が切り離されているため、秒間数百万回の割り当てでもカーネルには入らない。

4. カーネルが用意した「syscallを避ける道」を使う

time.Now() は vDSO 経由の clock_gettime を呼ぶ。vDSO はカーネルが各プロセスのアドレス空間へマップする共有コード領域で、カーネルモードへ切り替えずに時刻を読める。sync.Mutex が非競合時にアトミック命令だけで完結し、競合時にだけ futex へ落ちるのも同じ発想だ。

5. 避けられないブロックは1スレッドに閉じ込める

通常のファイルI/Oは epoll で待てないため、実際に read(2) でスレッドごとブロックする。そこでランタイムは entersyscallP を M から切り離してから syscall に入る。sysmon が切り離されたPを別のMへ渡すので、止まるのはスレッド1本だけで済む。

8.3 ハードウェアとの連携

OSのさらに下、物理的な資源まで降りると、ランタイムの設計判断の理由がもう一段はっきりする。goroutine からCPUコア、RAM、ストレージ、NICまでが縦につながっている。ハードウェアを選ぶと、その資源へ至る経路とランタイムの使い方が表示される。

この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。

4本の柱を一言でまとめると次のようになる。

資源ランタイム側の担当中心にある工夫
CPUスケジューラ(G-P-M)Pごとに実行キューとキャッシュを分け、コア間で共有する状態を減らす
メモリGCとアロケータ仮想アドレスは大きくまとめて予約し、物理ページは触ったときに割り当てる
ストレージファイルI/O静的リンクとmmapで、起動時に読むページを最小化する
ネットワークnetpoller非ブロッキングfdとepollで、接続数とスレッド数を切り離す

共通しているのは、共有をやめて資源ごとにローカル化するという方針だ。Pごとの実行キュー、Pごとの mcache、Pごとのタイマーヒープはすべて同じ発想で、コア間の同期とキャッシュラインの奪い合いを避けるためにある。Go 1.26 で既定になった Green Tea GC も、走査の順序をメモリの並びに合わせてキャッシュ局所性を上げる変更だった。

8.4 書く側の制約

制約10:OSスレッドを固定する操作はスケジューラと衝突する

runtime.LockOSThread は現在のGを特定のMへ固定する。OpenGLコンテキストや setns のようにスレッド単位の状態を扱う場合に必要だが、そのMは他のGを実行できなくなる。固定したまま goroutine が終了すると、そのスレッドは破棄される。

1
2
3
4
5
func mustRunOnOneThread() {
    runtime.LockOSThread()
    defer runtime.UnlockOSThread()
    // このブロックの間、Gは同じOSスレッドに固定される
}

制約11:シグナルはランタイムと共有している

ランタイムは SIGURG(非同期プリエンプション)、SIGPROF(CPUプロファイル)、SIGSEGV(nilポインタ参照の検出)などを内部的に使う。cgo で読み込んだライブラリがこれらのハンドラを上書きすると、プリエンプションが効かなくなったりクラッシュしたりする場合がある。

1
2
# 非同期プリエンプションを切って切り分ける(恒久対策にはしない)
GODEBUG=asyncpreemptoff=1 ./app

制約12:スレッド数の上限はOS側にもある

Goの既定上限は10000スレッドだが、その手前でcgroupの pids.maxulimit -u に当たる場合がある。ブロッキング syscall や cgo を多用する設計では、同時実行数を明示的に絞るほうが安全だ。

制約13:GOMAXPROCS はCPU時間の上限ではない

Pの数はGoコードを同時に実行できる本数であって、プロセス全体のスレッド数とは別物であり、CPUクォータでもない。syscall でブロックしたMはPを持たないまま存在し続けるため、GOMAXPROCS=2 と設定してもスレッドは数十本になりうる。cgroup のCPUスロットリングを見るときはこの区別が要る。

1
2
# スケジューラの実態を1秒ごとに見る
$ GODEBUG=schedtrace=1000 ./app
1
SCHED 1002ms: gomaxprocs=4 idleprocs=0 threads=5 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=30 [2 1 1 2]

threads=5 が実際のOSスレッド数、runqueue=30 がグローバル実行キュー長、[2 1 1 2] が各Pのローカル実行キュー長になる。runqueue が常に大きいままならPが足りておらず、idleprocs が大きいなら並列度を使い切れていない。

9. 観測方法

ランタイムの状態を見る手段は世代ごとに増えてきた。新規実装では新しいものを優先したい。

手段用途備考
GODEBUG=gctrace=1GCサイクルの生ログ標準エラー出力。本番では常用しない
GODEBUG=schedtrace=1000スケジューラの状態を1秒ごとに出力Pごとのキュー長が見える
runtime/metrics安定APIでの数値取得Go 1.16以降。runtime.MemStats より推奨
net/http/pprofヒープ、CPU、goroutine のプロファイル本番でも取得可能
runtime/trace実行トレースGo 1.23で低オーバーヘッド化
runtime/trace.FlightRecorder直近のトレースを常時保持Go 1.25以降。異常時のみダンプ
testing/synctest時間依存の並行コードのテストGo 1.25で正式機能化
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// runtime/metrics の最小例
import "runtime/metrics"

func snapshot() []metrics.Sample {
    samples := []metrics.Sample{
        {Name: "/sched/goroutines:goroutines"},
        {Name: "/gc/heap/live:bytes"},
        {Name: "/cpu/classes/gc/total:cpu-seconds"},
    }
    metrics.Read(samples)
    return samples // 読み出す際は s.Value.Kind() で型を判別する
}

10. バージョン別チェックリスト

いま自分が使っているGoのバージョンで、何に注意すべきかの早見表。

使用バージョン特に注意する点
1.13以前関数呼び出しのないループがGCを止める。GOMAXPROCS はホストのCPU数
1.14〜1.18メモリ上限の制御手段が GOGC しかない。コンテナでのOOMに注意
1.19〜1.21GOMEMLIMIT を設定する。ループ変数の捕捉バグは依然として存在
1.22〜1.23ループ変数は go.mod の go 行が 1.22 以上で挙動が変わる。タイマー刷新の影響を確認
1.24map の反復順序に依存したテストが壊れる可能性
1.25以降GOMAXPROCS の自動設定と automaxprocs の二重適用に注意
1.26Green Tea GC が既定。GCメトリクスを移行前後で比較する

11. 注意点

  • バージョンを上げるだけで速くなる場合が多い。手元での最適化より、まずGoのバージョンを上げて計測するほうが費用対効果は高い
  • GODEBUG の互換メカニズムに注意する。Go 1.21 以降、挙動が変わる変更は go.mod の go ディレクティブで制御される。ツールチェインを上げても、go 行を上げるまでは古い挙動のままになる場合がある
  • GOEXPERIMENT に依存した設計をしない。アリーナのように実験のまま一般提供されない機能もある
  • ベンチマークはCPU数を固定して取るGOMAXPROCS が自動追随するようになった分、環境差が結果に出やすい
  • 本番のGC設定は必ず計測してから変えるGOGC を上げればCPUは減るがメモリは増える。どちらが制約になるかはワークロード次第

参考

一次情報を中心に挙げる。日付や数値は公式のリリースノートで確認してほしい。

Share


See also