内存布局

本文将介绍Go中的各种类型的尺寸和对齐保证。 知晓这些保证对于估计结构体值的尺寸和正确使用64位整数原子操作函数是必要的。

Go是一门属于C语言家族的编程语言,所以本文谈及的很多概念和C语言是相通的。

Go中的类型对齐保证(alignment guarantee)

为了充分利用CPU指令来达到最佳程序性能,为一个特定类型的值开辟的内存块的起始地址必须为某个整数N的倍数。 N被称为此类型的值地址对齐保证,或者简单地称为此类型的对齐保证。 我们也可以说此类型的值的地址保证为N字节对齐的。

事实上,每个类型有两个对齐保证。当它被用做结构体类型的字段类型时的对齐保证称为此类型的字段对齐保证,其它情形的对齐保证称为此类型的一般对齐保证。

对于一个类型T,我们可以调用unsafe.Alignof(t)来获得它的一般对齐保证,其中t为一个T类型的非字段值, 也可以调用unsafe.Alignof(x.t)来获得T的字段对齐保证,其中x为一个结构体值并且t为一个类型为T的结构体字段值。

unsafe标准库包中的函数的调用都是在编译时刻估值的。

在运行时刻,对于类型为T的一个值t,我们可以调用reflect.TypeOf(t).Align()来获得类型T的一般对齐保证, 也可以调用reflect.TypeOf(t).FieldAlign()来获得T的字段对齐保证。

对于当前的官方Go标准编译器(1.17版本),一个类型的一般对齐保证和字段对齐保证总是相等的。对于gccgo编译器,这两者可能不相等。

Go白皮书仅列出了些许类型对齐保证要求

一个合格的Go编译器必须保证:
1. 对于任何类型的变量xunsafe.Alignof(x)的结果最小为1
2. 对于一个结构体类型的变量xunsafe.Alignof(x)的结果为x的所有字段的对齐保证unsafe.Alignof(x.f)中的最大值(但是最小为1)。
3. 对于一个数组类型的变量xunsafe.Alignof(x)的结果和此数组的元素类型的一个变量的对齐保证相等。

从这些要求可以看出,Go白皮书并未为任何类型指定了确定的对齐保证要求,它只是指定了一些最基本的要求。

即使对于同一个编译器,具体类型的对齐保证在不同的架构上也是不相同的。 同一个编译器的不同版本做出的具体类型的对齐保证也有可能是不相同的。 当前版本(1.17)的标准编译器做出的对齐保证列在了下面:

  1. 类型种类 对齐保证(字节数)
  2. ------ ------
  3. bool, uint8, int8 1
  4. uint16, int16 2
  5. uint32, int32 4
  6. float32, complex64 4
  7. 数组 取决于元素类型
  8. 结构体类型 取决于各个字段类型
  9. 其它类型 一个自然字的尺寸

这里,一个自然字(native word)的尺寸在32位的架构上为4字节,在64位的架构上为8字节。

这意味着,对于当前版本的标准编译器,其它类型的对齐保证为4或者8,具体取决于程序编译时选择的目标架构。 此结论对另一个流行Go编译器gccgo也成立。

一般情况下,在Go编程中,我们不必关心值地址的对齐保证。 除非有时候我们打算优化一下内存消耗,或者编写跨平台移植性良好的Go代码。 请阅读下两节以获得详情。

类型的尺寸和结构体字节填充(structure padding)

Go白皮书只对以下种类的类型的尺寸进行了明确规定

  1. 类型种类 尺寸(字节数)
  2. ------ ------
  3. uint8, int8 1
  4. uint16, int16 2
  5. uint32, int32, float32 4
  6. uint64, int64 8
  7. float64, complex64 8
  8. complex128 16
  9. uint, int 取决于编译器实现。通常在
  10. 32位架构上为4,在64
  11. 架构上为8
  12. uintptr 取决于编译器实现。但必须
  13. 能够存下任一个内存地址。

Go白皮书没有对其它种类的类型的尺寸做出明确规定。 请阅读值复制成本一文来获取标准编译器使用的各种其它类型的尺寸。

