内存模型

一、场景

如何保证在一个goroutine中看到另一个goroutine修改的变量的值,如果程序中修改数据时,有其他goroutine同时读取,那么必须将读取串行化。

Happen-Before

在一个goroutine中,读和写时按照一定顺序执行的,由于goroutine的重排,他的执行顺序可能是不确定的。

Memory Reordering

为了提高读写效率,编译器会对读写指令进行重新排列,这就是所谓得内存重排,英文为MemoryReordering。

二、内存分配原理

1. 堆和栈的定义

Go有两个地方分配内存:

  • 全局堆:用来动态分配内存。
  • goroutine栈:每个goroutine都有自身的栈空间。

其中,栈的内存一般由编译器自动进行分配和释放,其中存储了函数的入参和局部变量,这些参数会随着函数的创建而创建,会随着函数的返回而销毁。

为什么会有堆这个概念呢?因为goroutine栈空间是隔离的,无法访问到别人的栈空间。

堆内存一般由编译器和工程师自己共同管理分配,交给runtime gc释放,堆上分配必须找到一个足够大的内存来分配变量数据,后续释放时,垃圾回收器扫描堆空间寻找不再被使用的对象。

变量存在堆还是栈?

变量存在堆还是栈跟语义无关,由编译器决定是在堆上还是在栈上。

2. 内存逃逸

什么是逃逸?

变量的作用域超过了他所在的栈。

作用:减少GC压力,随着函数退出直接回收;减少内存碎片的产生;减轻分配堆内存的开销,提高运行速度。

内存逃逸分析

Go语言的逃逸分析总共实现了两个版本:

  • 1.13版本前是第一版
  • 1.13版本后是第二版

粗略看了一下逃逸分析的代码,大概有1500+行(go1.15.7)。代码我倒是没仔细看,注释我倒是仔细看了一遍,注释写的还是很详细的,代码路径:src/cmd/compile/internal/gc/escape.go,大家可以自己看一遍注释,其逃逸分析原理如下:

  • pointers to stack objects cannot be stored in the heap:指向栈对象的指针不能存储在堆中
  • pointers to a stack object cannot outlive that object:指向栈对象的指针不能超过该对象的存活期,也就说指针不能在栈对象被销毁后依旧存活。(例子:声明的函数返回并销毁了对象的栈帧,或者它在循环迭代中被重复用于逻辑上不同的变量)

我们大概知道它的分析准则是什么就好了,具体逃逸分析是怎么做的,感兴趣的同学可以根据源码自行研究。

既然逃逸分析是在编译阶段进行的,那我们就可以通过go build -gcflags '-m -l'命令查看到逃逸分析的结果,我们之前在分析内联优化时使用的-gcflags '-m -m',能看到所有的编译器优化,这里使用-l禁用掉内联优化,只关注逃逸优化就好了。

现在我们也知道了逃逸分析,接下来我们就看几个逃逸分析的例子。

分析方法

  1. 压测
  2. 使用pprof确定是那一个方法内存消耗高,另外可以确定分配了几个内存出去
  3. 使用go build --gcflag -m -l 来确定是哪一个内存逃逸
  4. 如果有内存逃逸的现象,会提示escape to heap

2.1 函数返回局部指针变量

先看例子:

1
2
3
4
5
6
7
8
9
func Add(x,y int) *int {
res := 0
res = x + y
return &res
}

func main() {
Add(1,2)
}

查看逃逸分析结果:

1
2
3
4
5
6
go build -gcflags="-m -l" ./test1.go
# command-line-arguments
./test1.go:6:9: &res escapes to heap
./test1.go:6:9: from ~r2 (return) at ./test1.go:6:2
./test1.go:4:2: moved to heap: res
复制代码

分析结果很明了,函数返回的局部变量是一个指针变量,当函数Add执行结束后,对应的栈桢就会被销毁,但是引用已经返回到函数之外,如果我们在外部解引用地址,就会导致程序访问非法内存,就像上面的C语言的例子一样,所以编译器经过逃逸分析后将其在堆上分配内存。

