[译]Golang中的内存对齐

1,779 阅读10分钟

原文连接

这篇文章将介绍Go(内存分配的)中的类型对齐以及大小保证。去理解Go如何确保正确估算struct类型的大小以及正确使用标准库sync/atomic64-bit函数至关重要。

Go是属于C系列的编程语言,这篇文章中谈到的很多概念都来自于C语言。

Go中的类型对齐保证

为了充分使用CPU的指令并获得最佳性能,为给指定类型的值分配的内存块(起始)地址必须是一个整数N的倍数,则N称为该类型的值的(内存)地址对齐保证,或者简单的称为该类型的对齐保证。我们也可以说,这个类型的可寻址值的地址确保是N字节对齐的。

事实上,每种类型拥有两个对其保证,一种当它作为其他类型(struct)的字段,另一种是针对其他情况(如当它作为一个变量的声明,数组元素的类型等等)。我称前者为类型的字段对齐保证,而称后者为类型的一般对齐保证。

对于一种类型,我们可以调用unsafe.Algnof(t)去获取它的一般对齐保证,其中tT类型的无字段值,也可以调用unsafe.Algnof(x.t)去获取它字段的对齐保证,其中x是一个struct的值,同时a是类型T的字段值。

标准库unsafe中的函数调用总是在编译期间。

在运行期间,对于类型T的值,我们可以调用reflect.TypeOf(t).Align()去获取类型T的一般对齐保证,也可以调用reflect.TypeOf(t).FieldAlign()去获取类型T字段的一般对齐保证。

对于目前官方的Go编译器(version 1.16),字段对齐保证和类型的一般对齐保证始终是一致的。而对于gccgo编译器,这种表述是错误的。

Go规范仅仅提到了一点类型对齐保证:

保证以下最小对齐属性
1.对于任意类型的变量x:unsafe.Alignof(x)的值至少为1.
2.对于struct类型的变量:x每个字段的unsafe.Alignof(x.f)的值至少为1,而unsafe.Alignof(x)取所有值最大的
3.对于数组类型的变量:unsafe.Alignof(x)的值和数组中每个元素类型变量的对齐相同

所有Go规范没有准确指定对于任何类型的对齐保证。它只是指定了一些最低需求。

对于相同的编译器,确切的类型对齐保证可能在不同数据结构和在不同的编译器版本中存在差异。对于列举当前版本(1.16)的标准Go编译器,对齐保证列举在这里。

type                      alignment guarantee
------                    ------
bool, uint8, int8         1
uint16, int16             2
uint32, int32             4
float32, complex64        4
arrays                    depend on element types
structs                   depend on field types
other types               size of a native word

这里,原生字(或机器字)的大小在32位架构里是4字节,在64位架构里是8字节

这意味着,对于当前版本的标准Go编译器,其他类型的对齐保证可能是4或者8,这取决于(程序)目标编译架构的不同。这对于gccgo同样适用。

总而言之,在Go的编程过程中,我们不需要关心值地址的对齐,除非我们想要优化内存消耗,或者使用sync/atomic64-bit函数。详情请阅读以下两节。

类型大小和结构填充

Go规范仅仅制定了下面的类型大小保证:

type                    size in bytes
------                  ------
uint8, int8             1
uint16, int16           2
uint32, int32, float32  4
uint64, int64           8
float64, complex64      8
complex128              16
uint, int               implementation-specific,
                        generally 4 on 32-bit
                        architectures, and 8 on
                        64-bit architectures.
uintptr                 implementation-specific,
                        large enough to store
                        the uninterpreted bits
                        of a pointer value.

Go规范没有制定对于其他类型的值大小保证。标准Go编译器确定的其他不同类型大小的完整列表被列入了值复制花费中。

标准Go编译器(包括gccgo)将确保一个类型的值大小是这个类型对齐保证的倍数。

为了满足前面提到的类型对齐保证,Go编译器可能会在struct值的字段之间填充一些字节。这使得struct类型值的大小可能不是简单的所有该类型字段大小的总和。

下面是一个例子,展示了字节如何被填充到struct的字段之间。我们已经了解到:

  • 对齐保证和内置的int8类型的大小都是1字节
  • 对齐保证和内置的int16类型的大小都是2字节
  • 内置的int64类型的大小是同8字节,int64类型的对齐保证在32位架构里是4字节,而在64位架构里是8字节
  • T1类型和T2类型的对齐保证是他们各自字段的最大对齐保证,比如,int64字段的对齐保证。所以他们的对齐保证在32位架构里都是4字节,而在64位架构里是8字节.
  • T1类型和T2类型的大小必须它们各自对齐保证的倍数,比如,在32位架构里是4N,而在64位架构里是8N.
type T1 struct {
	a int8

	// On 64-bit architectures, to make field b
	// 8-byte aligned, 7 bytes need to be padded
	// here. On 32-bit architectures, to make
	// field b 4-byte aligned, 3 bytes need to be
	// padded here.

	b int64
	c int16

	// To make the size of type T1 be a multiple
	// of the alignment guarantee of T1, on 64-bit
	// architectures, 6 bytes need to be padded
	// here, and on 32-bit architectures, 2 bytes
	// need to be padded here.
}
// The size of T1 is 24 (= 1 + 7 + 8 + 2 + 6)
// bytes on 64-bit architectures and is 16
// (= 1 + 3 + 8 + 2 + 2) on 32-bit architectures.

