Go sync.Poolが1回のGCで空になる条件—victim cacheの中身

BenchmarkNoPool-18        2000000      2440 ns/op    4352 B/op    106 allocs/op
BenchmarkWithPool-18      2000000      1938 ns/op     320 B/op    100 allocs/op

bytes.Buffersync.Pool で使い回したときの差です。B/op は 4352 から 320 へ 92.6% 減。ただ allocs/op は 106 が 100 になっただけで、6 回しか減りません。

目次

Poolで減るのはバイト数であって回数ではない

計測に使ったのは、200 件のレコードを 1 本の文字列へ組み立てる関数です。実行環境は Go 1.26.4 / darwin/arm64 / Apple M5 Pro、コマンドは go test -bench=. -benchmem -benchtime=2000000x

var bufPool = sync.Pool{
	New: func() any { return new(bytes.Buffer) },
}

func render(b *bytes.Buffer, n int) {
	for i := 0; i < n; i++ {
		b.WriteString("id=")
		b.WriteString(strconv.Itoa(i))
		b.WriteByte(';')
	}
}

func BenchmarkWithPool(b *testing.B) {
	for b.Loop() {
		buf := bufPool.Get().(*bytes.Buffer)
		buf.Reset()
		render(buf, 200)
		_ = buf.Len()
		bufPool.Put(buf)
	}
}

allocs/opが100回残る理由

残った 100 回はほぼ strconv.Itoa が返す文字列です。Pool が肩代わりできるのは bytes.Buffer 本体と、内部スライスが 4KB 近くまで伸びる過程での再確保だけ。ループの中で新しく作る値は Pool と無関係にヒープへ乗ります

ここを取り違えると「Pool を入れたのに allocs/op が落ちない」で手が止まります。落ちるのは B/op のほう。導入前に効果を数値で確かめておく話は Go PGO入門—default.pgoの置き方と効果が2.28%に留まる理由 と同じで、期待値を先に測っておくと入れる / 入れないの判断が早くなります。

Get・Put・Newの最小構成

Getany を返すので型アサーションが要ります。New を設定しなければ、Pool が空のとき Getnil を返す。そして Put(nil) は静かに捨てられます。Put の実装の 1 行目が if x == nil { return } です。

公式ドキュメントの Pool 型の説明にある一文が、この後の話の前提になります。

Any item stored in the Pool may be removed automatically at any time without notification.

入れた値が次の Get で返る保証はありません。消えるタイミングを決めているのは GC です。

poolCleanupがGCの先頭でやること

「いつ消えるか」は $(go env GOROOT)/src/sync/pool.gopoolCleanup に全部書いてあります。Go ランタイムが GC の開始時、world stop 中に呼ぶ関数です。

func poolCleanup() {
	// This function is called with the world stopped, at the beginning of a garbage collection.
	// It must not allocate and probably should not call any runtime functions.

	// Drop victim caches from all pools.
	for _, p := range oldPools {
		p.victim = nil
		p.victimSize = 0
	}

	// Move primary cache to victim cache.
	for _, p := range allPools {
		p.victim = p.local
		p.victimSize = p.localSize
		p.local = nil
		p.localSize = 0
	}

	oldPools, allPools = allPools, nil
}

localからvictimへ、victimからゼロへ

2 段の押し出しです。前回の GC で victim へ移したものを捨て、いま local にあるものを victim へ移す。だから Put した値は GC 1 回では生き残り、2 回目で落ちる。この 2 段構えを入れたコミットのタイトルが “sync: smooth out Pool behavior over GC with a victim cache” で、GC の直後に Pool が空になって性能が段差を作る問題を均すために足されました。

取り出し側にもコメントが残っています。getSlow が victim を見に行く直前の 3 行です。

Try the victim cache. We do this after attempting to steal from all primary caches because we want objects in the victim cache to age out if at all possible.

victim は「できれば使わずに歳を取らせたい」置き場。自分の local、他の P の shared を全部あたったあとの最後の受け皿です。

GOMAXPROCS=1なら仕様どおり2回で消える

New が呼ばれた回数を数えれば、消えたかどうかが分かります。

var newCalls int
p := sync.Pool{New: func() any {
	newCalls++
	return new([1024]byte)
}}

