《Go 语言运行时原理》7.3 减少分配与 GC 压力

用同一组基准量化五类常见优化(字符串拼接、slice/map 预分配、格式化、sync.Pool)在 ns/op、B/op、allocs/op 上的差异,并实测「同样的迭代次数,naive 版触发 6473 次 GC、优化版只有 307 次」,把分配路径定位到 src/runtime/malloc.go:mallocgc 与 src/sync/pool.go。

7.3 减少分配与 GC 压力

前两节都在调 GC 的「参数」,这一节换个方向:不改任何 GC 参数,只减少分配本身。GC 的输入是分配,把输入砍掉,输出自然变小。

本节要回答的问题是:常见的几类「少分配」写法,各自能省掉多少分配、又能否真的降低 GC 次数? 结论是:字符串拼接、slice/map 预分配、sync.Pool 三类的收益都在一个数量级以上,且同样的 200 万次迭代下 GC 次数从 6473 降到 307(约 21 倍)。本节的增量是基准数据与 GC 次数实测;「什么写法会逃逸、怎么改代码」属于卷三《Go 语言高级编程》6.1 的决策表,本节只从分配数量与 GC 压力的角度补数据。

7.3.1 实验:五类优化前后基准

复现基线

  • Go 版本:go version go1.27.0 darwin/arm64(GOTOOLCHAIN=go1.27.0)
  • 机器:Apple M1 Pro,10 核,32 GiB 内存;GOMAXPROCS=10;未开 -race
  • 基准:go test -bench=. -benchmem -count=5,下表取 5 次区间
  • GC 计数实验:GOGC=100(默认),-benchtime=2000000x,用 grep -c "^gc " 数 GC 行

被测的五类写法放在同一个测试包里,对照组与优化组一一对应:

package alloc

import (
	"fmt"
	"strconv"
	"strings"
	"sync"
	"testing"
)

var words = []string{"alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta"}

func BenchmarkConcatNaive(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		s := ""
		for j := 0; j < 64; j++ {
			s += words[j%len(words)] // 每次拼接都重新分配
		}
		if len(s) == 0 {
			b.Fatal("empty")
		}
	}
}

func BenchmarkConcatBuilder(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		var sb strings.Builder
		sb.Grow(64 * 8)
		for j := 0; j < 64; j++ {
			sb.WriteString(words[j%len(words)])
		}
		if sb.Len() == 0 {
			b.Fatal("empty")
		}
	}
}

func BenchmarkSliceNoPrealloc(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		var xs []int
		for j := 0; j < 1024; j++ {
			xs = append(xs, j) // 反复扩容
		}
		if len(xs) != 1024 {
			b.Fatal("len")
		}
	}
}

func BenchmarkSlicePrealloc(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		xs := make([]int, 0, 1024)
		for j := 0; j < 1024; j++ {
			xs = append(xs, j)
		}
		if len(xs) != 1024 {
			b.Fatal("len")
		}
	}
}

func BenchmarkMapNoHint(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		m := make(map[int]int) // 不给 hint,反复扩容
		for j := 0; j < 256; j++ {
			m[j] = j
		}
		if len(m) != 256 {
			b.Fatal("len")
		}
	}
}

func BenchmarkMapWithHint(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		m := make(map[int]int, 256) // 预知容量,一次到位
		for j := 0; j < 256; j++ {
			m[j] = j
		}
		if len(m) != 256 {
			b.Fatal("len")
		}
	}
}

func BenchmarkFmtSprintf(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		s := fmt.Sprintf("%d", i)
		if len(s) == 0 {
			b.Fatal("empty")
		}
	}
}

func BenchmarkStrconvItoa(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		s := strconv.Itoa(i)
		if len(s) == 0 {
			b.Fatal("empty")
		}
	}
}

type buf struct{ b [4096]byte }

var pool = sync.Pool{New: func() any { return new(buf) }}
var sinkAny any

func BenchmarkNoPool(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		p := new(buf)
		p.b[0] = byte(i)
		sinkAny = p // 存入接口,强制逃逸到堆
	}
}

func BenchmarkWithPool(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		p := pool.Get().(*buf)
		p.b[0] = byte(i)
		pool.Put(p)
	}
}

运行命令与结果:

