《Go 语言运行时原理》4.3 分配热点定位与对象复用

用 -memprofile 加 pprof 的 -alloc_space / -alloc_objects / -list 三件套,把分配热点从函数级定位到行级;再用同一份 benchmark 对比 bytes.Buffer 裸用与 sync.Pool 复用,实测 allocs 从 10 降到 1、B/op 从 44992 降到 12293,并诚实报告 ns/op 没有明显改善。

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 兜底,不是靠容量控制

三条落地建议:

  1. 先 profile,再动手。用 -alloc_space 和 -alloc_objects 分别看一遍,确定瓶颈是「字节」还是「次数」,再用 -list 定位到行。没定位到行就不要改。
  2. 把「省分配」和「变快」分开验收。本节的实测就是反例:字节省了 73%,时间没动。改完必须重跑 benchmark,用 ns/op 的区间(-count≥5)判断,而不是看单点。
  3. sync.Pool 的收益主要在 GC 压力,不在单次调用。它的价值要在「高频分配导致 GC 频繁」的负载里才显现——如果 GC 不是瓶颈,Pool 可能只增加复杂度。GC 压力怎么量化,是第 6、7 章的主题。

阅读导航:上一节:4.2 逃逸分析与分配决策 · 下一节:5.1 栈增长与连续栈 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练