golang不为人知的内存对齐(memory-alignment)

1,451 阅读11分钟

心之所向,身之所往。

1.什么是内存对齐?

a.想象中的内存

首先,思考下我们程序员是怎么看待内存的。是的,没错,就像下面图片展示的。内存就是一个一个的数据,可以用指针数组 char*byte[] 表示;

然而,当程序从磁盘加载到内存时,不同数据类型的变量读取和写入内存,是以2、4、8、16、32个byte的块进行内存访问的,此时我们将访问内存大小的块称为内存访问粒度。

b.现实中的内存

如上图所示,在访问特定类型变量在特定的内存地址访问时,需要把各类数据类型的数据按照上图以一定规则排列在内存空间上,我们把这种称之为内存对齐。

💡 注意,

1.内存对齐是编译器的管控范围

2.这里说的内存对齐,是指该变量首地址对齐,而不是每个变量所占的内存大小对齐

2.为什么需要内存对齐?

为什么需要进行内存对齐,一个一个字节访问不行吗?那么我们先看看非内存对齐是怎么访问CPU的。我们都知道当我们执行一个程序时,是由物理存储器中的代码直接加载到主内存中。然后CPU会通过总线对内存进行寻址操作,将内存中的指令进行译码,发送操作指令给操作控制器进行程序运行和数据的处理。

2.1当处理器处理未内存对齐的内存访问

如下图所示:

这里我们以一次性处理4个字节的处理器为例,每次获取4个字节的CPU只能在第一次得到0~3,因此一次只能获取1/2的数据,故还需第二次获取内存地址,当两次拼接处理后,才能将数据全部读完。

2.2 对齐的内存访问

当使用内存对齐时,类型数据会根据对齐规则在内存中一次性将数据全部读取出来,不需要剔除不需要的字节,利用空间换取时间,提高了CPU的吞吐量。如下图所示:

2.3 内存对齐的优点

1.原子性

几乎所有的现代处理器在进行多个任务同时运算时,都会保证原子操作。为了保证原子操作指令正常执行,CPU每次访问内存都会保持内存对齐。以32位处理器为例,每次访问的内存地址宽度为4个字节。如果以内存未对齐的情况,导致在访问内存时至少需要两次才能读完,而当我们访问的数据需要跨越两页虚拟内存时,就可能驻留在第一页,而不是最后一页,在指令在虚拟内存执行代码交换时生成错误的映射页面,导致其原子操作失效。

2.移植性

特定的硬件平台上只允许在特定的内存地址获取特定类型的数据,反之系统会产生异常情况。

3.性能速度

CPU在访问内存时,是以字长(word size)为单位进行读取数据的。不同位数的CPU,每次读取内存字节大小也不相同。比如:32bit的CPU访问内存的字长是4个字节,而64bit的CPU访问内存的字长是8个字节。在未对齐的情况,CPU两次访问内存的数据,将花费更多的时钟周期来处理其数据运算; 在内存对齐的前提下,CPU总是以字长访问内存,从而CPU一次性地访问内存的数据,减少了访问内存的次数,通过空间换取时间,提高了CPU访问内存的吞吐。

3.Go恰到好处的内存对齐

前面讲了很多理论知识,终归是"纸上得来终觉浅,绝知此事要躬行"。接下来我们会透过分析unsafe源码,分析问题的本质。

3.1 unsafe.Sizeof(x)

func (s *StdSizes) Sizeof(T Type) int64 {
	switch t := optype(T).(type) {
        // 基础类型
	case *Basic:
		assert(isTyped(T))
		k := t.kind
		if int(k) < len(basicSizes) {
			if s := basicSizes[k]; s > 0 {
				return int64(s)
			}
		}
		if k == String {
                        // 字长大小必须大于4个字节(32bits)
			return s.WordSize * 2
		}
        // 数组    
	case *Array:
		n := t.len
		if n <= 0 {
			return 0
		}
		// n > 0
		a := s.Alignof(t.elem)
		z := s.Sizeof(t.elem)
		return align(z, a)*(n-1) + z
        // 切片    
	case *Slice:
		return s.WordSize * 3
        // 结构体    
	case *Struct:
		n := t.NumFields()
		if n == 0 {
			return 0
		}
		offsets := s.Offsetsof(t.fields)
		return offsets[n-1] + s.Sizeof(t.fields[n-1].typ)
	case *_Sum:
		panic("Sizeof unimplemented for type sum")
        // 接口    
	case *Interface:
		return s.WordSize * 2
	}
	return s.WordSize // catch-all
}