cd /tmp/gbrt3/alloc
GOTOOLCHAIN=go1.27.0 go test -bench=. -benchmem -count=5
BenchmarkConcatNaive-10        	  470006	      2484 ns/op	   10488 B/op	      63 allocs/op
BenchmarkConcatNaive-10        	  454257	      2542 ns/op	   10488 B/op	      63 allocs/op
BenchmarkConcatBuilder-10      	 4085569	       292.5 ns/op	     512 B/op	       1 allocs/op
BenchmarkConcatBuilder-10      	 4147999	       282.0 ns/op	     512 B/op	       1 allocs/op
BenchmarkSliceNoPrealloc-10    	  430316	      2817 ns/op	   25208 B/op	      12 allocs/op
BenchmarkSliceNoPrealloc-10    	  371859	      3728 ns/op	   25208 B/op	      12 allocs/op
BenchmarkSlicePrealloc-10      	 1867983	       586.9 ns/op	       0 B/op	       0 allocs/op
BenchmarkSlicePrealloc-10      	 1954765	       644.4 ns/op	       0 B/op	       0 allocs/op
BenchmarkMapNoHint-10          	   79371	     13445 ns/op	   18856 B/op	      13 allocs/op
BenchmarkMapNoHint-10          	  115260	     11914 ns/op	   18856 B/op	      13 allocs/op
BenchmarkMapWithHint-10        	  488664	      2710 ns/op	    9512 B/op	       3 allocs/op
BenchmarkMapWithHint-10        	  419073	      4029 ns/op	    9512 B/op	       3 allocs/op
BenchmarkFmtSprintf-10         	20441720	        54.66 ns/op	      16 B/op	       2 allocs/op
BenchmarkFmtSprintf-10         	22631296	        56.04 ns/op	      16 B/op	       2 allocs/op
BenchmarkStrconvItoa-10        	60215520	        19.47 ns/op	       7 B/op	       1 allocs/op
BenchmarkStrconvItoa-10        	75384379	        18.13 ns/op	       7 B/op	       0 allocs/op
BenchmarkNoPool-10             	 1885946	       708.1 ns/op	    4096 B/op	       1 allocs/op
BenchmarkNoPool-10             	 1560910	       715.8 ns/op	    4096 B/op	       1 allocs/op
BenchmarkWithPool-10           	126004419	         9.839 ns/op	       0 B/op	       0 allocs/op
BenchmarkWithPool-10           	129200612	         8.863 ns/op	       0 B/op	       0 allocs/op

汇总(5 次区间):

对照耗时分配字节分配次数降幅
字符串 +=2484–2633 ns/op10488 B/op63—
strings.Builder273–292 ns/op512 B/op1耗时 ↓8.9×,分配 ↓63×
slice 无预分配2817–3728 ns/op25208 B/op12—
slice make(…,0,1024)587–644 ns/op0 B/op0分配归零
map 无 hint11045–13445 ns/op18856 B/op13—
map make(…,256)2710–4029 ns/op9512 B/op3耗时 ↓3.5×
fmt.Sprintf("%d")54.66–59.34 ns/op16 B/op1–2—
strconv.Itoa18.13–22.84 ns/op7 B/op0–1耗时 ↓2.7×
每次 new(buf)620–782 ns/op4096 B/op1—
sync.Pool 复用8.86–10.44 ns/op0 B/op0耗时 ↓70×

分配次数如何变成 GC 次数

单看 allocs/op 还不够直观,把「同样的迭代次数」下 GC 触发的次数直接数出来:

cd /tmp/gbrt3/alloc
echo "== naive concat =="
GODEBUG=gctrace=1 GOTOOLCHAIN=go1.27.0 go test -run='^$' -bench='ConcatNaive$' -benchtime=2000000x 2>&1 | grep -c "^gc "
echo "== builder concat =="
GODEBUG=gctrace=1 GOTOOLCHAIN=go1.27.0 go test -run='^$' -bench='ConcatBuilder$' -benchtime=2000000x 2>&1 | grep -c "^gc "
== naive concat ==
6473
== builder concat ==
307

同样的 200 万次迭代,naive 版触发 6473 次 GC,builder 版只有 307 次,相差约 21 倍。这不是参数调出来的,而是从源头把分配量砍掉换来的——这也解释了为什么 7.1 里 GOMEMLIMIT 压不出效果:只要分配量还在,GC 就得一遍遍跑。

