4.3 分配热点定位与对象复用
「减少分配」是 Go 性能优化的老话题,但工程上真正难的不是「知道要减少分配」,而是知道该减少哪一处。一个服务里可能有几十个函数在分配,凭直觉去改,多半是把不热的路径改得更复杂,热的那一处纹丝不动。
本节给一套可复制的流程:先用 -memprofile + pprof 把热点从「函数」精确到「行」,再用 sync.Pool 复用对象,并且用数字判断这次优化到底值不值。
本节要回答:怎么定位分配热点,
sync.Pool到底能省什么、不能省什么?结论是:go test -memprofile配go tool pprof -alloc_space(看字节)与-alloc_objects(看次数)能定位到源码行;sync.Pool能把「临时缓冲区」的分配次数从每轮多次降到接近 0(本机实测 allocs/op 10 → 1、B/op 44992 → 12293),但它省不掉「结果本身必须分配」的那一份,因此 ns/op 未必同比例改善。
4.3.1 实验:用 memprofile 把热点定位到行
复现基线:
- Go 工具链
go version go1.27.0 darwin/arm64(GOTOOLCHAIN=go1.27.0) - 机器:Apple M1 Pro,10 核,32 GiB(
sysctl -n hw.ncpu= 10) - 未开启
-race;GOGC默认,GOMAXPROCS默认(10) - 三个 benchmark:
BenchmarkBuildRecords(构造 1000 条记录)、BenchmarkEncodeNaive(用bytes.Buffer编码)、BenchmarkEncodePooled(用sync.Pool复用bytes.Buffer) - 每个 benchmark
-count=5,-benchmem;-memprofile=mem.out
被测代码是一个「构造记录 → 编码成文本」的小流水线,热点集中在两处:buildRecords 的逐条构造,和 encodeNaive 里 bytes.Buffer 的增长与 String() 拷贝。
func buildRecords(n int) []*Record {
out := make([]*Record, 0, n)
for i := 0; i < n; i++ {
out = append(out, &Record{ID: i, Name: fmt.Sprintf("rec-%d", i), Tags: []string{"a", "b"}})
}
return out
}
var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}
func encodePooled(recs []*Record) string {
b := bufPool.Get().(*bytes.Buffer)
b.Reset()
defer bufPool.Put(b)
for _, r := range recs {
b.WriteString(r.Name)
b.WriteByte(',')
for _, t := range r.Tags {
b.WriteString(t)
}
b.WriteByte('\n')
}
return b.String()
}
-benchmem 的原始输出(5 次,取区间):
$ GOTOOLCHAIN=go1.27.0 go test -bench=. -benchmem -count=5 -memprofile=mem.out
goos: darwin
goarch: arm64
pkg: hotspot
cpu: Apple M1 Pro
BenchmarkEncodeNaive-10 55054 18713 ns/op 44992 B/op 10 allocs/op
BenchmarkEncodeNaive-10 64492 18726 ns/op 44992 B/op 10 allocs/op
BenchmarkEncodeNaive-10 64288 18699 ns/op 44992 B/op 10 allocs/op
BenchmarkEncodeNaive-10 65199 20436 ns/op 44992 B/op 10 allocs/op
BenchmarkEncodeNaive-10 62263 21568 ns/op 44992 B/op 10 allocs/op
BenchmarkEncodePooled-10 72211 17330 ns/op 12293 B/op 1 allocs/op
BenchmarkEncodePooled-10 67741 17690 ns/op 12293 B/op 1 allocs/op
BenchmarkEncodePooled-10 69026 25007 ns/op 12294 B/op 1 allocs/op
BenchmarkEncodePooled-10 50097 24370 ns/op 12293 B/op 1 allocs/op
BenchmarkEncodePooled-10 63289 17606 ns/op 12293 B/op 1 allocs/op
BenchmarkBuildRecords-10 10000 106467 ns/op 102163 B/op 3745 allocs/op
BenchmarkBuildRecords-10 12768 80691 ns/op 102164 B/op 3745 allocs/op
BenchmarkBuildRecords-10 14164 83831 ns/op 102164 B/op 3745 allocs/op
BenchmarkBuildRecords-10 14546 104796 ns/op 102163 B/op 3745 allocs/op
BenchmarkBuildRecords-10 12021 94496 ns/op 102163 B/op 3745 allocs/op
PASS
ok hotspot 25.803s
先看两件确定的事:
sync.Pool把 allocs/op 从 10 降到 1,B/op 从 44992 降到 12293。 省掉的 9 次分配,正是bytes.Buffer的增长链(growSlice反复扩容)与String()的拷贝。- 剩下的那 1 次分配(12293 B)省不掉。 它是函数返回值
string本身——只要函数签名是「返回一个新字符串」,这份内存就必须存在。sync.Pool复用不了「结果」。
再看 ns/op:naive 是 18699–21568,pooled 是 17330–25007。两者区间高度重叠,不能断言 pooled 更快。 这是一个必须如实报告的结论——省了 73% 的字节,却没有换来可辨识的时间收益,因为这段负载是 CPU 计算主导,分配本身不是瓶颈。
接下来用 profile 把热点钉到行。-alloc_space 按分配字节排序:
$ GOTOOLCHAIN=go1.27.0 go tool pprof -top -alloc_space -nodecount=12 mem.out
File: hotspot.test
Type: alloc_space
Showing nodes accounting for 29.85GB, 100% of 29.86GB total
flat flat% sum% cum cum%
10.94GB 36.65% 36.65% 10.94GB 36.65% bytes.growSlice
9.27GB 31.04% 67.69% 10.04GB 33.61% hotspot.buildRecords
8.85GB 29.65% 97.34% 8.85GB 29.65% bytes.(*Buffer).String (inline)
0.76GB 2.55% 99.90% 0.77GB 2.57% fmt.Sprintf
0.02GB 0.072% 100% 10.96GB 36.72% bytes.(*Buffer).grow
-alloc_objects 按分配次数排序,两者排序可能完全不同:
$ GOTOOLCHAIN=go1.27.0 go tool pprof -top -alloc_objects -nodecount=8 mem.out
Type: alloc_objects
Showing nodes accounting for 306842075, 99.73% of 307669857 total
flat flat% sum% cum cum%
252475272 82.06% 82.06% 303630627 98.69% hotspot.buildRecords
51151628 16.63% 98.69% 51155355 16.63% fmt.Sprintf
2854706 0.93% 99.61% 2854706 0.93% bytes.growSlice
360469 0.12% 99.73% 3215175 1.05% bytes.(*Buffer).grow
对比两张表能读出重点:按字节看,bytes.growSlice 最大(36.65%);按次数看,buildRecords 最大(82.06%)。 如果你的瓶颈是 GC 扫描次数,该改的是 buildRecords;如果瓶颈是内存水位,该改的是 Buffer 扩容。用错口径会优化错地方。
最后用 -list 把函数落到行:
$ GOTOOLCHAIN=go1.27.0 go tool pprof -list 'buildRecords' -alloc_space mem.out
ROUTINE ======================== hotspot.buildRecords in /tmp/gbrt2/hotspot/main.go
9.27GB 10.04GB (flat, cum) 33.61% of Total
. . 16:func buildRecords(n int) []*Record {
811.31MB 811.31MB 17: out := make([]*Record, 0, n)
. . 18: for i := 0; i < n; i++ {
8.48GB 9.24GB 19: out = append(out, &Record{ID: i, Name: fmt.Sprintf("rec-%d", i), Tags: []string{"a", "b"}})
. . 20: }
. . 21: return out
. . 22:}
第 19 行吃掉 8.48 GB,问题一目了然:&Record{...} 每次都要新分配一个 Record,fmt.Sprintf 和 []string{"a","b"} 又各带一次分配。这就是「行级定位」的价值。
4.3.2 源码:sync.Pool 的 per-P 私有槽与 victim cache
sync.Pool 的结构在 src/sync/pool.go。每个 P 有一个 poolLocal,其中 private 是只有本 P 能访问的单槽,shared 是可以被别的 P 偷的链式队列:
type poolLocalInternal struct {
private any // Can be used only by the respective P.
shared poolChain // Can be used by any P.
}
Put 先填 private,满了再推 shared:
func (p *Pool) Put(x any) {
if x == nil {
return
}
...
l, _ := p.pin()
if l.private == nil {
l.private = x
} else {
l.shared.pushHead(x)
}
runtime_procUnpin()
...
}
Get 的取用顺序是本地优先:先 private,再本地 shared 的头部,最后才走 getSlow(去别的 P 偷 / 翻 victim cache):
func (p *Pool) Get() any {
l, pid := p.pin()
x := l.private
l.private = nil
if x == nil {
x, _ = l.shared.popHead()
if x == nil {
x = p.getSlow(pid)
}
}
runtime_procUnpin()
...
if x == nil && p.New != nil {
x = p.New()
}
return x
}
private 无锁、shared 头部无锁,只有跨 P 偷窃才需要同步——这是 sync.Pool 在多核下不成为新瓶颈的原因。
shared 的底层是 src/sync/poolqueue.go 的 poolChain / poolDequeue:pushHead/popHead 是同侧无锁操作,popTail(被偷的那一侧)才用 CAS 抢。注释里点明「我们优先取头部而不是尾部,是为了复用的时间局部性」——刚放进去的对象最可能还在 CPU 缓存里。
关键的一环是 poolCleanup:每次 GC 开始时(STW 中),所有 Pool 的主缓存被降级为 victim,上一轮的 victim 被丢弃。
func poolCleanup() {
// 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
}
这段代码解释了 sync.Pool 最重要的语义:放进去的对象最多活两轮 GC。它带来两个推论:
sync.Pool不是缓存,不能指望「放进去的一定能取回来」;Get可能返回nil,必须用New兜底或自己判空。- 正因为对象会在两轮 GC 内被清掉,它不会造成无界内存增长——这正是它比「手写对象池」更适合通用场景的原因:手写池要自己处理回收策略,而
sync.Pool把生命周期交给了 GC。
4.3.3 决策:什么时候该用 sync.Pool
| 场景特征 | 是否适合 sync.Pool | 理由 |
|---|---|---|
高频创建/销毁临时缓冲区(bytes.Buffer、[]byte 复用) | 适合 | 实测 allocs/op 10 → 1、B/op 44992 → 12293;这正是 Pool 的设计目标 |
| 对象要跨请求长期保存 | 不适合 | 对象最多活两轮 GC(poolCleanup),会被清掉 |
| 高频创建含指针的小结构体 | 谨慎 | 复用减少 GC 扫描压力,但要把对象状态完整重置(Reset()),漏重置比不用更危险 |
返回值本身就是新分配的(如返回 string) | 无收益 | 剩余那 1 次分配(12293 B)省不掉,ns/op 未必改善 |
| 想当「对象缓存」用,期望命中率 | 不适合 | Get 不保证命中;命中率随 GC 周期波动 |
| 需要控制池内对象上限 | 需要自己做 | sync.Pool 无上限参数;靠 GC 兜底,不是靠容量控制 |
三条落地建议:
- 先 profile,再动手。用
-alloc_space和-alloc_objects分别看一遍,确定瓶颈是「字节」还是「次数」,再用-list定位到行。没定位到行就不要改。 - 把「省分配」和「变快」分开验收。本节的实测就是反例:字节省了 73%,时间没动。改完必须重跑 benchmark,用
ns/op的区间(-count≥5)判断,而不是看单点。 sync.Pool的收益主要在 GC 压力,不在单次调用。它的价值要在「高频分配导致 GC 频繁」的负载里才显现——如果 GC 不是瓶颈,Pool 可能只增加复杂度。GC 压力怎么量化,是第 6、7 章的主题。
阅读导航:上一节:4.2 逃逸分析与分配决策 · 下一节:5.1 栈增长与连续栈 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。