// align returns the smallest y >= x such that y % a == 0.
// x <= a 返回a    a = 1 返回x    x > a 
func align(x, a int64) int64 {
	y := x + a - 1
	return y - y%a
}

举个🌰:

package main

import (
    "fmt"
    "unsafe"
)

func main() {
	fmt.Println(unsafe.Sizeof(bool(true))) // 1
	fmt.Println(unsafe.Sizeof(float32(1.12))) // 4
	fmt.Println(unsafe.Sizeof(int8(123))) // 1
	fmt.Println(unsafe.Sizeof(int16(123))) // 2
	fmt.Println(unsafe.Sizeof(int32(123))) // 4
	fmt.Println(unsafe.Sizeof(int64(123))) // 8
	fmt.Println(unsafe.Sizeof(uint(123))) // 8
	fmt.Println(unsafe.Sizeof(uintptr(123))) // 8
	fmt.Println(unsafe.Sizeof(int(1))) // 8
	fmt.Println(unsafe.Sizeof(printSize())) // 8
	fmt.Println(unsafe.Sizeof(string("sakura"))) // 16
	fmt.Println(unsafe.Sizeof([]string{"sakura"})) // 24
}

func printSize() int {
	return 1
}

✅ 通过上面的例子,我们可以总结下各种类型所占字节数

类型内存所在字节大小(64bit)
bool/uint8/int81
int16/uint162
int32/uint324
float324
int64/uint648
float64/complex648
uint/uintptr/int8【4(32bit)】
func8
string16
[]T24

3.2 unsafe.Alignof(x)

func (s *StdSizes) Alignof(T Type) int64 {
	
        // 对于数组和结构体而言,对齐是由其中的元素和属性内存对齐定义的
	switch t := optype(T).(type) {
	case *Array:
                // 在数组中x变量,unsafe.Alignof(x)=unsafe.Alignof(x[0]) 最小对齐为1
		return s.Alignof(t.elem)
	case *Struct:
                // 在切片中变量x 取unsafe.Alignof(x.f)的最大值,而x.f最小为1
		max := int64(1)
		for _, f := range t.fields {
			if a := s.Alignof(f.typ); a > max {
				max = a
			}
		}
		return max
	case *Slice, *Interface:
                // 多字数据结构等效于结构体,其中每个元素都有字长大小
		return s.WordSize
	case *Basic:
		// Strings 和切片、interface接口相同
		if t.Info()&IsString != 0 {
			return s.WordSize
		}
	}
	a := s.Sizeof(T) // may be 0
        // 对于任意类型的变量x, unsafe.Alignof(x)至少为1
	if a < 1 {
		return 1
	}
	// complex{64,128} are aligned like [2]float{32,64}.
	if isComplex(T) {
		a /= 2
	}
	if a > s.MaxAlign {
		return s.MaxAlign
	}
	return a
}

举个🌰:

func main() {
    s := []string{"123"}
	s1 := "123"
	s2 := []string{"1", "2", "3"}
	fmt.Println(unsafe.Alignof(s))  // 8
	fmt.Println(unsafe.Alignof(s1)) // 8
	fmt.Println(unsafe.Alignof(s2)) // 8
	fmt.Println("-------------------------")
	fmt.Println(unsafe.Alignof(int8(1))) // 1
	fmt.Println(unsafe.Alignof(int16(1))) // 2
	fmt.Println(unsafe.Alignof(int32(1))) // 4
	fmt.Println(unsafe.Alignof(int64(1))) // 8
	fmt.Println(unsafe.Alignof(int(1))) // 8
	fmt.Println("-------------------------")
	fmt.Println(unsafe.Alignof(uint8(1))) // 1
	fmt.Println(unsafe.Alignof(uint16(1))) // 2
	fmt.Println(unsafe.Alignof(uint32(1))) // 4
	fmt.Println(unsafe.Alignof(uint64(1))) // 8
	fmt.Println(unsafe.Alignof(uint(1))) // 8
}