p.Put(new([1024]byte))
obj := p.Get()
fmt.Printf("Get(GCなし)   : New呼び出し=%d\n", newCalls)
p.Put(obj)

runtime.GC()
obj = p.Get()
fmt.Printf("Get(GC 1回後) : New呼び出し=%d\n", newCalls)
p.Put(obj)

runtime.GC()
runtime.GC()
_ = p.Get()
fmt.Printf("Get(GC 2回後) : New呼び出し=%d\n", newCalls)
$ GOMAXPROCS=1 go run .
Put直後       : New呼び出し=0
Get(GCなし)   : New呼び出し=0 (再利用=true)
Get(GC 1回後) : New呼び出し=0
Get(GC 2回後) : New呼び出し=1

GC 1 回では New が呼ばれず、2 回目で 1 になりました。poolCleanup の読みどおりです。

GOMAXPROCSを上げると1回のGCで消えることがある

同じバイナリを GOMAXPROCS の指定なしで走らせます。手元は 18 コアです。

$ for i in 1 2 3; do go run . | tr '\n' '|'; echo; done
Put直後: New=0|Get(GCなし): New=0|Get(GC 1回後): New=1|Get(GC 2回後): New=2|
Put直後: New=0|Get(GCなし): New=0|Get(GC 1回後): New=1|Get(GC 2回後): New=2|
Put直後: New=0|Get(GCなし): New=0|Get(GC 1回後): New=0|Get(GC 2回後): New=1|

3 回のうち 2 回は、GC 1 回の時点で New が呼ばれています。しかも 3 回目は呼ばれない。実行するたびに結果が変わります。victim cache に移したはずの値が拾えていません。

privateは自分のPからしか拾えない

原因は Put の格納先にあります。

l, _ := p.pin()
if l.private == nil {
	l.private = x
} else {
	l.shared.pushHead(x)
}

Pool は P(論理プロセッサ)ごとに poolLocal を持ちます。1 個目の Put は private に入り、2 個目からは shared に積まれる。この差が回収時に効きます。getSlow が victim を探す部分がこうなっているためです。

size = atomic.LoadUintptr(&p.victimSize)
if uintptr(pid) >= size {
	return nil
}
locals = p.victim
l := indexLocal(locals, pid)
if x := l.private; x != nil {
	l.private = nil
	return x
}

indexLocal(locals, pid)pidいま自分が乗っている P の番号です。つまり victim の private は、格納したときと同じ P に乗っていないと拾えない。一方 shared は続く for ループで全 P を popTail して回るので、どの P からでも盗めます。

goroutine が GC をまたいで同じ P に留まる保証はありません。18 コアあれば、戻ってきたときに別の P にいる確率がそれだけ上がる。GOMAXPROCS=1 で再現しなかったのは、P が 1 個しかなく pid が必ず 0 だったからです。

Putが1個か2個かで取りこぼしが39%変わる

仮説が正しければ、Put の個数を 2 個に増やした瞬間に取りこぼしが消えるはずです。n 個 Put して GC を 1 回回し、その後の GetNew が呼ばれた割合を 200 回試行で数えました。

func trial(n int) int {
	var newCalls int
	p := sync.Pool{New: func() any {
		newCalls++
		return new([256]byte)
	}}
	for i := 0; i < n; i++ {
		p.Put(new([256]byte))
	}
	runtime.GC()
	_ = p.Get()
	return newCalls
}
GC前にPutした個数GOMAXPROCS=1GOMAXPROCS=18格納先
1個0/200 (0.0%)78/200 (39.0%)private のみ
2個0/200 (0.0%)0/200 (0.0%)private + shared
5個0/200 (0.0%)0/200 (0.0%)private + shared

境目は Put の 1 個目と 2 個目の間にありました。Put が 1 個だけのとき、18 コア環境では 39.0% の確率で victim から拾えません。2 個以上なら 0.0%。shared に 1 個でも積まれていれば、P が変わっても他の P から popTail で回収できるからです。

この39%が実務で問題になる場面

HTTP ハンドラのバッファのように毎秒何千回も Get/Put が回る Pool なら、shared には常に何個も積まれています。1 個しか入っていない瞬間は事実上ありません。この 39% は無視できます。

