V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
The Go Programming Language
http://golang.org/
Go Playground
Go Projects
Revel Web Framework
newmiao
V2EX  ›  Go 编程语言

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

  •  
  •   newmiao · 2020-03-11 18:39:18 +08:00 · 1700 次点击
    这是一个创建于 1507 天前的主题,其中的信息可能已经有所发展或是发生改变。

    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

    下面代码验证下:

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

    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 位对齐
      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 位字本身
    // GOARCH=386 go run types/struct/struct.go
    var 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))
    

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

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

    详见issue-37262

    改为加锁

    是不是有些复杂,要在 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 ,里边 structlayout, structlayout-optimize, structlayout-pretty 三个工具比较有意思

    本文代码见 NewbMiao/Dig101-Go

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


    欢迎关注公众号:newbmiao,获取及时更新文章。

    推荐阅读:Dig101-Go 系列,挖一挖技术背后的故事。

    1 条回复    2020-04-06 09:00:43 +08:00
    newmiao
        1
    newmiao  
    OP
       2020-04-06 09:00:43 +08:00
    新增 Go 夜读图解内存对齐分享,见 https://github.com/talk-go/night/issues/588
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   2885 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 28ms · UTC 09:30 · PVG 17:30 · LAX 02:30 · JFK 05:30
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.