内存模型
一、场景
如何保证在一个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
禁用掉内联优化,只关注逃逸优化就好了。
现在我们也知道了逃逸分析,接下来我们就看几个逃逸分析的例子。
分析方法
- 压测
- 使用pprof确定是那一个方法内存消耗高,另外可以确定分配了几个内存出去
- 使用go build --gcflag -m -l 来确定是哪一个内存逃逸
- 如果有内存逃逸的现象,会提示escape to heap
2.1 函数返回局部指针变量
先看例子:
1 |
|
查看逃逸分析结果:
1 |
|
分析结果很明了,函数返回的局部变量是一个指针变量,当函数Add
执行结束后,对应的栈桢就会被销毁,但是引用已经返回到函数之外,如果我们在外部解引用地址,就会导致程序访问非法内存,就像上面的C
语言的例子一样,所以编译器经过逃逸分析后将其在堆上分配内存。
2.2 interface类型逃逸
先看一个例子:
1 |
|
查看逃逸分析结果:
1 |
|
str
是main
函数中的一个局部变量,传递给fmt.Println()
函数后发生了逃逸,这是因为fmt.Println()
函数的入参是一个interface{}
类型,如果函数参数为interface{}
,那么在编译期间就很难确定其参数的具体类型,也会发送逃逸。
观察这个分析结果,我们可以看到没有moved to heap: str
,这也就是说明str
变量并没有在堆上进行分配,只是它存储的值逃逸到堆上了,也就说任何被str
引用的对象必须分配在堆上。如果我们把代码改成这样:
1 |
|
查看逃逸分析结果:
1 |
|
这回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 前的内存布局)
根据大小分配
小于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 分配的第一步是尝试利用分配过的前一个元素的空间,达到节约内存的目的。