标准编译器(和gccgo编译器)将确保一个类型的尺寸为此类型的对齐保证的倍数。

为了满足前面提到的各条地址对齐保证要求规则,Go编译器可能会在结构体的相邻字段之间填充一些字节。 这使得一个结构体类型的尺寸并非等于它的各个字段类型尺寸的简单相加之和。

下面是一个展示了一些字节是如何填充到一个结构体中的例子。 首先,从上面的描述中,我们已得知(对于标准编译器来说):

  • 内置类型int8的对齐保证和尺寸均为1个字节; 内置类型int16的对齐保证和尺寸均为2个字节; 内置类型int64的尺寸为8个字节,但它的对齐保证在32位架构上为4个字节,在64位架构上为8个字节。
  • 下例中的类型T1T2的对齐保证均为它们的各个字段的最大对齐保证。 所以它们的对齐保证和内置类型int64相同,即在32位架构上为4个字节,在64位架构上为8个字节。
  • 类型T1T2尺寸需为它们的对齐保证的倍数,即在32位架构上为4n个字节,在64位架构上为8n个字节。
  1. type T1 struct {
  2. a int8
  3. // 在64位架构上,为了让字段b的地址为8字节对齐,
  4. // 需在这里填充7个字节。在32位架构上,为了让
  5. // 字段b的地址为4字节对齐,需在这里填充3个字节。
  6. b int64
  7. c int16
  8. // 为了让类型T1的尺寸为T1的对齐保证的倍数,
  9. // 在64位架构上需在这里填充6个字节,在32架构
  10. // 上需在这里填充2个字节。
  11. }
  12. // 类型T1的尺寸在64位架构上为24个字节(1+7+8+2+6),
  13. // 在32位架构上为16个字节(1+3+8+2+2)。
  14. type T2 struct {
  15. a int8
  16. // 为了让字段c的地址为2字节对齐,
  17. // 需在这里填充1个字节。
  18. c int16
  19. // 在64位架构上,为了让字段b的地址为8字节对齐,
  20. // 需在这里填充4个字节。在32位架构上,不需填充
  21. // 字节即可保证字段b的地址为4字节对齐的。
  22. b int64
  23. }
  24. // 类型T2的尺寸在64位架构上位16个字节(1+1+2+4+8),
  25. // 在32位架构上为12个字节(1+1+2+8)。

从这个例子可以看出,尽管类型T1T2拥有相同的字段集,但是它们的尺寸并不相等。

一个有趣的事实是有时候一个结构体类型中零尺寸类型的字段可能会影响到此结构体类型的尺寸。 请阅读此问答获取详情。

64位字原子操作的地址对齐保证要求

在此文中,64位字是指类型为内置类型int64uint64的值。

原子操作一文提到了一个事实:一个64位字的原子操作要求此64位字的地址必须是8字节对齐的。 这对于标准编译器目前支持的64位架构来说并不是一个问题,因为标准编译器保证任何一个64位字的地址在64位架构上都是8字节对齐的。

然而,在32位架构上,标准编译器为64位字做出的地址对齐保证仅为4个字节。 对一个不是8字节对齐的64位字进行64位原子操作将在运行时刻产生一个恐慌。 更糟的是,一些非常老旧的架构并不支持64位原子操作需要的基本指令。

sync/atomic标准库包文档的末尾提到:

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 both ARM and x86-32, 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.

所以,情况并非无可挽救。

  1. 这些非常老旧的架构在今日已经相当的不主流了。 如果一个程序需要在这些架构上对64位字进行原子操作,还有很多其它同步技术可用。
  2. 对其它不是很老旧的32位架构,有一些途径可以保证在这些架构上对一些64位字的原子操作是安全的。

这些途径被描述为开辟的结构体、数组和切片值中的第一个(64位)字可以被认为是8字节对齐的。 这里的开辟的应该如何解读? 我们可以认为一个开辟的值为一个声明的变量、内置函数make的调用返回值,或者内置函数new的调用返回值所引用的值。 如果一个切片是从一个开辟的数组派生出来的并且此切片和此数组共享第一个元素,则我们也可以将此切片看作是一个开辟的值。

