Dig101-Go之聊聊struct的内存对齐

文章目录

  1. 1. 0x01 为什么要对齐
  2. 2. 0x02 数据结构对齐
    1. 2.1. 大小保证(size guarantee)
    2. 2.2. 对齐保证(align guarantee)
  3. 3. 0x03 零大小字段对齐
  4. 4. 0x04 内存地址对齐
  5. 5. 0x05 64位字安全访问保证
    1. 5.1. 为什么要保证
    2. 5.2. 怎么保证
    3. 5.3. 改为加锁

Update:

增加Go夜读slide


Dig101: dig more, simplified more and know more

经过前边几篇文章,相信你也发现了,struct几乎无处不在。

string,slice和map底层都用到了struct。

今天我们来重点关注下struct的内存对齐,

理解它,对更好的运用struct和读懂一些源码库的实现会有很大的帮助。

在此之前,我们先明确几个术语,便于后续分析。

  • 字(word)

是用于表示其自然的数据单位,也叫machine word。字是电脑用来一次性处理事务的一个固定长度。

  • 字长

一个字的位数(即字长)。

现代电脑的字长通常为16、32、64位。(一般N位系统的字长是 N/8 字节。)

电脑中大多数寄存器的大小是一个字长。CPU和内存之间的数据传送单位也通常是一个字长。还有而内存中用于指明一个存储位置的地址也经常是以字长为单位。

参见维基百科中

0x01 为什么要对齐

简单来说,操作系统的cpu不是一个字节一个字节访问内存的,是按2,4,8这样的字长来访问的。

所以当处理器从存储器子系统读取数据至寄存器,或者,写寄存器数据到存储器,传送的数据长度通常是字长。

如32位系统访问粒度是4字节(bytes),64位系统的是8字节。

当被访问的数据长度为 n 字节且该数据地址为n字节对齐,那么操作系统就可以一次定位到数据,这样会更加高效。无需多次读取、处理对齐运算等额外操作。

0x02 数据结构对齐

我们先看下基础数据结构的大小定义

大小保证(size guarantee)

如Go官方的文档size and alignment guarantees所示:

type size in bytes
byte, uint8, int8 1
uint16, int16 2
uint32, int32, float32 4
uint64, int64, float64, complex64 8
complex128 16

A struct or array type has size zero if it contains no fields (or elements, respectively) that have a size greater than zero. Two distinct zero-size variables may have the same address in memory.

struct{}[0]T{} 的大小为0; 不同的大小为0的变量可能指向同一块地址。

对齐保证(align guarantee)

  • For a variable x of any type: unsafe.Alignof(x) is at least 1.
  • For a variable x of struct type: unsafe.Alignof(x) is the largest of all the values unsafe.Alignof(x.f) for each field f of x, but at least 1.
  • For a variable x of array type: unsafe.Alignof(x) is the same as the alignment of a variable of the array’s element type.

对这段描述翻译到对应类型的对齐就是下表

参考go101-memory layout

type alignment guarantee
bool, byte, uint8, int8 1
uint16, int16 2
uint32, int32 4
float32, complex64 4
arrays 由其元素(element)类型决定
structs 由其字段(field)类型决定
other types 一个机器字(machine word)的大小

这里机器字(machine word)对应的大小, 在32位系统上是4bytes,64位系统上是8bytes

下面代码验证下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type T1 struct {
a [2]int8
b int64
c int16
}
type T2 struct {
a [2]int8
c int16
b int64
}
fmt.Printf("arrange fields to reduce size:\n"+
"T1 align: %d, size: %d\n"+
"T2 align: %d, size: %d\n",
unsafe.Alignof(T1{}), unsafe.Sizeof(T1{}),
unsafe.Alignof(T2{}), unsafe.Sizeof(T2{}))
/*
output:
arrange fields to reduce size:
T1 align: 8, size: 24
T2 align: 8, size: 16
*/

以64位系统为例,分析如下:

T1,T2内字段最大的都是int64, 大小为8bytes,对齐按机器字确定,64位下是8bytes,所以将按8bytes对齐

T1.a 大小2bytes,填充6bytes使对齐(后边字段已对齐,所以直接填充)

T1.b 大小8bytes,已对齐

T1.c 大小2bytes,填充6bytes使对齐(后边无字段,所以直接填充)

总大小为 8+8+8=24

T2中将c提前后,ac总大小4bytes,在填充4bytes使对齐

总大小为 8+8=16

所以,合理重排字段可以减少填充,使struct字段排列更紧密

0x03 零大小字段对齐

零大小字段(zero sized field)是指struct{},

