内容简介:推荐阅读原创不易,欢迎转发、点击再看。
Dig101: dig more, simplified more and know more
经过前边几篇文章,相信你也发现了,struct 几乎无处不在。
string,slice 和 map 底层都用到了 struct。
今天我们来重点关注下 struct 的内存对齐,
理解它,对更好的运用 struct 和读懂一些源码库的实现会有很大的帮助。
文章目录
-
0x01 为什么要对齐
-
0x02 数据结构对齐
-
大小保证(size guarantee)
-
对齐保证(align guarantee)
-
0x03 零大小字段对齐
-
0x04 内存地址对齐
-
0x05 64 位字安全访问保证
-
为什么要保证
-
怎么保证
-
改为加锁
在此之前,我们先明确几个术语,便于后续分析 (参见维基百科 - 字)。
-
字(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 [1] 所示:
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 [2]
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 字段排列更紧密
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 包规范 [3] 中,有如下说明:
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位对齐ifuintptr(unsafe.Pointer(&wg.state1))%8 == 0 { // 前8bytes做uint64指针statep,后4bytes做semareturn (*uint64)(unsafe.Pointer(&wg.state1)), &wg.state1[2] } else { // 后8bytes做uint64指针statep,前4bytes做semareturn (*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 [4] 中提到:
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 [5] 中也有提到:
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.govar 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))
改为加锁
是不是有些复杂,要在 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 [6] ,里边 structlayout, structlayout-optimize, structlayout-pretty 三个工具比较有意思
本文代码见 NewbMiao/Dig101-Go [7]
See More: Golang 是否有必要内存对齐? [8]
参考资料
size and alignment guarantees: https://golang.org/ref/spec#Size_and_alignment_guarantees
go101-memory layout: https://go101.org/article/memory-layout.html
unsafe 包规范: https://golang.org/ref/spec#Package_unsafe
atomic-bug: https://golang.org/pkg/sync/atomic/#pkg-note-BUG
issue-6404: https://github.com/golang/go/issues/6404#issuecomment-66085602
dominikh/go-tools: https://github.com/dominikh/go-tools
NewbMiao/Dig101-Go: https://github.com/NewbMiao/Dig101-Go/blob/master/types/struct/struct.go
Golang 是否有必要内存对齐?: https://ms2008.github.io/2019/08/01/golang-memory-alignment/
推荐阅读
原创不易,欢迎转发、点击再看。
微信内外链不能跳转,戳 阅读原文 查看原文中参考资料
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:- Dig101:Go 之聊聊 struct 的内存对齐
- Golang 内存对齐问题
- 你的内存对齐了吗
- CSS之文本两端对齐
- echarts dataView数据对齐
- LWN: kmalloc( )确保对齐
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
勇敢新世界‧互聯網罪與罰
許煜、劉細良 / CUP / 2005 / $48
我天天上網數小時,為的是要在節目裡面介紹世界的最新動態,尤其是網絡這個世界本身日新月異的變化。所以我不可能不注意到BT、共享軟件、 Wikipedia、網絡監管等各種影響政治、社會、經濟及文化的重要網絡現象。但是我發現市面上一直沒有一本內容充實全面,資料切時的中文參考書,直到這本《互聯網罪與罰》。而且,最大的驚喜是它易讀好看,簡直就像故事書。 梁文道 鳳凰衛視 《網羅天下......一起来看看 《勇敢新世界‧互聯網罪與罰》 这本书的介绍吧!