此对哪些64位字可以在32位架构上被安全地原子访问的描述是有些保守的。 有很多此描述并未包括的64位字在32位架构上也是可以被安全地原子访问的。 比如,如果一个元素类型为64位字的数组或者切片的第一个元素可以被安全地进行64位原子访问,则此数组或切片中的所有元素都可以被安全地进行64位原子访问。 只是因为很难用三言两语将所有在32位架构上可以被安全地原子访问的64位字都罗列出来,所以官方文档采取了一种保守的描述。

下面是一个展示了哪些64位字在32位架构上可以和哪些不可以被安全地原子访问的例子。

  1. type (
  2. T1 struct {
  3. v uint64
  4. }
  5. T2 struct {
  6. _ int16
  7. x T1
  8. y *T1
  9. }
  10. T3 struct {
  11. _ int16
  12. x [6]int64
  13. y *[6]int64
  14. }
  15. )
  16. var a int64 // a可以安全地被原子访问
  17. var b T1 // b.v可以安全地被原子访问
  18. var c [6]int64 // c[0]可以安全地被原子访问
  19. var d T2 // d.x.v不能被安全地被原子访问
  20. var e T3 // e.x[0]不能被安全地被原子访问
  21. func f() {
  22. var f int64 // f可以安全地被原子访问
  23. var g = []int64{5: 0} // g[0]可以安全地被原子访问
  24. var h = e.x[:] // h[0]可以安全地被原子访问
  25. // 这里,d.y.v和e.y[0]都可以安全地被原子访问,
  26. // 因为*d.y和*e.y都是开辟出来的。
  27. d.y = new(T1)
  28. e.y = &[6]int64{}
  29. _, _, _ = f, g, h
  30. }
  31. // 事实上,c、g和e.y.v的所有以元素都可以被安全地原子访问。
  32. // 只不过官方文档没有明确地做出保证。

如果一个结构体类型的某个64位字的字段(通常为第一个字段)在代码中需要被原子访问,为了保证此字段值在各种架构上都可以被原子访问,我们应该总是使用此结构体的开辟值。 当此结构体类型被用做另一个结构体类型的一个字段的类型时,此字段应该(尽量)被安排为另一个结构体类型的第一个字段,并且总是使用另一个结构体类型的开辟值。

如果一个结构体含有需要一个被原子访问的字段,并且我们希望此结构体可以自由地用做其它结构体的任何字段(可能非第一个字段)的类型,则我们可以用一个[15]byte值来模拟此64位值,并在运行时刻动态地决定此64位值的地址。 比如:

  1. package mylib
  2. import (
  3. "unsafe"
  4. "sync/atomic"
  5. )
  6. type Counter struct {
  7. x [15]byte // 模拟:x uint64
  8. }
  9. func (c *Counter) xAddr() *uint64 {
  10. // 此返回结果总是8字节对齐的。
  11. return (*uint64)(unsafe.Pointer(
  12. (uintptr(unsafe.Pointer(&c.x)) + 7)/8*8))
  13. }
  14. func (c *Counter) Add(delta uint64) {
  15. p := c.xAddr()
  16. atomic.AddUint64(p, delta)
  17. }
  18. func (c *Counter) Value() uint64 {
  19. return atomic.LoadUint64(c.xAddr())
  20. }

通过采用此方法,Counter类型可以自由地用做其它结构体的任何字段的类型,而无需担心此类型中维护的64位字段值可能不是8字节对齐的。 此方法的缺点是,对于每个Counter类型的值,都有7个字节浪费了。而且此方法使用了非类型安全指针。 sync标准库包中的WaitGroup类型的实现采用了此方法,但使用的是[3]uint32类型而不是[15]byte类型。 这基于uint32类型的对齐保证为4的倍数这个假设。 此假设对于当前的官方Go标准编译器和gccgo编译器来说都是成立的。但此假设对于一个可能的第三方编译器未必成立

为了原子操作的跨架构兼容编程变得更简单,Russ Cox提议64位字的地址应该总是8字节对齐的,无论在64位架构上还是32位架构上。 但截至目前(Go 1.17),此提议尚未得到通过。