7.3.2 源码:分配快路径与 sync.Pool

所有堆分配最终都汇到 src/runtime/malloc.go 的 mallocgc:

// src/runtime/malloc.go:mallocgc(节选,约 1067 行)
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
	// Short-circuit zero-sized allocation requests.
	if size == 0 {
		return unsafe.Pointer(&zerobase)
	}

	if sizeSpecializedMallocEnabled && size < uintptr(len(mallocNoScanTable)) {
		if typ == nil || !typ.Pointers() {
			if size >= maxTinySize {
				return mallocNoScanTable[size](size, typ, needzero)
			}
			return mallocgcTinySC2(size, typ, needzero)
		}
		...
	}
	...
	// Assist the GC if needed.
	if gcBlackenEnabled != 0 {
		deductAssistCredit(size)
	}
	...
}

两个关键点:尺寸特化(sizeSpecializedMallocTable 让常见尺寸直接跳到对应函数,省掉 size class 查表)和辅助标记扣费(deductAssistCredit)——后者正是 7.2 里那些 GCMarkAssistBegin 事件的来源。分配越多,用户 goroutine 被征用去标记的时间越多,这就是「减少分配」能同时改善吞吐与延迟的原因。

小对象的分配走 mcache 的本地空闲链表,nextFreeFast 是它的快路径:

// src/runtime/malloc.go:nextFreeFast(节选,约 969 行)
func nextFreeFast(s *mspan) gclinkptr {
	theBit := sys.TrailingZeros64(s.allocCache) // Is there a free object in the allocCache?
	if theBit < 64 {
		result := s.freeindex + uint16(theBit)
		if result < s.nelems {
			freeidx := result + 1
			...
			return gclinkptr(uintptr(result)*s.elemsize + s.base())
		}
	}
	return 0
}

sync.Pool 之所以能把 4096 字节的分配变成 0 次分配,是因为它把对象挂到每个 P 的本地池上,命中时完全不经过 mallocgc:

// src/sync/pool.go:Get(节选,约 132 行)
func (p *Pool) Get() any {
	if race.Enabled {
		...
	}
	l, pid := p.pin()
	x := l.private
	l.private = nil
	if x == nil {
		// Try to pop the head of the local shard.
		x, _ = l.shared.popHead()
		if x == nil {
			x = p.getSlow(pid)
		}
	}
	runtime_procUnpin()
	...
	return x
}

pin 会把当前 goroutine 钉在某个 P 上,从而复用该 P 的本地池;这也是为什么 sync.Pool 里的对象可能在任意一次 GC 后被清空——它的定位是「临时对象的缓存」,不是长期缓存。

7.3.3 决策:减少分配的七类手法

把实验覆盖到的手法与结论整理成清单:

#手法典型收益(实测)适用场景
1字符串拼接改用 strings.Builder + Grow分配 ↓63×,耗时 ↓8.9×循环内拼字符串、构造大文本
2slice 用 make([]T, 0, n) 预分配分配 12→0,耗时 ↓4.8×已知或可估长度的 append
3map 用 make(map[K]V, n) 给 hint分配 13→3,耗时 ↓3.5×已知元素数量的填充
4fmt.Sprintf 换 strconv耗时 ↓2.7×单一数值/布尔转字符串
5大对象用 sync.Pool 复用耗时 ↓70×,分配归零高并发下反复申请的大缓冲区
6避免在热路径上构造临时切片/结构体取决于逃逸,用 -gcflags='-m' 确认(见 8.3)请求级热路径
7传值改传指针、复用对象而非每次 new减少单次分配结构体较大的场景

三条纪律:

  • 先 profile 再动手:go test -benchmem 看 allocs/op,或 -memprofile 定位热点(见 4.3),不要凭感觉改。
  • sync.Pool 不是缓存:对象会在 GC 时被清空,且只有并发下才有收益;单 goroutine 顺序分配反而可能更慢。
  • 分配归零不等于耗时归零:SlicePrealloc 的 0 allocs 仍要 587 ns/op,那是真实的计算与内存写入成本;减少分配省掉的是 GC 与辅助标记的连带开销。

阅读导航:上一节:7.2 GC trace 与延迟分析 · 下一节:8.1 从源码到 SSA:编译阶段与 dump 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

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