2.2 interface类型逃逸

先看一个例子:

1
2
3
4
func main()  {
str := "asong太帅了吧"
fmt.Printf("%v",str)
}

查看逃逸分析结果:

1
2
3
4
5
6
7
go build -gcflags="-m -m -l" ./test2.go 
# command-line-arguments
./test2.go:9:13: str escapes to heap
./test2.go:9:13: from ... argument (arg to ...) at ./test2.go:9:13
./test2.go:9:13: from *(... argument) (indirection) at ./test2.go:9:13
./test2.go:9:13: from ... argument (passed to call[argument content escapes]) at ./test2.go:9:13
./test2.go:9:13: main ... argument does not escape

strmain函数中的一个局部变量,传递给fmt.Println()函数后发生了逃逸,这是因为fmt.Println()函数的入参是一个interface{}类型,如果函数参数为interface{},那么在编译期间就很难确定其参数的具体类型,也会发送逃逸。

观察这个分析结果,我们可以看到没有moved to heap: str,这也就是说明str变量并没有在堆上进行分配,只是它存储的值逃逸到堆上了,也就说任何被str引用的对象必须分配在堆上。如果我们把代码改成这样:

1
2
3
4
func main()  {
str := "asong太帅了吧"
fmt.Printf("%p",&str)
}

查看逃逸分析结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
go build -gcflags="-m -m -l" ./test2.go
# command-line-arguments
./test2.go:9:18: &str escapes to heap
./test2.go:9:18: from ... argument (arg to ...) at ./test2.go:9:12
./test2.go:9:18: from *(... argument) (indirection) at ./test2.go:9:12
./test2.go:9:18: from ... argument (passed to call[argument content escapes]) at ./test2.go:9:12
./test2.go:9:18: &str escapes to heap
./test2.go:9:18: from &str (interface-converted) at ./test2.go:9:18
./test2.go:9:18: from ... argument (arg to ...) at ./test2.go:9:12
./test2.go:9:18: from *(... argument) (indirection) at ./test2.go:9:12
./test2.go:9:18: from ... argument (passed to call[argument content escapes]) at ./test2.go:9:12
./test2.go:8:2: moved to heap: str
./test2.go:9:12: main ... argument does not escape

这回str也逃逸到了堆上,在堆上进行内存分配,这是因为我们访问str的地址,因为入参是interface类型,所以变量str的地址以实参的形式传入fmt.Printf后被装箱到一个interface{}形参变量中,装箱的形参变量的值要在堆上分配,但是还要存储一个栈上的地址,也就是str的地址,堆上的对象不能存储一个栈上的地址,所以str也逃逸到堆上,在堆上分配内存。(这里注意一个知识点:Go语言的参数传递只有值传递)。

三、内存管理

TCMalloc 是 Thread Cache Malloc 的简称,是Go 内存管理的起源,Go的内存管理是借鉴了TCMalloc:

内存碎片:随着内存不断的申请和释放,内存上会存在大量的碎片,降低内存的使用率。为了解决内存碎片,可以将2个连续的未使用的内存块合并,减少碎片。

大锁:同一进程下的所有线程共享相同的内存空间,它们申请内存时需要加锁,如果不加锁就存在同一块内存被2个线程同时访问的问题。

主要概念

page: 内存页, 一块 8K 大小的内存空间. Go 与 OS之间的内存申请和释放都是以page 为单位的。

span: 内存块, 一个或多个连续的 page 组成一个span. 如果把 page 比喻成工人, span可以看成是小队, 工人被分成若干个队伍, 不同队伍干不同的(sizeclass)活。

sizeclass: 空间规格, 每个 span 都带有一个 sizeclass , 标记着该 span 中的 page 应该如何使用. 标志着 span 是一个什么样的队伍。

object: 对象, 用来存储一个变量数据内存空间, 一个 span 在初始化时,会被切割成一堆等大的object. 假设 object 的大小是 16B, span 大小是 8K, 那么就会把span中的 page 就会被初始化 8K / 16B = 512 个 object . 所谓内存分配, 就是分配一个 object 出去。