3.3 内存对齐系数

由 sync/atomic 标准库文档可以得到:

- 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 both ARM and x86-32, 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.

一个64位字的原子操作要求此64位字的地址必须是8字节对齐的。 这对于标准编译器目前支持的64位架构来说并不是一个问题,因为标准编译器保证任何一个64位字的地址在64位架构上都是8字节对齐的。

然而,在32位架构上,标准编译器为64位字做出的地址对齐保证仅为4个字节。 对一个不是8字节对齐的64位字进行64位原子操作将在运行时刻产生一个恐慌。 更糟的是,一些非常老旧的架构并不支持64位原子操作需要的基本指令。

3.4 对齐规则

3.4.1 成员对齐规则

结构体(struct)的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员的地址必须是它本身大小或对齐参数两者中较小的一个的倍数。

一个合格的Go编译器必须保证:

  1. 对于任何类型的变量x,unsafe.Alignof(x) 的结果最小为1。
  2. 对于一个结构体类型的变量x,unsafe.Alignof(x) 的结果为x的所有字段的对齐保证unsafe.Alignof(x.f)中的最大值(但是最小为1)。
  3. 对于一个数组类型的变量x,unsafe.Alignof(x) 的结果和此数组的元素类型的一个变量的对齐保证相等。

详细可见3.2 unsafe.Alignof()  对齐系数函数

3.4.2 整体对齐规则

在数据成员完成各自对齐之后,结构体本身也要进行对齐,整体长度必须是对齐参数和结构体最长的元素长度中较小的一个的倍数。

举个🌰:

func main() {
    a := Animal{123, 123, 18}
    
    fmt.Println("the first offset:", unsafe.Offsetof(a.id))
    fmt.Println("the first size:", unsafe.Sizeof(a.id))
    
    fmt.Println("the second offset:", unsafe.Offsetof(a.num))
    fmt.Println("the second size:", unsafe.Sizeof(a.num))
    
    fmt.Println("the third offset:", unsafe.Offsetof(a.age))
    fmt.Println("the third size:", unsafe.Sizeof(a.age))
    fmt.Println("the total size:", unsafe.Sizeof(a))
}

type Animal struct {
	id uint16
	num uint64
	age uint16
}

console:
the first offset: 0
the first size: 2
the second offset: 8
the second size: 8
the third offset: 16
the third size: 2
the total size: 24

通过unsafe.Sizeof(x) 、unsafe.Offsetof(x),按照顺序我们可以得到各变量占用大小分别为:2,8,2,但在第2个变量num 计算其偏移量为8,由于num的内存首地址不能被8整除,故需要在其之前保留6个字节;因此第3个变量偏移量为16,当前结构体计算总和为18;由于内存对齐需要保证为该结构体最大系数整数倍,在最后我们还需要保留6个字节,即最终内存占用大小为24个字节。

3.5 struct内存对齐

下面我们可以通过一个例子来了解struct是如何内存对齐的。

import (
	"fmt"
	"unsafe"
)

func main() {
	a := Animal{123, 123, 18}
	fmt.Println("the total size:", unsafe.Sizeof(a))
	fmt.Println("----------- the optimized order ---------")
	d := Dog{123, 123, 18}
	fmt.Println(unsafe.Sizeof(d))
	fmt.Println("id's size:", unsafe.Sizeof(d.id))
	fmt.Println("age's offset:", unsafe.Offsetof(d.age))
	fmt.Println("age size:", unsafe.Sizeof(d.age))
	fmt.Println("num's offset:", unsafe.Offsetof(d.num))
	fmt.Println("num's size:", unsafe.Sizeof(d.num))
}

type Animal struct {
	id uint16
	num uint64
	age uint16
}

type Dog struct {
	id uint16
	age uint16
	num uint64
}

// console:
the total size: 24
----------- the optimized order ---------
16
id's size: 2
age's offset: 2
age size: 2
num's offset: 8
num's size: 8

同样使用3.4.2的🌰,我们可以很清晰知道struct内存对齐规则同整体对齐规则一致。但由这个例子我们可以容易看到当改变struct内的属性顺序,可以减少对内存的占用。

