一文了解golang结构体的内存对齐

1,559 阅读13分钟

一、前置知识

CPU与内存的交互

CPU获取内存数据:CPU通过地址总线(Address Bus)发送地址信号到内存,并将控制总线(Control Bus)信号设置为Enable信号,之后内存会将数据通过数据总线(Data Bus)返回给CPU。

CPU写入内存数据:CPU通过地址总线(Address Bus)发送地址信号到内存,并将控制总线(Control Bus)信号设置为Set信号,最后将数据通过数据总线(data bus)发送到内存并进行写入。

image

二、什么是内存对齐

现代计算机中内存空间都是按照字节(byte)进行划分的,所以从理论上讲对于任何类型的变量访问都可以从任意地址开始,但是在实际情况中,在访问特定类型变量的时候经常在特定的内存地址访问,所以这就需要把各种类型数据按照一定的规则在空间上排列,而不是按照顺序一个接一个的排放,这种就称为内存对齐,内存对齐是指首地址对齐,而不是说每个变量大小对齐。

举个具体的例子,结构体demo1各成员大小分别为4B、16B、4B,但是结构体总大小为32B,这是因为编译器在编译结构体时对各成员进行了内存对齐,使得成员b、c、a并不是首尾相连的。

type demo1 struct {
   b int32  // 4B
   c string // 16B
   a int32  // 4B
}
func main() {
   demo := demo1{}
   fmt.Println(unsafe.Sizeof(demo))
}
// output: 32

三、为何要内存对齐?

CPU访问内存时,是以字长(word size)为单位进行访问的,字长即对应的CPU的位数。如果访问未对齐的内存,处理器可能需要做两次内存访问,而对齐的内存访问可能仅需要一次访问,内存对齐后可以提升性能。(空间换时间的策略)

举个例子,如果没有内存对齐,基于64位的CPU,访问下图中的对象成员d时,需要访问两次内存。

image

  1. 先将0-7字节的数据加载出来,然后将0-5的数据抛弃,保留6-7的数据。

  2. 然后再把8-15字节的数据加载出来,保留8-9的数据,抛弃10-15的数据。

  3. 最后将两部分数据合并,放到寄存器中。

至于CPU访问内存为何不直接从localtion为6处开始读取一个Word Size长度内容,也是有原因的。

实际上内存并不是程序员想象中的一条长长的不间断的存储空间。

image

实际上它长这样:

image

内存架构在设计时为了最大化带宽,使用了许多并行的memory chip,并基于offset将内存划分为一个个存取的最小单元。CPU通过Address Bus告知memory chip对应的偏移量来获取数据,每次都只能取一个偏移量对应的数据,并通过Data Bus送回CPU。比如上图中偏移量为0时取0-7字节的数据,偏移量为1时取8-15字节的数据。

四、golang结构体内存对齐规则

在代码编译过程中,编译器会对数据的存储布局进行对齐优化,也就是说内存对齐是在编译器的工作范畴内,内存对齐规则也是在编译阶段时就执行的。对于golang结构体来说,在编译后,就已经确定好了结构体的大小以及各成员相对首部的偏移量。

type demo2 struct {
   a int32 
   b bool  
   c bool  
   d int32 
}

比如上面这个结构体,编译后的元数据信息如下:

image

圈选出来的框分别表示结构体大小和各成员相对结构体初始位置的偏移量*2。

具体结构体编译后的元数据包含哪些字段,可以参考:juejin.cn/post/710425…

4.1 基本概念

介绍golang语言结构体的对齐规则前,先了解几个概念。

  1. 基本对齐系数

由编译器和操作系统决定,一般来说32位系统基本对齐系数为4(字节);64位系统基本对齐系数为8(字节)。

  1. 成员对齐系数

成员对齐系数=min(成员大小,基本对齐系数)。

以基本对齐系数为8时为例,结构体demo1中b的成员对齐系数为min(4,8)=4,c的成员对齐系数为min(16,8)=8。

type demo1 struct {
   b int32  // 成员大小:4B,成员对齐系数:4B
   c string // 成员大小:16B,成员对齐系数:8B
   a int32  // 成员大小:4B,成员对齐系数:4B
}
  1. 最大成员对齐系数

结构体中最大的成员对齐系数,以demo1为例,该结构体最大成员对齐系数为:8。

4.2 具体规则

  1. 第一个成员在与结构体变量偏移量为0的地址处。

  2. 其他成员变量从该成员的成员对齐系数的整数倍处分配内存(各成员变量对齐于各自成员对齐系数)。

  3. 结构体总大小为最大成员对齐系数的整数倍(结构体对齐于最大成员对齐系数)。

  4. 如果嵌套了子结构体,子结构体对齐为自己的最大成员对齐系数,父结构体的总大小是最大成员对齐系数(嵌套结构体当作一个普通成员看待)的整数倍。(数组、slice都可以等同视为子结构体)

4.3 示例

这里用一个具体的例子来解释下规则(默认64位系统):

