《Go 语言运行时原理》5.2 结构体对齐、padding 与缓存行

实测同字段集合重排顺序后结构体从 32 字节降到 24 字节(省 25%),并钉到 go1.27.0 的 CalcStructSize 对齐算法;再用 4 个 goroutine 各写一个原子计数器,实测 false sharing 让单次操作从 14.7ms 涨到 221ms(约 15 倍),并确认 M1 Pro 的缓存行是 128 字节。

5.2 结构体对齐、padding 与缓存行

同一个结构体,只把字段顺序换一换,unsafe.Sizeof 就能从 32 变成 24——这不是编译器魔法,而是对齐规则的必然结果:每个字段必须放在它自身对齐值的整数倍偏移上,字段之间因此插入了 padding;而 padding 的多少,取决于你把大字段排在前面还是后面。

对齐的另一面是缓存行:CPU 以缓存行为单位读写内存,两个 goroutine 若分别写同一缓存行里的不同变量,硬件会来回作废对方的缓存副本,性能断崖式下跌。这就是 false sharing。

本节用两组实验分别量化这两件事。

本节要回答:字段顺序能省多少内存、false sharing 到底有多贵?结论是:字段按对齐值从大到小排列可把结构体从 32 字节压到 24 字节(省 25%);4 个 goroutine 写同一缓存行上的不同计数器,比写各自独立缓存行慢约 15 倍(实测 221 ms vs 14.7 ms);M1 Pro 的缓存行是 128 字节,所以 64 字节的间隔仍会 false sharing,必须按 128 字节对齐。 与 /posts/golang/ 既有内存对齐文章的分工:那些偏「对齐是什么」,本节只写增量——实测的布局收益、false sharing 数字与缓存行宽度的实测确认。

5.2.1 实验一:字段顺序如何改变大小

复现基线:

  • Go 工具链 go version go1.27.0 darwin/arm64(GOTOOLCHAIN=go1.27.0)
  • 机器:Apple M1 Pro,10 核,32 GiB;sysctl -n hw.cachelinesize = 128 字节
  • 纯编译期/运行期布局观察,GOGC/GOMAXPROCS 无关

两个结构体字段集合完全相同,只是顺序不同:

type Bad struct {
	A bool   // 1
	B int64  // 8
	C bool   // 1
	D int32  // 4
	E *int   // 8
}

type Good struct {
	B int64
	E *int
	D int32
	A bool
	C bool
}

实测输出:

$ GOTOOLCHAIN=go1.27.0 go run .
Bad  size=32 align=8
  A off=0 B off=8 C off=16 D off=20 E off=24
Good size=24 align=8
  B off=0 E off=8 D off=16 A off=20 C off=21
省下 8 字节/对象 (25.0%)

Bad 的浪费一眼可见:A 是 bool(1 字节)却占了偏移 0,下一个字段 B 是 int64(对齐 8),必须在偏移 8——于是偏移 1–7 共 7 字节 padding。后面 C(偏移 16)之后接 D(int32,对齐 4),偏移 17–19 又是 3 字节 padding。整块 32 字节里 10 字节是 padding。

Good 把字段按对齐值从大到小排:8、8、4、1、1。字段之间一个 padding 都没有(D 在偏移 16、A 在 20、C 在 21,紧挨着),只有尾部为了满足整体对齐补 2 字节。24 字节里只有 2 字节 padding。

关键在于这 8 字节不是省了 8,而是跨过了一整个 size class:Bad 的 32 字节正好落在 size class 32 上,Good 的 24 字节落在 class 24 上(见 4.1 的档位表)。对 100 万个实例,账单从 32 MB 降到 24 MB。

5.2.2 实验二:false sharing 到底有多贵

四个 goroutine,各自只写自己的计数器,区别只在计数器在数组里的间距:

const (
	workers = 4
	iters   = 2_000_000
)

func run(stride int) {
	counters := make([]int64, workers*stride)
	var wg sync.WaitGroup
	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			c := &counters[i*stride]
			for j := 0; j < iters; j++ {
				atomic.AddInt64(c, 1)
			}
		}(i)
	}
	wg.Wait()
}

stride=1 时四个 int64 挤在连续 32 字节里(同一缓存行);stride=8 时每个计数器相隔 64 字节;stride=16 时相隔 128 字节。每个 benchmark -benchtime=20x -count=5,取区间:

$ GOTOOLCHAIN=go1.27.0 go test -bench=. -benchtime=20x -count=5 -cpu=4
cpu: Apple M1 Pro
BenchmarkStride1-4    	      20	 221206196 ns/op
BenchmarkStride1-4    	      20	 250179158 ns/op
BenchmarkStride1-4    	      20	 271357873 ns/op
BenchmarkStride1-4    	      20	 293969758 ns/op
BenchmarkStride1-4    	      20	 316698004 ns/op
BenchmarkStride8-4    	      20	  30117667 ns/op
BenchmarkStride8-4    	      20	  34616700 ns/op
BenchmarkStride8-4    	      20	  37047385 ns/op
BenchmarkStride8-4    	      20	  35758585 ns/op
BenchmarkStride8-4    	      20	  30383802 ns/op
BenchmarkStride16-4   	      20	  14735165 ns/op
BenchmarkStride16-4   	      20	  14718096 ns/op
BenchmarkStride16-4   	      20	  14670473 ns/op
BenchmarkStride16-4   	      20	  14816688 ns/op
BenchmarkStride16-4   	      20	  14609017 ns/op
PASS
ok  	layout	33.887s

整理成区间:

布局计数器间距每轮耗时(20 次迭代)相对 stride16
stride=18 字节(同一缓存行)221–317 ms约 15–21 倍
stride=864 字节(仍同一 128B 行)30–37 ms约 2.1–2.5 倍
stride=16128 字节(独立缓存行)14.6–14.8 ms1.0(基线)

三点结论:

  1. false sharing 的代价是数量级的:同一缓存行上的四个计数器,比各自独占缓存行慢约 15 倍。这不是微优化,是会主导整体性能的问题。
  2. 64 字节间隔不够。stride=8(64 字节)仍比 stride=16(128 字节)慢约 2 倍——因为 M1 Pro 的缓存行是 128 字节,64 字节间距的两个计数器依然落在同一行里。这与 x86 常见的 64 字节缓存行不同,不能照搬「pad 到 64 字节」的结论。
  3. 用 sysctl 确认缓存行宽度再决定 padding 值,而不是记一个固定数字。

5.2.3 源码:对齐算法与整体对齐

结构体布局由 src/cmd/compile/internal/types/size.go:CalcStructSize 计算。它先按字段顺序累积偏移(calcStructOffset),再取所有字段对齐值的最大值作为结构体自身对齐,最后把总大小向上圆整到该对齐:

func CalcStructSize(t *Type) {
	var maxAlign uint8 = 1
	...
	fields := t.Fields()
	size := calcStructOffset(t, fields, 0)

	// For non-zero-sized structs which end in a zero-sized field, we
	// add an extra byte of padding to the type. ...
	if size > 0 && fields[len(fields)-1].Type.width == 0 {
		size++
	}

	var intRegs, floatRegs uint64
	for _, field := range fields {
		typ := field.Type
		// The alignment of a struct type is the maximum alignment of its
		// field types.
		if align := typ.align; align > maxAlign {
			maxAlign = align
		}
		...
	}

	// Final size includes trailing padding.
	size = RoundUp(size, int64(maxAlign))
	...
	t.width = size
	t.align = maxAlign

三个可直接对应到实验的点:

  • maxAlign 是所有字段对齐值的最大值。Bad/Good 都含 int64 和指针(对齐 8),所以两者 align=8——这也是为什么 Good 的尾部要补 2 字节:21 向上圆整到 8 的倍数得 24。
  • RoundUp(size, maxAlign) 是尾部 padding 的来源。注释写得很直白:「Final size includes trailing padding」。
  • 末尾零大小字段会额外加 1 字节(size++)。这是为了防止「取零大小字段的地址」制造出指向下一个堆对象的指针(注释引的 issue 9401)。所以 struct{ x int64; _ [0]byte } 的大小是 9 向上圆整,不是 8——零大小字段并不总是「免费」的。

字段之间的 padding 来自 calcStructOffset,它对每个字段做一次「先圆整、再累加」:

func calcStructOffset(t *Type, fields []*Field, offset int64) int64 {
	for _, f := range fields {
		CalcSize(f.Type)
		offset = RoundUp(offset, int64(f.Type.align))

		if t.IsStruct() { // param offsets depend on ABI
			f.Offset = offset
			...
		}
		offset += f.Type.width
		...
	}
	return offset
}

offset = RoundUp(offset, f.Type.align) 这一行就是 padding 的唯一来源:每当当前偏移不是下一个字段对齐值的倍数,就先抬到最近的倍数。Bad 里 A(偏移 0,宽 1)之后偏移变成 1,而 B 的对齐是 8,于是 RoundUp(1, 8) = 8——中间 7 字节成了 padding。字段顺序之所以重要,是因为它决定了「圆整发生的频率」:大字段排在前面时,后续小字段几乎不会触发圆整;反过来则每次都要补。

RoundUp 本身很朴素:

func RoundUp(o int64, r int64) int64 {
	if r < 1 || r > 8 || r&(r-1) != 0 {
		base.Fatalf("Round %d", r)
	}
	return (o + r - 1) &^ (r - 1)
}

注意这里 r < 1 || r > 8 || r&(r-1) != 0 直接 Fatalf:Go 的对齐上限是 8 字节,任何超过 8 或非 2 的幂的对齐请求都被当成编译器内部错误。这也是为什么你无法通过声明一个 [64]byte 字段来「自然对齐到缓存行」——[64]byte 的对齐仍是 1。想对齐到缓存行,只能手工插入 padding 字段(如 5.2.2 里用数组间距把相邻计数器隔到 128 字节边界),或者用 sync/atomic 里 align64 那类编译器特判(CalcStructSize 里对 align64 有专门分支,把 maxAlign 设为 8)。

最后,编译器算出的结构体大小还会被运行时的 size class 再圆整一次(见 4.1)——Good 的 24 字节正好是档位值,所以不会二次浪费;若把 Good 改到 25 字节,就会跳到 class 32,把省下的 8 字节又吐回去。

5.2.4 决策:布局优化清单

现象 / 目标依据决策
结构体里有大量 paddingCalcStructSize 按声明顺序累积偏移按字段对齐值从大到小排列:int64/指针/float64 → int32/float32 → int16 → int8/bool
改完大小没变结果被 size class 二次圆整(4.1)对齐目标是跨过 size class 边界(32/48/64/96/128),不是「少几个字节」
想自然对齐到缓存行RoundUp 把对齐上限压到 8声明 [N]byte 字段没用(对齐仍是 1);必须手工 pad 或依赖 align64 特判
多核下并发计数器变慢false sharing给每个计数器独占一条缓存行;先用 sysctl -n hw.cachelinesize 确认行宽(M1 Pro 是 128)
照搬「pad 到 64 字节」缓存行宽度随 CPU 变别照搬;Apple Silicon 是 128,x86 常见 64,按机器实测
结构体末尾有零大小字段CalcStructSize 的 size++零大小字段不总是免费;非必要不要放在结构体末尾
想把结构体塞进寄存器传参CalcStructSize 同时算 intRegs/floatRegs字段数与类型影响寄存器传递;小结构体(≤ 若干寄存器)传值比传指针便宜

两条经验:

  1. 先 unsafe.Sizeof + Offsetof 量,再动手。把 Offsetof 全打出来,padding 藏在哪个字段后面一目了然——比凭记忆猜可靠。
  2. 布局优化对「数组/切片元素」收益最大。单个结构体的 8 字节差别不大;但一个 []Bad 有 100 万个元素时,8 字节 × 100 万 = 8 MB,还顺带降低 GC 扫描与缓存压力。热点数据结构值得花十分钟重排字段。

量偏移的代码本身只有几行,值得随手写一个:

var b Bad
fmt.Printf("A off=%d B off=%d C off=%d D off=%d E off=%d\n",
	unsafe.Offsetof(b.A), unsafe.Offsetof(b.B), unsafe.Offsetof(b.C),
	unsafe.Offsetof(b.D), unsafe.Offsetof(b.E))

相邻两个字段的偏移之差若大于前一个字段的宽度,差额就是 padding。 这个判据不需要你记住对齐规则——只要把 Offsetof 打出来,padding 就自己显形了。

最后一个容易踩的坑:unsafe.Sizeof 给出的是编译期确定的值,它已经包含尾部 padding;而运行时的实际分配还要再经过 size class 圆整(4.1)。所以「Sizeof 省了 8 字节」和「内存账单省了 8 字节」只有在没有跨档位时才等价。真正的验收标准是 4.1 的 TotalAlloc 差值,或者 -benchmem 的 B/op。

阅读导航:上一节:5.1 栈增长与连续栈 · 下一节:5.3 内存布局优化实测 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

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