Go 高性能之内存对齐

概述 #

内存对齐,或者说字节对齐,指代码编译后在内存的布局与使用方式。现代计算机一般是 32位64位 地址对齐,如果要访问的变量内存没有对齐,可能会触发总线错误。 维基百科

为什么需要内存对齐 #

CPU 访问内存时,并不是逐个字节访问,而是以字长(word size)为单位访问。比如 32 位的 CPU ,字长为 4 字节,那么 CPU 访问内存的单位也是 4 字节。 这么设计的目的,是减少 CPU 访问内存的次数,提升 CPU 访问内存的吞吐量。比如同样读取 8 个字节的数据,一次读取 4 个字节那么只需要读取 2 次。

CPU 始终以字长访问内存,如果不进行内存对齐,很可能增加 CPU 访问内存的次数,例如:

这里假设字长为 4 字节

变量 a、b 各占据 3 字节的空间,内存对齐后,a、b 占据 4 字节空间,CPU 读取 b 变量的值只需要进行一次内存访问。 如果不进行内存对齐,CPU 读取 b 变量的值需要进行 2 次内存访问。第一次访问得到 b 变量的第 1 个字节,第二次访问得到 b 变量的后两个字节。

从这个例子中也可以看到,内存对齐对实现变量的原子性操作也是有好处的,每次内存访问是原子的,如果变量的大小不超过字长,那么内存对齐后, 对该变量的访问就是原子的,这个特性在并发场景下至关重要。

内存对齐可以提高内存读写性能,并且便于实现原子性操作。

内存对齐带来的影响 #

内存对齐提升性能的同时,也需要付出相应的代价。由于变量与变量之间增加了填充,并没有存储真实有效的数据,所以 占用的内存会更大,这也是典型的 空间换时间 策略。

对齐规则 #

类型 大小
bool 1 个字节
intN, uintN, floatN, complexN N / 8个字节(例如 float64 是 8 个字节)
int, uint, uintptr 1 个字
*T 1 个字
string 2 个字 (数据、长度)
[]T 3 个字 (数据、长度、容量)
map 1 个字
func 1 个字
chan 1 个字
interface 2 个字 (类型、值)

字长为 4 字节时,1 个字就是 4 字节,字长为 8 字节时,1 个字就是 8 字节。

内存未对齐 #

package performance

import (
	"testing"
)

// 占用 32 个字节
type person struct {
	hasMoney bool   // 1 个字节
	name     string // 16 个字节
	age      int16  // 2 个字节
}

func Benchmark_Alignment(b *testing.B) {
	b.ResetTimer()

	for i := 0; i < b.N; i++ {
		_ = make([]person, b.N)
	}
}
  • hasMoney 是第 1 个字段,对齐倍数 1,默认已经对齐,从位置 0 开始占据 1 个字节
  • name 是第 2 个字段,对齐倍数 8,因此空出了 7 个字节 (1 - 7),从位置 8 开始占据 16 个字节,正好对齐填充满
  • age 是第 3 个字段,对齐倍数 2,此时内存已经对齐了,从位置 24 开始占据 2 个字节

hasMoney (1 个字节) + 空出的 (7 个字节) + name (16 个字节) + age (2 个字节) + 末尾对齐 (6 个字节) = 32 个字节

32 字节

运行测试,并将基准测试结果写入文件:

# 运行 10000 次,统计内存分配
$ go test -run='^$' -bench=. -count=1 -benchtime=10000x -benchmem > slow.txt

内存对齐 #

package performance

import (
	"testing"
)

// 占用 24 个字节
type person struct {
	name     string // 16 个字节
	age      int16  // 2 个字节
	hasMoney bool   // 1 个字节
}

func Benchmark_Alignment(b *testing.B) {
	b.ResetTimer()

	for i := 0; i < b.N; i++ {
		_ = make([]person, b.N)
	}
}
  • name 是第 1 个字段,对齐倍数 8,默认已经对齐,从位置 0 开始占据 16 个字节
  • age 是第 2 个字段,对齐倍数 2,此时内存已经对齐了,从位置 16 开始占据 2 个字节
  • hasMoney 是第 3 个字段,对齐倍数 1,此时内存已经对齐了,从位置 18 开始占据 1 个字节

name (16 个字节) + age (2 个字节) + hasMoney (1 个字节) + 末尾对齐 (5 个字节) = 24 个字节

24 字节

运行测试,并将基准测试结果写入文件:

# 运行 1000 次,统计内存分配
$ o test -run='^$' -bench=. -count=1 -benchtime=10000x -benchmem > fast.txt

使用 benchstat 比较差异 #

$ benchstat -alpha=100 slow.txt fast.txt 

# 输出如下:
name          old time/op    new time/op    delta
_Alignment-8    18.1µs ± 0%    15.2µs ± 0%  -15.80%  (p=1.000 n=1+1)

name          old alloc/op   new alloc/op   delta
_Alignment-8     328kB ± 0%     246kB ± 0%  -25.00%  (p=1.000 n=1+1)

name          old allocs/op  new allocs/op  delta
_Alignment-8      1.00 ± 0%      1.00 ± 0%     ~     (all equal)

输出的结果分为了三行,分别对应基准期间的: 运行时间、内存分配总量、内存分配次数,采用了 内存对齐 方案后:

  • 运行时间提升了 15%
  • 内存分配优化了 25%

因为时间关系,基准测试只运行了 10000 次,运行次数越大,优化的效果越明显。感兴趣的读者可以将 -benchtime 调大后看看优化效果。

空结构体 #

空结构体 小节中,我们谈到过 空结构体 struct{} 大小为 0。当结构体中字段的类型为 struct{} 时, 一般情况下不需要内存对齐。但是有一种情况例外:当最后一个字段类型为 struct{} 时,需要内存对齐

如果内存没有对齐,同时有指针指向结构体最后一个字段, 那么指针对应的的地址将到达结构体之外,虽然 Go 保证了无法对该指针进行任何操作 (避免安全问题),但是如果该指针一直存活不释放对应的内存, 就会产生内存泄露问题(指针指向的内存不会因为结构体释放而释放)。

一个良好实践是: 不要将 struct{} 类型的字段放在结构体的最后,这样可以避免 内存对齐 带来的占用损耗。

内存对齐造成的额外占用 #

package main

import (
	"fmt"
	"unsafe"
)

type t1 struct {
	x int32
	y struct{}
}

type t2 struct {
	y struct{}
	x int32
}

func main() {
	fmt.Printf("size = %d\n", unsafe.Sizeof(t1{}))
	fmt.Printf("size = %d\n", unsafe.Sizeof(t2{}))
}
$ go run main.go

# 输出如下
size = 8
size = 4

通过将 struct{} 类型的字段从最后一个换到第一个,避免了 内存对齐,节省了一半的内存使用量。

问题 #

Q: 编译器会在编译时自动内存对齐吗?

A: 会自动对齐,但是不会进行优化,也就是不会修改字段顺序,减少内存的使用。

Q: 为什么编译器不在编译时进行优化,通过修改结构体字段顺序,优化内存使用呢?

A: 如果修改了结构体字段顺序,在数据传输的时候,无法正确读取到结构体的数据。

小结 #

结构体类型时刻牢记内存对齐。

Reference #

转载申请

本作品采用 知识共享署名 4.0 国际许可协议 进行许可,转载时请注明原文链接,图片在使用时请保留全部内容,商业转载请联系作者获得授权。

© 蛮荆 | 陕公网安备 61011302001681 号 | 陕ICP备2023004378号-1 | Rendered by Hugo