大小为0,按理作为字段时不需要对齐,但当在作为结构体最后一个字段(final field)时需要对齐的。

为什么?

因为,如果有指针指向这个final zero field, 返回的地址将在结构体之外(即指向了别的内存),

如果此指针一直存活不释放对应的内存,就会有内存泄露的问题(该内存不因结构体释放而释放)

所以,Go就对这种final zero field也做了填充,使对齐。

代码验证如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type T1 struct {
a struct{}
x int64
}

type T2 struct {
x int64
a struct{}
}
a1 := T1{}
a2 := T2{}
fmt.Printf("zero size struct{} in field:\n"+
"T1 (not as final field) size: %d\n"+
"T2 (as final field) size: %d\n",
// 8
unsafe.Sizeof(a1),
// 64位:16;32位:12
unsafe.Sizeof(a2))

0x04 内存地址对齐

unsafe包规范中,有如下说明:

Computer architectures may require memory addresses to be aligned; that is, for addresses of a variable to be a multiple of a factor, the variable’s type’s alignment. The function Alignof takes an expression denoting a variable of any type and returns the alignment of the (type of the) variable in bytes. For a variable x:

uintptr(unsafe.Pointer(&x)) % unsafe.Alignof(x) == 0

大致意思就是,如果类型 t 的对齐保证是 n,那么类型 t 的每个值的地址在运行时必须是 n 的倍数。

这一点在sync.WaitGroup有很好的应用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type WaitGroup struct {
noCopy noCopy
state1 [3]uint32
}

// state returns pointers to the state and sema fields stored within wg.state1.
func (wg *WaitGroup) state() (statep *uint64, semap *uint32) {
// 判定地址是否8位对齐
if uintptr(unsafe.Pointer(&wg.state1))%8 == 0 {
// 前8bytes做uint64指针statep,后4bytes做sema
return (*uint64)(unsafe.Pointer(&wg.state1)), &wg.state1[2]
} else {
// 后8bytes做uint64指针statep,前4bytes做sema
return (*uint64)(unsafe.Pointer(&wg.state1[1])), &wg.state1[0]
}
}

重点是WaitGroup.state1这个字段,

我们知道uint64的对齐是由机器字决定,32位系统是4bytes,64位系统是8bytes