type T2 struct {
	a int8

	// To make field c 2-byte aligned, one byte
	// needs to be padded here on both 64-bit
	// and 32-bit architectures.

	c int16

	// On 64-bit architectures, to make field b
	// 8-byte aligned, 4 bytes need to be padded
	// here. On 32-bit architectures, field b is
	// already 4-byte aligned, so no bytes need
	// to be padded here.

	b int64
}
// The size of T2 is 16 (= 1 + 1 + 2 + 4 + 8)
// bytes on 64-bit architectures, and is 12
// (= 1 + 1 + 2 + 8) on 32-bit architectures.

尽管T1T2拥有相同的字段集合,但它们的大小是不同的。 对于标准Go编译器,一个有趣的事情是,有时候大小为0的字段可能会影响结构填充。详情请在非官方的GO FAQ阅读这个问题.

64位字原子操作的对齐要求

64 位字表示基础类型为 int64 或 uint64 的类型的值。

这篇文章原子操作提到了一个事实,在64位字上的64位原子操作要求64位字的地址必须是8字节对齐。这对于被标准Go编译器支持的64位架构来说不是什么问题,因为64位字在这些64位架构上总是8字节对齐的。

然而,在32位架构上,标准Go编译器制定的对齐保证仅仅是4字节。对非8字节对齐的64位字进行64位原子操作将在运行时发生panic。 更糟糕的是,在非常古老的CPU架构上,64位的原子函数是不被支持的。

sync/atomic文档的最后,提到:

x86-32机器上,64位函数的指令使用Pentium MMX之前不可用的。

在非Linux ARM上,64位函数的指令在ARMv6k内核之前是不可用的。

ARMx86-32机器上,调用者的指责是安排以原子方式访问的64位字使用64位对齐。变量或分配的结构、数组或切片中的第一个字可以依赖于64位对齐。

所以,因为这两个原则,事情还不算非常糟糕。

  1. 特别古老的CPU架构不是当前主流的CPU架构。如果一个程序需要在这些架构上同步64位字,这里还有其他的同步技术去拯救。
  2. 在其他不是特别古老32位架构上,这里有一些方法去确保一些64位字可以依赖于64位对齐。

这些方法被描述为可以依赖于64位对齐的变量或者被分配的structarray或者slice里的第一个(64位)字。分配是什么意思?我们可以把一个被分配的值看作一个声明的变量,一个由内置make函数返回的值,或者一个被内置new方法返回的值的引用。如果一个切片来自于一个分配的数组而且切片的第一个元素也是数组的第一个元素,那么切片的值也被视作一个分配的值。

关于哪些64位字可以依赖于32位架构上的64位对齐的描述有些保守。还有更多的64位字可以依赖于8字节对齐。事实上,如果数字或者切片的第一个元素类型是64位字类型的元素可以依赖于64位对齐,那么该数组/切片的所有元素也可以被原子访问。 对包括所有在32位架构上64位字可以依赖于64位对齐,做一个简单清晰的描述会有一些微妙和冗长的地方,所以官方文档就做了一个保守的描述。

这里有一个示例列举了一些在64位和32位架构都可以安全或不安全被访问的64位字。

type (
	T1 struct {
		v uint64
	}

	T2 struct {
		_ int16
		x T1
		y *T1
	}

	T3 struct {
		_ int16
		x [6]int64
		y *[6]int64
	}
)

var a int64    // a is safe
var b T1       // b.v is safe
var c [6]int64 // c[0] is safe

var d T2 // d.x.v is unsafe
var e T3 // e.x[0] is unsafe

func f() {
	var f int64           // f is safe
	var g = []int64{5: 0} // g[0] is safe

	var h = e.x[:] // h[0] is unsafe

	// Here, d.y.v and e.y[0] are both safe,
	// for *d.y and *e.y are both allocated.
	d.y = new(T1)
	e.y = &[6]int64{}

	_, _, _ = f, g, h
}

// In fact, all elements in c, g and e.y.v are
// safe to be accessed atomically, though Go
// official documentation never makes the guarantees.

如果一个struct类型的64位字的字段(通常指第一个)将在代码中被原子访问,我们应该始终使用分配的struct类型的值去保证在32位架构上,被原子访问的字段总是依赖于8字节对齐。当该struct被用于另一个struct类型的某个字段,我们应该安排该字段作为其他struct类型的第一个字段,并且总是使用其他struct类型的分配值。

有时候,如果我们不能确保一个64位字是否能被原子访问,我们可以使用类型[15]byte的值来确定64位字运行时的地址。例如,

package mylib

import (
	"unsafe"
	"sync/atomic"
)

type Counter struct {
	x [15]byte // instead of "x uint64"
}

func (c *Counter) xAddr() *uint64 {
	// The return must be 8-byte aligned.
	return (*uint64)(unsafe.Pointer(
		(uintptr(unsafe.Pointer(&c.x)) + 7)/8*8))
}

func (c *Counter) Add(delta uint64) {
	p := c.xAddr()
	atomic.AddUint64(p, delta)
}

func (c *Counter) Value() uint64 {
	return atomic.LoadUint64(c.xAddr())
}

通过使用这种解决方案,Counter类型可以自由而且安全的被嵌入到其他用户类型,甚至32位架构中。这个解决方案的缺点是每个Counter类型的值都浪费了7个字节,并且它使用了不安全的指针。sync标准库使用了一个[3]unit32值代替这个解决方案。这个问题假设unit32类型的对齐保证是4字节的倍数。对于标准Go编译器和gccgo编译器来说,这个假设是正确的,然后它在其他第三方Go编译器可能是错误的。

Russ Cox提出64位字的地址应该总是8字节对齐,无论是在64位还是32位架构上,以使Go编程更简单。目前(Go 1.16)这个提议还没有被采纳。