主要分区

mcache

当程序里发生了 32kb 以下的小块内存申请时,Go 会从一个叫做的 mcache 的本地缓存给程序分配内存。这样的一个内存块里叫做 mspan,它是要给程序分配内存时的分配单元。

在 Go 的调度器模型里,每个线程 M 会绑定给一个处理器 P,在单一粒度的时间里只能做多处理运行一个 goroutine,每个 P 都会绑定一个上面说的本地缓存 mcache。当需要进行内存分配时,当前运行的 goroutine 会从 mcache 中查找可用的 mspan。从本地 mcache 里分配内存时不需要加锁,这种分配策略效率更高。

mcentral

程序申请内存的时候,mcache 里已经没有合适的空闲 mspan了,那么工作线程就会像下图这样去 mcentral 里去申请。mcache 从 mcentral 获取和归还 mspan 的流程:

获取加锁;从 nonempty 链表找到一个可用的mspan;并将其从 nonempty 链表删除;将取出的 mspan 加入到 empty 链表;将 mspan 返回给工作线程;解锁。

归还加锁;将 mspan 从 empty 链表删除;将mspan 加入到 nonempty 链表;解锁。

mcentral sizeclass 相同的 span 会以链表的形式组织在一起*,* 就是指该 span 用来存储哪种大小的对象*。*

mheap

当 mcentral 没有空闲的 mspan 时,会向 mheap 申请。而 mheap 没有资源时,会向操作系统申请新内存。mheap 主要用于大对象的内存分配,以及管理未切割的 mspan,用于给 mcentral 切割成小对象。

mheap 中含有所有规格的 mcentral,所以当一个 mcache 从 mcentral 申请 mspan 时,只需要在独立的 mcentral 中使用锁,并不会影响申请其他规格的 mspan。

内存分配

所有 mcentral 的集合则是存放于 mheap 中的。 mheap 里的 arena 区域是真正的堆区,运行时会将 8KB 看做一页,这些内存页中存储了所有在堆上初始化的对象。运行时使用二维的 runtime.heapArena 数组管理所有的内存,每个 runtime.heapArena 都会管理 64MB 的内存。

*如果 arena 区域没有足够的空间,会调用 runtime.mheap.sysAlloc 从操作系统中申请更多的内存。(如下图:*Go 1.11 前的内存布局)

image-20230322165035180

根据大小分配

小于32kb内存分配

在mcache中选定指定大小的span进行分配

小于16b

对于小于16字节的对象(且无指针),Go 语言将其划分为了tiny 对象。划分 tiny 对象的主要目的是为了处理极小的字符串和独立的转义变量。对 json 的基准测试表明,使用 tiny 对象减少了12%的分配次数和20%的堆大小。tiny 对象会被放入class 为2的 span 中。

•首先查看之前分配的元素中是否有空余的空间

•如果当前要分配的大小不够,例如要分配16字节的大小,这时就需要找到下一个空闲的元素

tiny 分配的第一步是尝试利用分配过的前一个元素的空间,达到节约内存的目的。

大于32K

对于小于16字节的对象(且无指针),Go 语言将其划分为了tiny 对象。划分 tiny 对象的主要目的是为了处理极小的字符串和独立的转义变量。对 json 的基准测试表明,使用 tiny 对象减少了12%的分配次数和20%的堆大小。tiny 对象会被放入class 为2的 span 中。

•首先查看之前分配的元素中是否有空余的空间

•如果当前要分配的大小不够,例如要分配16字节的大小,这时就需要找到下一个空闲的元素

tiny 分配的第一步是尝试利用分配过的前一个元素的空间,达到节约内存的目的。

image-20230322165521965


内存模型
https://www.zengzx.xyz/2023/03/07/01.知识架构/01.Go/01.基础/10.内存模型/
作者
Eden
发布于
2023年3月7日
许可协议