11

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

 4 years ago
source link: https://mp.weixin.qq.com/s/qPILuArUBnNrJ15COpBziQ
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Dig101: dig more, simplified more and know more

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

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

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

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

文章目录

  • 0x01 为什么要对齐

  • 0x02 数据结构对齐

    • 大小保证(size guarantee)

    • 对齐保证(align guarantee)

  • 0x03 零大小字段对齐

  • 0x04 内存地址对齐

  • 0x05 64 位字安全访问保证

    • 为什么要保证

    • 怎么保证

    • 改为加锁

在此之前,我们先明确几个术语,便于后续分析 (参见维基百科 - 字)。

  • 字(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 [1] 所示:

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 [2]

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

下面代码验证下:

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 也做了填充,使对齐。

代码验证如下:

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 包规范 [3] 中,有如下说明:

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 有很好的应用:

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位对齐ifuintptr(unsafe.Pointer(&wg.state1))%8 == 0 {
    // 前8bytes做uint64指针statep,后4bytes做semareturn (*uint64)(unsafe.Pointer(&wg.state1)), &wg.state1[2]
  } else {
    // 后8bytes做uint64指针statep,前4bytes做semareturn (*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 [4] 中提到:

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 [5] 中也有提到:

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 位字本身

// GOARCH=386 go run types/struct/struct.govar c0 int64
fmt.Println("64位字本身:",
    atomic.AddInt64(&c0, 1))
  • 64 位字数组、切片

c1 := [5]int64{}
fmt.Println("64位字数组、切片:",
    atomic.AddInt64(&c1[:][0], 1))
  • 结构体首字段为对齐的 64 位字及相邻的 64 位字

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 位字

type T struct {
    val2 int64
    _    int16
}
c3 := struct {
    val   T
    valid bool
}{}
fmt.Println("结构体中首字段为嵌套结构体,且其首元素为64位字:",
    atomic.AddInt64(&c3.val.val2, 1))
  • 结构体增加填充使对齐的 64 位字

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 位字切片

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,

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

c51 := struct {
  val   int64
  valid bool
  val2  [3]int64
}{val2: [3]int64{0}}
// will panic
atomic.AddInt64(&c51.val2[0], 1)
  • 结构体中 64 位字指针

c6 := struct {
    val   int64
    valid bool
    val2  *int64
}{val2: new(int64)}
fmt.Println("结构体中64位字指针:",
    atomic.AddInt64(c6.val2, 1))

改为加锁

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

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

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 [6] ,里边 structlayout, structlayout-optimize, structlayout-pretty 三个工具比较有意思

本文代码见 NewbMiao/Dig101-Go [7]

See More: Golang 是否有必要内存对齐? [8]

参考资料

[1]

size and alignment guarantees: https://golang.org/ref/spec#Size_and_alignment_guarantees

[2]

go101-memory layout: https://go101.org/article/memory-layout.html

[3]

unsafe 包规范: https://golang.org/ref/spec#Package_unsafe

[4]

atomic-bug: https://golang.org/pkg/sync/atomic/#pkg-note-BUG

[5]

issue-6404: https://github.com/golang/go/issues/6404#issuecomment-66085602

[6]

dominikh/go-tools: https://github.com/dominikh/go-tools

[7]

NewbMiao/Dig101-Go: https://github.com/NewbMiao/Dig101-Go/blob/master/types/struct/struct.go

[8]

Golang 是否有必要内存对齐?: https://ms2008.github.io/2019/08/01/golang-memory-alignment/

推荐阅读

原创不易,欢迎转发、点击再看。

微信内外链不能跳转,戳 阅读原文 查看原文中参考资料


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK