Dig101: dig more, simplified more and know morehtml
通过前边几篇文章,相信你也发现了,struct几乎无处不在。android
string,slice和map底层都用到了struct。git
今天咱们来重点关注下struct的内存对齐,github
理解它,对更好的运用struct和读懂一些源码库的实现会有很大的帮助。golang
在此以前,咱们先明确几个术语,便于后续分析。express
是用于表示其天然的数据单位,也叫machine word
。字是电脑用来一次性处理事务的一个固定长度。c#
一个字的位数(即字长)。数组
现代电脑的字长一般为1六、3二、64位。(通常N位系统的字长是 N/8
字节。)安全
电脑中大多数寄存器的大小是一个字长。CPU和内存之间的数据传送单位也一般是一个字长。还有而内存中用于指明一个存储位置的地址也常常是以字长为单位。session
参见维基百科中 字
简单来讲,操做系统的cpu不是一个字节一个字节访问内存的,是按2,4,8这样的字长来访问的。
因此当处理器从存储器子系统读取数据至寄存器,或者,写寄存器数据到存储器,传送的数据长度一般是字长。
如32位系统访问粒度是4字节(bytes),64位系统的是8字节。
当被访问的数据长度为 n
字节且该数据地址为n
字节对齐,那么操做系统就能够一次定位到数据,这样会更加高效。无需屡次读取、处理对齐运算等额外操做。
咱们先看下基础数据结构的大小定义
如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的变量可能指向同一块地址。
- 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.
对这段描述翻译到对应类型的对齐就是下表
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
提早后,a
和c
总大小4bytes,在填充4bytes使对齐
总大小为 8+8=16
因此,合理重排字段能够减小填充,使struct字段排列更紧密
零大小字段(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))
复制代码
从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的倍数
并且剩余的4bytes能够给sema
字段用,也不浪费内存
但是为何要在32位系统上也要保证一个64位对齐的uint64
指针呢?
答案是,为了保证在32位系统上也能原子访问64位对齐的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位字有:
// GOARCH=386 go run types/struct/struct.go
var c0 int64
fmt.Println("64位字自己:",
atomic.AddInt64(&c0, 1))
复制代码
c1 := [5]int64{}
fmt.Println("64位字数组、切片:",
atomic.AddInt64(&c1[:][0], 1))
复制代码
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))
复制代码
type T struct {
val2 int64
_ int16
}
c3 := struct {
val T
valid bool
}{}
fmt.Println("结构体中首字段为嵌套结构体,且其首元素为64位字:",
atomic.AddInt64(&c3.val.val2, 1))
复制代码
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))
复制代码
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)
复制代码
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
嵌套到别的结构体时,若是不放到结构体首位会有问题, 这会使其使用受限。
总结一下:
即 uintptr(unsafe.Pointer(&x)) % unsafe.Alignof(x) == 0
mutex
)的方式更清晰简单推荐一个工具包:dominikh/go-tools ,里边 structlayout, structlayout-optimize, structlayout-pretty 三个工具比较有意思
本文代码见 NewbMiao/Dig101-Go
See more: Golang 是否有必要内存对齐?
文章首发公众号: newbmiao (欢迎关注,获取及时更新内容)
推荐阅读:Dig101-Go系列