谈到内存对齐,早年间玩Java的时候就能偶尔打打交道,为此Java8还提供了个语法糖@Contended
来帮助我们解决高速缓存cacheline内存未对齐的伪共享问题。不过Go目前涉及到类似问题,比如内存对齐带来的原子操作的问题还是需要手动处理下,毕竟Russ Cox
大佬也发话了
On both ARM and x86-32, it is the caller's responsibility to arrange for 64-bit alignment of 64-bit words accessed atomically.
不过有些语言倒是会自动帮开发人员解决对齐问题,比如Rust
最近还比较火,Microsoft也宣布将逐渐从C/C++转移到Rust
构建他的基础结构软件。嗯。。。今年也立个flag 学习下。
什么是内存对齐?
我理解的内存对齐,大体分为三类
- 基本类型对齐,内存地址对齐。不同架构CPU平台上基本类型的对齐系数可能会不同
- CPU和内存之间的高速缓存行cacheline对齐,也就是伪共享问题 如果你还不了解 那你一定没有读过之前那篇《手摸手Go 深入剖析sync.Pool》
- 操作系统层面,出于网络或磁盘操作考虑时的页大小的对齐
如有异议 欢迎diss,今天我们主要聊第一个方面的内存对齐,先来看看维基百科的定义
A memory address a is said to be n-byte aligned when a is a multiple of n bytes (where n is a power of 2). In this context, a byte is the smallest unit of memory access, i.e. each memory address specifies a different byte. An n-byte aligned address would have a minimum of log2(n) least-significant zeros when expressed in binary. 来自维基百科
根据维基百科的定义,内存对齐,是代码编译后在内存的布局和使用方式。当一个内存地址a是n字节的倍数(其中n是2的幂)时,内存地址a被称为n字节对齐。在这种情况下,字节是存储器访问的最小单元,即每个存储器地址指定一个不同的字节。当使用二进制表示时,一个n字节对齐的地址将具有最少log2(n)个最低位有效零。
为什么要内存对齐?
现代CPU对基本类型的合法地址做了一些限制,而且并不是一个字节一个字节得读取和写入内存的,而是以字(word)
为单位 ,通常是2、4、8个字来批量访问。比如64位CPU,字长为8字节,那么CPU访问的字长也是8字节。因为CPU始终都是根据字长来访问内存,如果不进行内存对齐,那么很可能增加CPU访问内存的次数。
除了上面提到的CPU访问数据性能问题外,当然网上很多都说还有一个原因“特定的硬件平台只允许在特定地址获取特定类型的数据,否则会导致异常情况” 不过这种情况我是没遇到过。暂时忽略吧
基于这些原因所以Go编译器也使用了内存对齐。
那么掌握内存对齐的原则又啥好处呢?以64位系统为例,看个栗子
type Type1 struct {
a int8
b int64
c int32
}
a := align.Type1{}
fmt.Printf("size of align.Type1 is %d",unsafe.Sizeof(a))
在64位平台上,你觉得输出会是多少呢?有人可能会说,这多简单1+8+4=13byte。但是。
size of align.Type1 is 24
为啥呢?我们看看它的内存布局:
但是一旦你掌握内存对齐技巧,你会这么写代码
type Type1 struct {
a int8
c int32
b int64
}
a := align.Type1{}
fmt.Printf("size of align.Type1 is %d",unsafe.Sizeof(a))
执行结果
size of align.Type1 is 16
显然这种方式能节约33%的空间。
另一方面,内存对齐对于实现原子操作也是很有好处的。如果一个数据的大小不超过平台CPU访问内存的字长,那么这个数据可以被一次读取,这样其访问自然也就是原子的了。
内存对齐的技巧
Go内存地址对齐
正如Go编程语言规范
中描述,计算机体系结构可能需要内存地址对齐;也就是说,如果变量的地址是一个因子的倍数,则变量的类型就是对齐的。函数Alignof
返回值表示了任何类型的对齐方式。(https://golang.org/ref/spec#Size_and_alignment_guarantees)
Go内存对齐和大小保证
Go
的unsafe
包为我们提供了两个比较有用的方法。
// 返回类型实例占用的字节数
func Sizeof(x ArbitraryType) uintptr
// 返回指定类型的对齐系数
func Alignof(x ArbitraryType) uintptr
unsafe.Sizeof
返回类型实例的字节数很好理解。unsafe.Alignof
返回类型的对齐系数,具体规则Go官方给出了定义:
For a variable
x
of any type:unsafe.Alignof(x)
is at least 1.对于任何变量x,unsafe.Alignof(x)的最小值为1
For a variable
x
of struct type:unsafe.Alignof(x)
is the largest of all the valuesunsafe.Alignof(x.f)
for each fieldf
ofx
, but at least 1.对于结构体变量x,unsafe.Alignof(x)等于这个结构体所有字段unsafe.Alignof(x.f)的对齐系数的最大值,但是最小为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.对于数组类型的变量x,unsafe.Alignof(x)等于数组元素类型的变量的对齐系数。
翻译过来就是下面的表格
type | alignment guarantee |
---|---|
bool,byte,uint8,int8 | 1 |
uint16,int16 | 2 |
uint32,int32 | 4 |
Float32,complex64 | 4 |
array | 由数组元素类型的变量的对齐系数决定 |
struct | 结构体所有字段的对齐系数的最大值决定 |
Other type | 一个machine word大小,32位系统4字节 64位系统8字节 |
Go对一些基本类型做了大小也做了保证
type | size in bytes |
---|---|
byte,uint8,int8 | 1 |
uint16,int16 | 2 |
uint32,int32,float32 | 4 |
uint64,int64,float64,complex64 | 8 |
Complex128 | 16 |
但是翻了翻Go官方编程语言规范,感觉关于Go内存对齐规则感觉描述的还是不够详尽。根据测试Go跟C的对齐规则挺一致的,都遵循
- 数据成员对齐规则: 结构体的数据成员,第一个字段放在offset为0的地方,之后的字段的起始地址都必须是默认对齐系数和该类型成员长度中最小的值的倍数
- 结构体本身也需要对齐:结构体本身的长度必须是默认的对齐系数和结构体成员中最大对齐系数的最小值的倍数
如有异议 欢迎赐教 交流。
保证原子操作的可移植性
我们在sync.WaitGroup
中可能会看到这么一段代码。
type WaitGroup struct {
noCopy noCopy
state1 [3]uint32
}
func (wg *WaitGroup) state() (statep *uint64, semap *uint32) {
if uintptr(unsafe.Pointer(&wg.state1))%8 == 0 {
return (*uint64)(unsafe.Pointer(&wg.state1)), &wg.state1[2]
} else {
return (*uint64)(unsafe.Pointer(&wg.state1[1])), &wg.state1[0]
}
}
其实就是内存对齐的一个最佳实践,通过uintptr(unsafe.Pointer(&wg.state1))%8
判断当前地址是否8字节对齐,因为目前主要是4字节和8字节对齐
- 如果8字节已经对齐,这直接用前8字节空间来操作64位数;
- 如果非8字节对齐,则用前4字节填充,保证后面8字节首地址是8字节对齐的。
无图无真相
假如sync.WaitGroup
内嵌到其他结构体 且首位出场
type M struct {
wg sync.WaitGroup
}
如果不巧的是,sync.WaitGroup
内嵌在结构体时,不是第一位出道
type N struct {
n int8
wg sync.WaitGroup
}
也不用担心。
所以sync.WaitGroup
通过这种动态调整64位数据储存位置,保证了其原子操作在不同架构系统上有较好的兼容性。
零大小字段对齐
如果结构体或数组类型不包含大小大于零的字段或元素,那么它的大小就为0。
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.
比如x [0]int8
,空结构体struct{}
。当它作为字段时不需要对齐,但是作为结构体最后一个字段时需要对齐。我们拿空结构体来举个例子
package main
import (
"fmt"
"unsafe"
)
type M struct {
m int64
x struct{}
}
type N struct {
x struct{}
n int64
}
func main() {
m := M{}
n := N{}
fmt.Printf("as final field size:%d\nnot as final field size:%d\n", unsafe.Sizeof(m), unsafe.Sizeof(n))
}
输出
as final field size:16
not as final field size:8
上面输出可能看着有点儿迷糊,那么上图
[图片上传失败...(image-b9e001-1615042229523)]看图说话,很显然:大小为0的空结构体 内嵌在其他结构体的一个字段位置,是不会占空间的。但是。。。如果空结构体内嵌到其他结构体的末尾时,它的大小变成1了,以64位系统为例,它会有7个字节的padding。为何???
其实想想也好理解,当空结构体放到内嵌结构体的最后一位,我们如果不给它分配内存,那么这个空结构体就指向了一个非法的地址,就像是C/C++中的野指针,Go应该是为了避免这种情况而特殊处理的。
我们在分析sync
包中的代码时经常看到Go未了防止对象使用后被拷贝,会在结构体内嵌noCopy
。比如sync.Cond
中
type Cond struct {
noCopy noCopy
// L is held while observing or changing the condition
L Locker
notify notifyList
checker copyChecker
}
type noCopy struct{}
这个noCopy
就是个大小零的空结构体,我们发现它一般都放在结构体的第一个字段位置,我想也是有这个原因。
原子操作问题
对于64位数据,拿uint64
来说,正常如果在64位系统上,因为是按照8字节对齐,字长正好跟8字节相等,所以CPU可以一次完成原子操作。但是32位系统,4字节对齐,字长也是4字节,所以64位数据uint64
可能被分配在两块数据块中,故而需要两次才能完成操作,两次操作过程中如果有其他操作修改,显示就无法保证原子性。这种访问方式也是不安全的。issues-6404(https://github.com/golang/go/issues/6404)有相关的讨论
在atomic
包中有下面一段描述
On 386, 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, 386, 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.
意思是说:在ARM,386,和32位MIPS,调用者有责任安排原子访问的64位字按照8字节对齐,否则程序会panic
如何保证?
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返回的引用的值, 如果一个切片是从一个开辟的数组派生出来的并且此切片和此数组共享第一个元素,则我们也可以将此切片看作是一个开辟的值。
对齐检测工具
fieldalignment
//安装
export GOPROXY=https://mirrors.aliyun.com/goproxy/
go get -u golang.org/x/tools/...
//使用
fieldalignment {package}
例如
fieldalignment align.go
输出:
~/workspace/workspace_github/go-snippets/align/align.go:8:8: struct of size 16 could be 8
structlayout
go get -u honnef.co/go/tools
//显示结构体布局
go install honnef.co/go/tools/cmd/structlayout@latest
//重新设计struct字段 减少填充的数量
go install honnef.co/go/tools/cmd/structlayout-optimize@latest
// 用ASCII格式输出
go install honnef.co/go/tools/cmd/structlayout-pretty@latest
//第三方可视化
go install github.com/ajstarks/svgo/structlayout-svg@latest
上面零大小字段对齐的栗子中 可视化图即是我们使用structlayout
和structlayout-svg
完成的
//struct M
structlayout -json file=~/mywork/workspace/workspace_github/go-snippets/align/align.go M | structlayout-svg -t "align.M" > m.svg
//struct N
structlayout -json file=~/mywork/workspace/workspace_github/go-snippets/align/align.go N | structlayout-svg -t "align.N" > n.svg
总结
- 内存对齐主要是CPU可以更高效的访问内存中的数据。
- 掌握内存对齐规则你明白结构体字段如何布置可以让内存更合理
- Go的对齐保证 如果类型T的对齐系数为n,则类型T的地址必须是n的倍数,n为2的幂
- 注意零大小字段避免放到结构体的最后,以防内存浪费。
- 32位系统上需要注意保证64位字原子访问时保证8字节对齐。如果你不想考虑内存对齐问题,我觉得使用
sync.Mutex
来修改数据保证原子性也未尝不可。
有疑问加站长微信联系(非本文作者)