GOMEMLIMITとGOGCの違い—上限を詰めると10倍遅くなるのはなぜ?

Go のランタイムは既定で、前回の GC 後に生き残ったヒープとほぼ同じ量を新たに割り当てた時点で次の GC を始めます。GOGC=100 の意味はこれで、GOMEMLIMIT はそこに総メモリの天井を足します。天井をライブヒープに寄せすぎたところ、手元では実行時間が 1.51秒から15.42秒 に伸びました。

目次

GOGC は次の GC までに割り当ててよい量を決める

目標ヒープの計算式

公式の A Guide to the Go Garbage Collector の “GOGC” セクションに、式がそのまま載っています。

Target heap memory = Live heap + (Live heap + GC roots) * GOGC / 100

GC roots はゴルーチンのスタックとグローバル変数のポインタ。ガイドの例では、ライブヒープ 8MiB、スタック 1MiB、グローバル 1MiB で GOGC=100 なら、次の GC までに 10MiB 割り当ててよく、合計 18MiB が目標になります。同じ節に “doubling GOGC will double heap memory overheads and roughly halve GC CPU cost” とあり、GOGC を倍にすればヒープの上乗せ分が倍、GC の CPU コストはおよそ半分です。

GOGC=50・100・200 を実測する

計測条件は Apple M5 Pro / macOS / Go 1.27.1 / GOMAXPROCS=4。1KiB のオブジェクトを 64MiB 分保持しつつ、合計 16GiB を割り当てるプログラムを 3 回ずつ走らせ、中央値を取りました。負荷の本体はこの部分。

// 1KiB のオブジェクトを 64MiB 分保持したまま、合計 16GiB を割り当てる
const objSize = 1024
live := make([][]byte, *liveMiB*1024)
for i := range live {
	live[i] = make([]byte, objSize)
}

n := *totalMiB * 1024
for i := 0; i < n; i++ {
	b := make([]byte, objSize)
	if i%4 == 0 {
		live[rand.IntN(len(live))] = b // 4回に1回は保持中のものと入れ替える
	} else {
		sink = b // 残りはすぐ不要になる
	}
}