由上面优化后的例子我们做个整体分析,这里所有的程序均是由64位编译器运行生成的结果:

1.属性id 数据类型为uint16,占用2个字节,从第0个位置开始占用2个字节

2.通过unsafe.Offsetof(x),我们可以很容易知道属性age是从第2个位置开始占用2个字节,到现在共占用了4个字节

3.但是当打印属性num的偏移量时,其偏移值为8,而num的内存对齐系数为8,内存对齐需要保证当前变量首地址可以被8整除,故需要在前面占用4个空白字节,从第8位开始占用8个字节

4.结构体当前所有的成员保证内存对齐,我们还需要根据整体对齐规则,结构体内变量最大占用内存大小,保证是它的整数倍即可,当前占用的总大小为16,满足被8整除的要求,因此最终该结构体占用16个字节。

这是因为每个属性都有自己的内存对齐系数,当其偏移量不能满足整体内存对齐的系数时,会留出对应空白空间来满足内存对齐,因此一个合理的布局顺序对于内存资源紧张的程序设计至关重要

3.6 空struct的内存对齐

3.6.1 空struct占用内存空间吗?

func main() {

    fmt.Println("size:", unsafe.Sizeof(name{}))
}

type name struct {

}

console:
------------------------------------------------------------
GOROOT=C:\Users\admin\scoop\apps\go\current #gosetup
GOPATH=C:\Go\gopath #gosetup
C:\Users\admin\scoop\apps\go\current\bin\go.exe build -o C:\Users\admin\AppData\Local\Temp\GoLand___1go_build_MemoryOfffset_go.exe 
D:\project_code\sakura\unit_go\src\code.local\string\MemoryOfffset.go #gosetup
C:\Users\admin\AppData\Local\Temp\GoLand___1go_build_MemoryOfffset_go.exe #gosetup
0

Process finished with the exit code 0
------------------------------------------------------------

事实证明空结构体struct{} 实例不占用任何内存空间,然而空结构体有什么作用呢?

3.6.2 空结构作用

由于其没有内存空间大小,首先就是解决内存资源,其次由于结构体其强大的语义,可以作为占位符来使用。举个例子:

type User struct {}

func (u User) walk() {
    fmt.Println("walk on the park.")
}

空struct{} 模拟Set集合

package main

import (
	"fmt"
)


type Set map[string]struct{}

// Contains return bool when contains the element.
func (s Set) Contains(key string) bool {
	_, flg := s[key]
	return flg
}

func (s Set) Add(key string) {
	s[key] = struct{}{}
}

func (s Set) Size() int {
	 return len(s)
}

func (s Set) Delete(key string) {
	delete(s, key)
}



func main() {
	s := make(Set)
	s.Add("aaaa")
	s.Add("bbbb")
	fmt.Println(s.Size())
	fmt.Println(s.Contains("aaaa"))
	fmt.Println(s.Contains("cccc"))
        // Go语言中的并行垃圾回收效率比写一个清空函数要高效的多
        // 清空 map 的唯一办法就是重新 make 一个新的 map
        s = make(Set)
	fmt.Println(s.Contains("bbbb"))
	fmt.Println(s.Size())
}

3.6.3 空结构体内存对齐规则

type Dog struct {
	feature struct{}
	age int16
	name string
}

type Cat struct {
	age int16
	feature struct{}
	name string
}

type Pig struct {
	age int16
	name string
	feature struct{}
}

type Duck struct {
	name string
	age int16
	feature struct{}
}

func main() {
	fmt.Println(unsafe.Sizeof(Dog{}))  // 24=16 + (2 + 6)
	fmt.Println(unsafe.Sizeof(Cat{}))  // 24=(2 + 6) + 16
	fmt.Println(unsafe.Sizeof(Pig{}))  // 32=(2 + 6) + 16 + 8
	fmt.Println(unsafe.Sizeof(Duck{})) // 32=16 + (2 + 2 + 4)
}

空结构体变量在结构体最后位置时,就需要内存对齐,占用的大小与上一个变量内存大小一样即可,对于整体内存大小结果不是返回的整数倍时,需要对应填充空白字节,直到被整除,规则同整体对齐规则即可。