为保证在32位系统上,也可以返回一个64位对齐(8bytes aligned)的指针(*uint64

就巧妙的使用了[3]uint32

首先在64位系统和32位系统上,uint32能保证是4bytes对齐

state1地址是4N: uintptr(unsafe.Pointer(&wg.state1))%4 == 0

而为保证8位对齐,我们只需要判断state1地址是否为8的倍数

  • 如果是(N为偶数),那前8bytes就是64位对齐
  • 否则(N为奇数),那后8bytes是64位对齐

而且剩余的4bytes可以给sema字段用,也不浪费内存

可是为什么要在32位系统上也要保证一个64位对齐的uint64指针呢?

答案是,为了保证在32位系统上也能原子访问64位对齐的64位字。我们下边来详细看下。

0x05 64位字安全访问保证

atomic-bug中提到:

On x86-32, the 64-bit functions use instructions unavailable before the Pentium MMX.
On non-Linux ARM, the 64-bit functions use instructions unavailable before the ARMv6k core.

On ARM, x86-32, and 32-bit MIPS, it is the caller’s responsibility to arrange for 64-bit alignment of 64-bit words accessed atomically. The first word in a variable or in an allocated struct, array, or slice can be relied upon to be 64-bit aligned.

大致意思是,在32位系统上想要原子操作64位字(如uint64)的话,需要由调用方保证其数据地址是64位对齐的,否则原子访问会有异常。

为什么呢?

为什么要保证

这里简单分析如下:

还拿uint64来说,大小为8bytes,32位系统上按4bytes对齐,64位系统上按8bytes对齐。

在64位系统上,8bytes刚好和其字长相同,所以可以一次完成原子的访问,不被其他操作影响或打断。

而32位系统,4byte对齐,字长也为4bytes,可能出现uint64的数据分布在两个数据块中,需要两次操作才能完成访问。

如果两次操作中间有可能别其他操作修改,不能保证原子性。

这样的访问方式也是不安全的。

这一点issue-6404中也有提到:

This is because the int64 is not aligned following the bool.
It is 32-bit aligned but not 64-bit aligned, because we’re on a 32-bit
system so it’s really just two 32-bit values side by side.

怎么保证

The first word in a variable or in an allocated struct, array, or slice can be relied upon to be 64-bit aligned.

变量或开辟的结构体、数组和切片值中的第一个64位字可以被认为是8字节对齐

这一句中开辟的意思是通过声明,make,new方式创建的,就是说这样创建的64位字可以保证是64位对齐的。

但还是比较抽象,我们举例分析下

32位系统下可原子安全访问的64位字有:

  • 64位字本身
1
2
3
4
// GOARCH=386 go run types/struct/struct.go
var c0 int64
fmt.Println("64位字本身:",
atomic.AddInt64(&c0, 1))
  • 64位字数组、切片
1
2
3
c1 := [5]int64{}
fmt.Println("64位字数组、切片:",
atomic.AddInt64(&c1[:][0], 1))
  • 结构体首字段为对齐的64位字及相邻的64位字
1
2
3
4
5
6
7
8
c2 := struct {
val int64 // pos 0
val2 int64 // pos 8
valid bool // pos 16
}{}
fmt.Println("结构体首字段为对齐的64位字及相邻的64位字:",
atomic.AddInt64(&c2.val, 1),
atomic.AddInt64(&c2.val2, 1))
  • 结构体中首字段为嵌套结构体,且其首元素为64位字
1
2
3
4
5
6
7
8
9
10
type T struct {
val2 int64
_ int16
}
c3 := struct {
val T
valid bool
}{}
fmt.Println("结构体中首字段为嵌套结构体,且其首元素为64位字:",
atomic.AddInt64(&c3.val.val2, 1))
  • 结构体增加填充使对齐的64位字
1
2
3
4
5
6
7
8
9
10
c4 := struct {
val int64 // pos 0
valid bool // pos 8
// 或者 _ uint32
// 使32位系统上多填充 4bytes
_ [4]byte // pos 9
val2 int64 // pos 16
}{}
fmt.Println("结构体增加填充使对齐的64位字:",
atomic.AddInt64(&c4.val2, 1))
  • 结构体中64位字切片
1
2
3
4
5
6
7
c5 := struct {
val int64
valid bool
val2 []int64
}{val2: []int64{0}}
fmt.Println("结构体中64位字切片:",
atomic.AddInt64(&c5.val2[0], 1))

The first element in slices of 64-bit elements will be correctly aligned

此处切片相当指针,数据是指向底层堆上开辟的64位字数组,如c1

如果换成数组则会panic,

因为结构体的数组的对齐还是依赖于结构体内字段

1
2
3
4
5
6
7
c51 := struct {
val int64
valid bool
val2 [3]int64
}{val2: [3]int64{0}}
// will panic
atomic.AddInt64(&c51.val2[0], 1)
  • 结构体中64位字指针
1
2
3
4
5
6
7
c6 := struct {
val int64
valid bool
val2 *int64
}{val2: new(int64)}
fmt.Println("结构体中64位字指针:",
atomic.AddInt64(c6.val2, 1))

不过研究这块时发现有个坑:

如果包含首个64位字的结构体是12byte大小时,不一定能保证64未对齐

详见issue-37262

改为加锁

是不是有些复杂,要在32位系统上保证8bytes对齐的64位字, 确实不是很方便

当然也可以选择不使用原子访问(atomic),用加锁(mutex)的方式避免此bug

1
2
3
4
5
6
7
8
c := struct{
val int16
val2 int64
}{}
var mu sync.Mutex
mu.Lock()
c.val2 += 1
mu.Unlock()

最后,其实前边WaitGroup.state1那样保证8bytes对齐还有有个有点点没有分析:

就是为啥state原子访问不直接用uint64,并使用上边提到的64位字对齐保证?

答案相信你也想到了:如果WaitGroup嵌套到别的结构体时,如果不放到结构体首位会有问题, 这会使其使用受限。

总结一下:

  • 内存对齐是为了cpu更高效访问内存中数据
  • struct的对齐是:如果类型 t 的对齐保证是 n,那么类型 t 的每个值的地址在运行时必须是 n 的倍数。

uintptr(unsafe.Pointer(&x)) % unsafe.Alignof(x) == 0

  • struct内字段如果填充过多,可以尝试重排,使字段排列更紧密,减少内存浪费
  • 零大小字段要避免作为struct最后一个字段,会有内存浪费
  • 32位系统上对64位字的原子访问要保证其是8bytes对齐的;当然如果不必要的话,还是用加锁(mutex)的方式更清晰简单

推荐一个工具包:dominikh/go-tools ,里边 structlayout, structlayout-optimize, structlayout-pretty 三个工具比较有意思

本文代码见 NewbMiao/Dig101-Go

See more: Golang 是否有必要内存对齐?

如有疑问,请文末留言交流或邮件:newbvirgil@gmail.com 本文链接 : https://newbmiao.github.io/2020/02/10/dig101-golang-struct-memory-align.html