GC 回数と CPU 時間は runtime/metrics の /gc/cycles/total:gc-cycles と /cpu/classes/* から取りました。ピークは /memory/classes/total:bytes − /memory/classes/heap/released:bytes を 5ms 間隔で読んだ最大値です。gcCPU は GC の CPU 時間を /cpu/classes/total:cpu-seconds(GOMAXPROCS × 実時間)で割った値で、アイドルの P で走った GC も含みます。

$ go version
go version go1.27.1 darwin/arm64
$ for g in 50 100 200; do GOMAXPROCS=4 GOGC=$g ./gcexp -total 16384; done
limit=8796093022207MiB gcs=495 peak=125MiB time=1.91s gcCPU=10.6% limiterLast=0
limit=8796093022207MiB gcs=253 peak=158MiB time=1.51s gcCPU=7.1% limiterLast=0
limit=8796093022207MiB gcs=118 peak=243MiB time=1.34s gcCPU=4.1% limiterLast=0

上は 3 回目の出力。3 回の中央値で見ると GC 回数は 495 → 253 → 118 回。GOGC を倍にするたびにほぼ半分になり、ガイドの説明どおりでした。ピークが 130 → 157 → 243MiB と倍々にならないのは、64MiB のライブヒープ部分は GOGC に関係なく常に要るからです。割り当て自体のコストは Go 1.27 で下がっています(Go 1.27のメモリ割り当て高速化—80バイト以下で31%、-raceでは効かない)が、GC の回数を決めるのはこの式だけ。

gctrace の行を読む

$ GOMAXPROCS=4 GODEBUG=gctrace=1 GOGC=100 ./gcexp -total 1024 2>&1 >/dev/null | sed -n '8p'
gc 8 @0.022s 4%: 0.016+0.83+0.008 ms clock, 0.065+0.007/0.79/0.66+0.032 ms cpu, 123->131->73 MB, 140 MB goal, 0 MB stacks, 0 MB globals, 4 P

go doc runtime の GODEBUG の説明によると、123->131->73 MB は GC 開始時・終了時のヒープとライブヒープ、140 MB goal が次の目標ヒープを指します。ライブ 73 に GOGC=100 を当てると目標はおよそ 146 で、表示の 140 とほぼ合います。先頭の 4% はプログラム開始からの GC 時間の割合で、上の gcCPU とは集計方法が違うため数字は一致しません。

GOMEMLIMIT の既定値と単位の書き方

既定値は math.MaxInt64

go doc runtime には “The default setting is math.MaxInt64, which effectively disables the memory limit.” とあり、何も設定しなければ上限はありません。

fmt.Println(debug.SetMemoryLimit(-1)) // 負の値を渡すと、変更せずに現在値だけ返る
$ ./ml
9223372036854775807

Go 1.25 で GOMAXPROCS はコンテナの CPU 制限を読むようになりました(GOMAXPROCSをコンテナのCPU制限に合わせる—Go 1.25の自動調整)。メモリ側に同じ仕組みはありません。runtime のドキュメントに、既定値を cgroup から決めるという記述はありません。Go 1.25〜1.27 のリリースノートにも GOMEMLIMIT の変更は載っていません。コンテナに載せるなら自分で設定します。

512MB も 512Mi も起動時に落ちる

受け付ける接尾辞は B / KiB / MiB / GiB / TiB だけ。MB や Kubernetes 流の Mi、小数を書くと、起動時にランタイムが止まります。スタックトレースは runtime.schedinit → runtime.gcinit → runtime.readGOMEMLIMIT で、パッケージの init すら走りません。

$ GOMEMLIMIT=512MB ./gcexp
fatal error: malformed GOMEMLIMIT; see `go doc runtime/debug.SetMemoryLimit`
$ echo $?
2
$ GOMEMLIMIT=512Mi ./gcexp
fatal error: malformed GOMEMLIMIT; see `go doc runtime/debug.SetMemoryLimit`
$ GOMEMLIMIT=0.5GiB ./gcexp
fatal error: malformed GOMEMLIMIT; see `go doc runtime/debug.SetMemoryLimit`

panic ではなく fatal error なので recover もできません。環境変数の打ち間違いがデプロイ直後のクラッシュループとして見えるので、ログにこの行があれば単位を疑ってください。

単位なしの 512 はバイトとして通る

危ないのはこちら。数字だけ書くとバイト扱いで、エラーになりません。

$ GOMAXPROCS=4 ./gcexp -total 1024 | tail -1
limit=8796093022207MiB gcs=16 peak=155MiB time=0.09s gcCPU=6.4% limiterLast=0
$ GOMAXPROCS=4 GOMEMLIMIT=512 ./gcexp -total 1024 | tail -1
limit=0MiB gcs=351 peak=84MiB time=1.01s gcCPU=12.0% limiterLast=0

512 バイトは実質ゼロなので、GC はほぼ休みなく走ります。同じ負荷で GC が 16 回から 351 回、実行時間は 0.09秒から1.01秒。起動は通り、警告も出ません。

gogc vs gomemlimit、役割の違い

比較表

「gogc vs gomemlimit」とよく並べられますが、2つは同時に効きます。

GOGCGOMEMLIMIT
決めるものライブヒープに対する上乗せの比率ランタイム全体のメモリの天井
既定値100math.MaxInt64(上限なし)
実行中の変更debug.SetGCPercentdebug.SetMemoryLimit
導入初期からGo 1.19
守られ方目標なので大きな割り当てで超えるsoft limit。GC の CPU が50%を超えると超過を許す
無効にする方法GOGC=off(上限があれば上限に届くまで GC しない)既定の MaxInt64 のまま

GC のアルゴリズム自体を入れ替えた Green Tea GC とは別の層の設定です(Green Tea GCとは—Go 1.26で変わるGCの仕組みと効果を実測)。

上限に数えるメモリの範囲

go doc runtime/debug.SetMemoryLimit によると、上限と比べるのは runtime.MemStats.Sys - runtime.MemStats.HeapReleased。cgo で C 側が確保したメモリ、syscall.Mmap の領域、バイナリ自体のマッピングは入りません。コンテナの OOM Killer が見る値とはずれるので、GOMEMLIMIT をコンテナ上限ぴったりにすると余白が消えます。

GOGC=off と GOMEMLIMIT はどこまで詰められるか

上限を下げていった結果

検索上位の記事は「GOGC=off にして、GOMEMLIMIT をコンテナ上限の85〜90%に置く」構成をよく勧めています。ガイドの “Memory limit” セクションにも “even when GOGC is set to off, the memory limit is still respected!” とあり、動き自体は正しい。問題は上限とライブヒープの距離です。同じ負荷(ライブ 64MiB)で上限だけ下げました。

設定GC回数ピーク実行時間gcCPU
GOGC=100(基準)253157MiB1.51s7.1%
GOGC=off, 1GiB181013MiB1.39s1.1%
GOGC=off, 300MiB86303MiB1.32s3.3%
GOGC=off, 150MiB449152MiB1.85s9.9%
GOGC=off, 100MiB1300101MiB3.52s15.7%
GOGC=off, 80MiB514088MiB15.42s12.3%
GOGC=100, 80MiB527688MiB15.94s12.5%

300MiB までは GOGC=100 より速い。一方 1GiB は 300MiB より 0.07 秒遅く、上限が大きいほど速いわけでもありませんでした。100MiB で 2.3 倍、80MiB で 10.2 倍。最後の行のとおり、GOGC を 100 に戻しても上限の方が先に効くので結果は変わりません。

50% リミッタは作動しなかった

ガイドには、上限を低く設定しすぎても “the program will slow down at most by 2x” とあります。GC が使える CPU を約50%に抑えるリミッタがあるためです。ところが 80MiB のケースで /gc/limiter/last-enabled:gc-cycle は 0、一度も作動していません。CPU の内訳を出すと、理由はすぐ分かりました。

$ GOMAXPROCS=4 GOGC=off GOMEMLIMIT=80MiB ./gcexp -total 16384  # 抜粋
  /cpu/classes/gc/mark/assist:cpu-seconds 0.56
  /cpu/classes/gc/mark/dedicated:cpu-seconds 3.30
  /cpu/classes/gc/mark/idle:cpu-seconds 3.39
  /cpu/classes/gc/pause:cpu-seconds 0.32
  /cpu/classes/gc/total:cpu-seconds 7.56
  /cpu/classes/idle:cpu-seconds 29.40
  /cpu/classes/total:cpu-seconds 62.07
  /cpu/classes/user:cpu-seconds 25.10
limit=80MiB gcs=5124 peak=90MiB time=15.52s gcCPU=12.2% limiterLast=0

GC の CPU は 7.56 秒で、そのうち 3.39 秒はアイドルの P で走った分です。膨らんだのは user で、GOGC=100 のときの 2.00秒から25.10秒 に増えています。同じ処理をしているのにアプリ側の時間が 12 倍。pprof で中身を見ました。

$ go tool pprof -top -nodecount=18 gcexp cpu.out
      flat  flat%   sum%        cum   cum%
     8.73s 33.88% 33.88%      8.73s 33.88%  runtime.usleep
     4.35s 16.88% 50.76%      4.35s 16.88%  runtime.pthread_cond_signal
     3.23s 12.53% 63.29%      3.23s 12.53%  runtime.pthread_cond_timedwait_relative_np
     2.47s  9.58% 72.88%      2.47s  9.58%  runtime.pthread_cond_wait
     ...
     0.06s  0.23% 97.05%     10.22s 39.66%  runtime.lock2
     0.05s  0.19% 97.48%      7.68s 29.80%  runtime.(*sweepLocked).sweep

-peek で呼び出し元をたどると、スイープ(累計 30%)の 92% が newMarkBits で、その中身はほぼランタイム内部のロック取得でした。lock2(累計 40%)の 85% は osyield、macOS では usleep です。スイープの途中でロックを待ち、その間スレッドが寝ている、という形でした。

runtime/metrics の説明では、/cpu/classes/user は “may also include some small amount of time spent in the Go runtime” とされています。small amount のはずのこの部分が、今回は 2 秒から 25 秒に膨らみました。ランタイムはこの時間を GC ではなくアプリ側に数えます。

リミッタの実装 src/runtime/mgclimit.go のコメントでは、バケツは “fills with GC CPU time and drains with mutator time”。リミッタはアイドル時間を計算から外します。アプリ側がロック待ちで伸びるほどバケツは減るので、GC の比率は 50% に届きません。リミッタが抑えるのは GC の CPU の取り分で、実行時間は守備範囲の外でした。なお usleep や pthread_cond_* は macOS のロック実装で、Linux(futex)では倍率が変わる可能性があります。本番が Linux コンテナなら同じ計測をやり直してください。

GOMAXPROCS=1 なら作動する

$ for i in 1 2 3; do GOMAXPROCS=1 GOGC=off GOMEMLIMIT=70MiB ./gcexp -total 16384 | tail -1; done
limit=70MiB gcs=2105 peak=86MiB time=3.66s gcCPU=41.9% limiterLast=2106
limit=70MiB gcs=2098 peak=86MiB time=3.66s gcCPU=42.0% limiterLast=2099
limit=70MiB gcs=2100 peak=86MiB time=3.67s gcCPU=41.9% limiterLast=2101
$ GOMAXPROCS=1 GOGC=100 ./gcexp -total 16384 | tail -1
limit=8796093022207MiB gcs=177 peak=196MiB time=1.57s gcCPU=10.6% limiterLast=0

P が 1 本だとアイドルの P が無く、GC が CPU を直接奪い合います。limiterLast が GC 回数とほぼ同じ値なので、終了時点までリミッタは作動し続けていました。実行時間は 1.57 秒から 3.66 秒で 2.3 倍、ガイドの「最大2倍」に近い。その代わりピークは 86MiB で、70MiB の上限を 16MiB 超えました。soft limit の「超過を許して止まらない」側の動きです。

Kubernetes での GOMEMLIMIT は上限の 90% にする

公式ガイドの Suggested uses

“Suggested uses” の節は、使う場面と使わない場面を分けて書いています。

  • コンテナのように環境を自分で管理できるなら使う。Go ランタイムが知らないメモリのために 5〜10% の余白を残す
  • ほかのプロセスとメモリを分け合う環境では GOGC を off にしない。上限は残しつつ、GOGC は平時向けの小さめの値にする
  • CLI ツールやデスクトップアプリのように、動く環境を選べないプログラムには組み込まない

同じ節の “Don’t set a memory limit to avoid out-of-memory conditions when a program is already close to its environment’s memory limits.” に当てはまるのが、上の 80MiB の結果でした。ライブヒープがすでに上限近くにあるなら、GOMEMLIMIT で抑え込むのは筋が悪い。ガイドはコンテナの上限を上げるか、GOGC を下げる方を勧めています。

resourceFieldRef をそのまま渡すと余白がゼロ

「gomemlimit kubernetes」もサジェストの上位に出ていました。Downward API の resourceFieldRef を使うと、公式ドキュメントの例では limits.memory: "64Mi" を渡した Pod は 67108864 を出力しています。単位の無いバイト数なので GOMEMLIMIT はそのまま読めますが、上限の 100% で余白がありません。

env:
  - name: GOMEMLIMIT
    valueFrom:
      resourceFieldRef:
        resource: limits.memory   # 64Mi なら "67108864" が入る

起動時に SetMemoryLimit で 90% を設定する

別名の環境変数で受け取り、アプリ側で 90% にします。

env:
  - name: MEM_LIMIT
    valueFrom:
      resourceFieldRef:
        resource: limits.memory
// setMemoryLimitFromEnv は MEM_LIMIT(バイト)の90%を GOMEMLIMIT として設定する。
// GOMEMLIMIT が明示されていればそちらを優先する。
func setMemoryLimitFromEnv() {
	if _, ok := os.LookupEnv("GOMEMLIMIT"); ok {
		return
	}
	v, err := strconv.ParseInt(os.Getenv("MEM_LIMIT"), 10, 64)
	if err != nil || v <= 0 {
		return
	}
	debug.SetMemoryLimit(v / 10 * 9)
}

func main() {
	fmt.Println("before:", debug.SetMemoryLimit(-1))
	setMemoryLimitFromEnv()
	fmt.Println("after: ", debug.SetMemoryLimit(-1))
}
$ MEM_LIMIT=67108864 ./ml
before: 9223372036854775807
after:  60397974
$ MEM_LIMIT=67108864 GOMEMLIMIT=48MiB ./ml
before: 50331648
after:  50331648
$ ./ml
before: 9223372036854775807
after:  9223372036854775807

67108864 の 90% で 60397974 バイト(約57.6MiB)。GOMEMLIMIT を明示した場合はそちらが残るので、障害対応で一時的に上書きしたいときも環境変数だけで済む作りにしました。

設定後に見るメトリクス

リミッタが作動したか

samples := []metrics.Sample{
	{Name: "/gc/limiter/last-enabled:gc-cycle"},
	{Name: "/cpu/classes/gc/total:cpu-seconds"},
	{Name: "/cpu/classes/gc/mark/idle:cpu-seconds"},
	{Name: "/cpu/classes/user:cpu-seconds"},
}
metrics.Read(samples)

/gc/limiter/last-enabled:gc-cycle が 0 以外になったら、ランタイムが上限の超過を許したことになります。runtime/metrics の説明には “useful for diagnosing the root cause of an out-of-memory error” とあり、OOM の調査で見る値です。

user の CPU 時間を基準と比べる

リミッタが 0 のままでも安心はできません。上の 80MiB のケースのように、/cpu/classes/user が GOGC=100 のときより大きく伸びていれば、GC の回数が多すぎてアプリ側が待たされています。今回は 2.00 秒が 25.10 秒になりました。上限を外した状態で一度同じ負荷を測り、その user を基準に比べます。

まとめ

  • GOGC は「ライブヒープに対してどれだけ上乗せしてよいか」。倍にすると GC 回数はほぼ半分になった(495 → 253 → 118 回)
  • GOMEMLIMIT の既定は math.MaxInt64。接尾辞は B/KiB/MiB/GiB/TiB だけで、512MB は fatal error、512 は 512 バイトとして通る
  • GOGC=off と上限の組み合わせは、上限がライブヒープの 1.25 倍(80MiB)まで近づくと実行時間が 10.2 倍になった。50% リミッタは GC の CPU を見るだけなので作動しなかった
  • Kubernetes では limits.memory を別の環境変数で受け取り、debug.SetMemoryLimit で 90% を設定する
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次