type demo2 struct {
   a int32 // 成员大小:4B,成员对齐系数:4B
   b bool  // 成员大小:1B,成员对齐系数:1B
   c bool  // 成员大小:1B,成员对齐系数:1B
   d int32 // 成员大小:4B,成员对齐系数:4B
}

func main() {
   demo := demo2{}
   fmt.Println("结构体大小:", unsafe.Sizeof(demo))
   fmt.Println("成员a偏移量", unsafe.Offsetof(demo.a))
   fmt.Println("成员b偏移量", unsafe.Offsetof(demo.b))
   fmt.Println("成员c偏移量", unsafe.Offsetof(demo.c))
   fmt.Println("成员d偏移量", unsafe.Offsetof(demo.d))
}
output:
结构体大小: 12
成员a偏移量 0
成员b偏移量 4
成员c偏移量 5
成员d偏移量 8

image

4.4 如何利用对齐规则?

在定义结构体时调整成员顺序,有时可以起到节约内存的作用。

原:存储共需要32B

type demo1 struct {
   b int32  // 成员大小:4B,成员对齐系数:4B
   c string // 成员大小:16B,成员对齐系数:8B
   a int32  // 成员大小:4B,成员对齐系数:4B
}
func main() {
   fmt.Println(unsafe.Sizeof(demo1{}))
}
// output:32

修改后:存储共需要24B

type demo1 struct {
   a int32  // 4B
   b int32  //4B
   c string // 16B
}
func main() {
   fmt.Println(unsafe.Sizeof(demo2{}))
}
// output:24

简单普适的调整原则:

  1. 把类型相同的字段定义放一块。
  2. 按照占用空间从小到大(或者从大到小)的顺序定义字段。

4.5 自动优化工具

目前也有工具能够自动对项目中结构体进行顺序调整使得占用内存最小化。

  1. 安装工具
go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
  1. 执行命令
fieldalignment -fix ./...

执行命令后会自动调整结构体字段顺序(但要注意会清除结构体内注释信息)

image

五、结构体内存布局的特殊场景

5.1 结构体中包含空结构体

因为空结构体的大小为0,当空结构体在父结构体中间定义时,没有什么影响。但当空结构体为最后的字段时,会产生额外存储。为方便计算父结构体长度,可以简单理解为空结构体会占用1B空间(实际为0),然后父结构体再根据当前长度判断是否要执行0填充。

原:存储共需要8B,成员b占用4B,成员a占用0B,填充4B。

type demo7 struct {
   b int32
   a struct{}
}

func main() {
   demo := demo7{}
   fmt.Println("结构体大小:", unsafe.Sizeof(demo))
   fmt.Println("成员b偏移量:", unsafe.Offsetof(demo.b))
   fmt.Println("成员a偏移量:", unsafe.Offsetof(demo.a))
}
output:
结构体大小: 8
成员b偏移量: 0
成员a偏移量: 4
成员a所占空间: 0

image

修改后: 存储共需要4B,成员a占用0B,成员b占用4B。

type demo7 struct {
    a struct{}
    b int32
}
func main() {
   demo := demo7{}
   fmt.Println("结构体大小:", unsafe.Sizeof(demo))
   fmt.Println("成员b偏移量:", unsafe.Offsetof(demo.b))
   fmt.Println("成员a偏移量:", unsafe.Offsetof(demo.a))
   fmt.Println("成员a所占空间:", unsafe.Sizeof(demo.a))
}
output:
结构体大小: 4
成员b偏移量: 0
成员a偏移量: 0
成员a所占空间: 0

问题:为什么空结构体字段放最后会额外占用内存?

原因:若不填充,当空结构体字段定义到最后时,如果该空结构体字段被其它指针所引用,那么该指针就会访问到结构体外部的空间,存在内存泄漏的风险。

5.2 32位平台上进行64位原子操作

在32位系统中用64位的原子操作,必须要保证64位对齐,也就是8字节对齐,否则可能会发生panic。

type demo8 struct {
    a bool
    b uint64
}
func main() {
    y := demo8{}
    atomic.AddUint64(&y.b, 1) // maybe panic in 32bit system
}

上面的例子,在64位操作系统上能正常运行,但在32位操作系统上,因为操作系统只能保证32位对齐(32位系统默认对齐系数为4B),不能保证64位对齐,所以可能发生会panic。这里可以使用人为控制的方式保证对齐,从而解决系统panic的问题。在sync.WaitGroup中有相关的示例:

WaitGroup中需要64位的字段state和32位的字段semaphore,其中64位字段state包括高32位的counter和低32位的waiter count,且state字段执行的操作为原子操作;32位的字段semaphore,用于block和release调用了wait()方法的协程。

image

64位的原子操作需要64位对齐,但是在32位的系统上,只能保证字段32位对齐。所以WaitGroup将成员state1和state2当成了一个整体进行空间划分,如果state1是64位对齐的,就将state1作为原子操作的字段,将state2作为信号量字段;如果state1是32位对齐的,就将state1的后32位和state2连在一起作为原子操作的字段,将state1的前32位作为信号量字段。