効いてくるのは呼び出し頻度が低い Pool のほうです。日次バッチの中で数回しか通らない、あるいは特定のエンドポイントでしか使わない Pool は、GC のたびに中身が空になり、New が毎回走る。Pool を置いた意味がなくなるどころか、Get のポインタ操作ぶんだけ遅くなります。低頻度なら Pool を外して素直に確保したほうが速い。

値をPutするとアロケーションが1回増える

Put の引数は any です。スライスをそのまま渡すと、24 バイトのスライスヘッダを interface に収めるためのヒープ確保が入ります。

Putする型ns/opB/opallocs/op
[]byte(値)14.27241
*[]byte(ポインタ)5.53500

ポインタで持たせれば 0 allocs/op、速度も 2.6 倍です。*bytes.Buffer*[]byte のように、Pool へ入れる値は必ずポインタにしてください。

Poolへ戻す前に必要な後始末

Resetを忘れると前の内容が混ざる

Pool は値を初期化しません。Get したあとに Reset を呼び忘れると、前の呼び出しの中身がそのまま乗ります。

// NG: Reset していない
func handle(name string) string {
	b := p.Get().(*bytes.Buffer)
	b.WriteString(name)
	out := b.String()
	p.Put(b)
	return out
}
handle("alice") -> "alice"
handle("bob")   -> "alicebob"
handle("carol") -> "alicebobcarol"

ユーザーごとのレスポンスを組み立てる処理なら、他人のデータがそのまま漏れます。Get の直後に b.Reset() を置くのが定石です。戻す側で Reset する設計も成立しますが、その場合は Put を素で書かず、後始末をまとめた関数を 1 つ通すようにします。Put の呼び出し箇所が増えるほど書き漏れます。

32MBのバッファはGC3回まで解放されない

sync.Pool にサイズ上限の設定はありません。max size を探した人は見つからなくて当然で、そういうフィールドが存在しない。一度でも大きく育ったバッファを戻すと、そのまま保持されます。

開始時 HeapInuse            : 0.28 MB
32MB書き込み後Put→GC1回    : 32.27 MB
さらにGC2回                : 0.27 MB

32MB を書き込んだ bytes.Buffer を Put して参照を捨て、GC を 1 回回しても 32.27MB が居座ります。victim cache が握っているためで、解放されるのは合計 3 回目の GC。リクエストサイズにばらつきがある処理では、たまに来る巨大リクエストの容量を Pool が抱え続けます。

const maxKeep = 64 << 10 // 64KB

if b.Cap() <= maxKeep {
	b.Reset()
	p.Put(b)
}
// 上限を超えたものは Put せず GC に任せる

この 64KB には出どころがあります。標準ライブラリの fmt が同じ対策を持っていて、src/fmt/print.gofree() にコメントごと残っています。

// Proper usage of a sync.Pool requires each entry to have approximately
// the same memory cost. To obtain this property when the stored type
// contains a variably-sized buffer, we add a hard limit on the maximum
// buffer to place back in the pool. If the buffer is larger than the
// limit, we drop the buffer and recycle just the printer.
//
// See https://golang.org/issue/23199.
if cap(p.buf) > 64*1024 {
	p.buf = nil
} else {
	p.buf = p.buf[:0]
}

“each entry to have approximately the same memory cost” が Pool の前提条件です。fmt は 64KB を超えたバッファを捨てて、printer 構造体だけを再利用しています。閾値を決めて超えたぶんは戻さない、という判断がここに書かれています。

まとめ

  • Pool が減らすのは B/op で、allocs/op はループ内で新規に作る値のぶん残る(実測で 4352→320 バイト、106→100 回)
  • poolCleanup は GC の先頭で victim を捨て、local を victim へ移す。だから公称は「GC 2 回で消える」
  • ただし victim の private は同じ P からしか拾えず、Put が 1 個だけだと 18 コア環境で 39.0% 取りこぼす

Put する値はポインタにする。Get の直後に Reset を呼ぶ。育ちすぎたバッファは戻さない。この 3 つを守れば、あとは Get/Put の頻度が十分あるかどうかだけを見れば足ります。頻度が低い Pool は、外したほうが速いことがあります。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次