本文将介绍Go语言中的类型对齐和大小保证。了解这些保证对于估算结构体类型的大小,以及正确使用`sync/atomic`标准包中的64位函数至关重要。
Go是一种C族语言,所以本文讨论的许多概念与C语言是共通的。
Go中的类型对齐保证
为了充分利用CPU指令并获得最佳性能,为特定类型的值分配的内存块的(起始)地址必须按某个整数N的倍数对齐,那么N就称为该类型的值地址对齐保证,或简称为该类型的对齐保证。我们也可以说,该类型可寻址值的地址保证是按N字节对齐的。
实际上,每种类型都有两种对齐保证,一种是当它用作其他(结构体)类型的字段类型时的对齐保证,另一种是用于其他情况(当它用于变量声明、数组元素类型等时)的对齐保证。我们将前者称为该类型的字段对齐保证,将后者称为该类型的一般对齐保证。
对于类型T,我们可以调用`unsafe.Alignof(t)`来获取其一般对齐保证,其中t是类型T的非字段值;调用`unsafe.Alignof(x.t)`来获取其字段对齐保证,其中x是结构体值,t是类型T的字段值。
对`unsafe`标准代码包中函数的调用总是在编译时求值。
在运行时,对于类型T的值t,我们可以调用`reflect.TypeOf(t).Align()`来获取类型T的一般对齐保证,调用`reflect.TypeOf(t).FieldAlign()`来获取类型T的字段对齐保证。
对于当前的标准Go编译器(v1.25.n),一种类型的字段对齐保证和一般对齐保证始终相等。对于gccgo编译器,该说法不成立。
Go规范仅略微提及类型对齐保证:
以下是保证的最小对齐属性:
对于任何类型的变量`x`:`unsafe.Alignof(x)`至少为1。
对于结构体类型的变量`x`:`unsafe.Alignof(x)`是`x`的每个字段`f`的所有`unsafe.Alignof(x.f)`值中的最大值,但至少为1。
对于数组类型的变量`x`:`unsafe.Alignof(x)`与数组元素类型变量的对齐方式相同。
所以Go规范并未为任何类型指定确切的对齐保证,只是规定了一些最低要求。
对于同一编译器,确切的类型对齐保证在不同架构之间以及不同编译器版本之间可能会有所不同。对于当前版本(v1.25.n)的标准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/atomic`包中64位函数的可移植程序。有关详细信息,请阅读以下两个部分。
类型大小与结构体填充
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编译器可能会在结构体值的字段之间填充一些字节。这使得结构体类型的值大小可能并非该类型所有字段大小的简单总和。
以下是一个示例,展示结构体字段之间是如何填充字节的。我们已经了解到
-
内置类型 `int8` 的对齐保证和大小均为 1 字节。
-
内置类型 `int16` 的对齐保证和大小均为 2 字节。
-
内置类型 `int64` 的大小为 8 字节,在 32 位架构上,`int64` 类型的对齐保证为 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.
尽管T1和T2具有相同的字段集,但它们的大小不同。
对于标准Go编译器而言,一个有趣的现象是,有时大小为零的字段可能会影响结构体填充。详情请阅读非官方Go常见问题解答中的这个问题。
64位字原子操作的对齐要求
64位字指的是其底层类型为int64或uint64的类型的值。
《原子操作》这篇文章提到一个事实,对64位字进行64位原子操作要求该64位字的地址必须是8字节对齐的。对于标准Go编译器所支持的当前64位架构来说,这并不是问题,因为在这些64位架构上,64位字始终是8字节对齐的。
然而,在32位架构上,标准Go编译器对64位字做出的对齐保证仅为4字节。对未8字节对齐的64位字进行64位原子操作会在运行时引发恐慌。更糟糕的是,在非常古老的CPU架构上,不支持64位原子函数。
在sync/atomic文档的末尾,提到:
在x86 - 32架构上,64位函数所使用的指令在奔腾MMX之前的处理器中不可用。
在非Linux的ARM架构上,64位函数所使用的指令在ARMv6k内核之前的处理器中不可用。
在ARM和x86 - 32架构上,调用者有责任确保以原子方式访问的64位字是64位对齐的。变量、已分配的结构体、数组或切片中的第一个字可确保为64位对齐。
所以,情况并没有那么糟糕,原因有两个:
- 那些非常古老的CPU架构如今已非主流架构。如果一个程序需要在这些架构上对64位字进行同步操作,还有其他同步技术可以救急。
- 在其他没那么古老的32位架构上,有一些方法可以确保某些64位字是64位对齐的。
这里提到的“allocated”(已分配的)是什么意思呢?我们可以把一个已分配的值理解为一个声明的变量、由内置的`make`函数返回的值,或者由内置的`new`函数返回的值所引用的值。如果一个切片值源自一个已分配的数组,并且该切片的第一个元素是数组的第一个元素,那么这个切片值也可以被视为一个已分配的值。
关于在32位架构上哪些64位字可以确保是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.
如果结构体类型的一个64位字段(通常是第一个)在代码中要进行原子访问,我们应该始终使用该结构体类型的已分配值,以确保在32位架构上,被原子访问的字段始终能保证是8字节对齐的。当这个结构体类型用作另一个结构体类型的字段类型时,我们应该将该字段安排为另一个结构体类型的第一个字段,并且始终使用另一个结构体类型的已分配值。
有时,如果我们无法确定一个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())
}
通过使用这种解决方案,即使在32位架构上,`Counter`类型也能自由且安全地嵌入到其他用户类型中。该解决方案的缺点是,每个`Counter`类型的值会浪费7个字节,并且它使用了不安全指针。
Go 1.19引入了一种更优雅的方式来确保某些值的8字节对齐。Go 1.19在`sync/atomic`标准包中添加了几个原子类型。这些类型包括`atomic.Int64`和`atomic.Uint64`,其值即使在32位架构上也保证是8字节对齐的。我们可以利用这一特性,使某些64位字在32位架构上始终保持8字节对齐。例如,在以下代码中,无论在64位还是32位架构上,`T`类型的任何值的`x`字段在任何情况下都始终是8字节对齐的。
type T struct {
_ [0]atomic.Int64
x int64
}