type WaitGroup struct {
   noCopy noCopy

   // 64-bit value: high 32 bits are counter, low 32 bits are waiter count.
   // 64-bit atomic operations require 64-bit alignment, but 32-bit
   // compilers only guarantee that 64-bit fields are 32-bit aligned.
   // For this reason on 32 bit architectures we need to check in state()
   // if state1 is aligned or not, and dynamically "swap" the field order if
   // needed.
   // 可以根据需要,动态交换字段顺序
   state1 uint64
   state2 uint32
}
// state returns pointers to the state and sema fields stored within wg.state*.
func (wg *WaitGroup) state() (statep *uint64, semap *uint32) {
   // 判断state1是否64位对齐,如果对齐,则state1作指针,state2作sema
   if unsafe.Alignof(wg.state1) == 8 || uintptr(unsafe.Pointer(&wg.state1))%8 == 0 {
      // state1 is 64-bit aligned: nothing to do.
      return &wg.state1, &wg.state2
   } else {
      // 如果state1不是64位对齐,则将state1、state2作为[3]unit32,后8B作指针,前4B作sema
      state := (*[3]uint32)(unsafe.Pointer(&wg.state1))
      return (*uint64)(unsafe.Pointer(&state[1])), &state[0]
   }
}

5.3 伪共享

计算机的存储器是分层次的,离CPU越近的存储器,速度越快,每字节的成本也越高,容量也越小。按离CPU远近程度,可以分为register、cache(包括L1、L2等)、memory、disk。

cache分成多个组,每个组分成多个缓存行(cache line),缓存行的大小为2的整数幂个连续字节,一般为32-256个字节。最常见的缓存行大小是64个字节。当不同的线程同时读写同一个cache line上不同数据时就可能发生false sharing,导致多核处理器上严重的系统性能下降。

为了避免变量出现在同一个缓存行而导致false sharing的情况,可以使用填充的方式。只要将对象的大小填充到64B,或者64B的整数倍,就能保证不会出现两个变量在同一缓存行的情况。

在go的标准库sync.Pool中就有这种设计:

这里将整个结构体的大小填充到128字节(64字节的整数倍),可能是为了兼容更多平台(linesize为128字节)。

type poolLocal struct {
   poolLocalInternal

   // Prevents false sharing on widespread platforms with
   // 128 mod (cache line size) = 0 .
   pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}

5.4 Hot path

hot path 是指执行非常频繁的指令序列。

在访问结构体的第一个字段时,可以直接使用结构体的指针来访问第一个字段(结构体变量的内存地址就是其第一个字段的内存地址)。

如果要访问结构体的其他字段,除了结构体指针外,还需要计算偏移量(calculate offset)。在机器码中,偏移量是随指令传递的附加值,所以带上偏移量的指令序列会更长。同时CPU 还需要做一次偏移量与指针的加法运算,才能获取要访问的值的地址。因此,访问第一个字段与访问其它字段相比,机器代码会更紧凑(长度短),执行速度也会更快(没有指针与偏移量的加法运算)。

标准库sync.Once中有一个使用实例,其将常用字段done放在第一位,来提高访问done的性能。

m只有在done为0时才会被访问到,访问次数相对来说更少。

type Once struct {
   // done indicates whether the action has been performed.
   // It is first in the struct because it is used in the hot path.
   // The hot path is inlined at every call site.
   // Placing done first allows more compact instructions on some architectures (amd64/386),
   // and fewer instructions (to calculate offset) on other architectures.
   done uint32
   m    Mutex
}
type Mutex struct {
   state int32
   sema  uint32
}
func (o *Once) Do(f func()) {
   if atomic.LoadUint32(&o.done) == 0 {
      // Outlined slow-path to allow inlining of the fast-path.
      o.doSlow(f)
   }
}
func (o *Once) doSlow(f func()) {
   o.m.Lock()
   defer o.m.Unlock()
   if o.done == 0 {
      defer atomic.StoreUint32(&o.done, 1)
      f()
   }
}

同时与不将done放在第一位相比,内存大小没有变化,都是占用12B,不会产生额外内存占用。

六、经验总结

  1. 定义结构体时可以把类型相同的字段定义放一块,同时按照占用空间从小到大(或者从大到小)的顺序定义字段。

  2. 结构体内嵌套空结构体时,不要放在最后一位。

  3. 在32位平台上进行字段64位原子操作时需要人为控制保证该字段对齐64位。

  4. 在设计一些并发组件的时候,为避免伪共享导致并发性能下降,可以考虑手动的进行填充(定义额外字段的方式)。

  5. 定义结构体时,可以考虑把常使用的字段放在第一位。

参考链接

Data alignment: Straighten up and fly right

Why misaligned address access incur 2 or more accesses?

从源代码和汇编层面解析golang结构体元数据

What does "hot path" mean in the context of sync.Once?