链客,专为开发者而生,有问必答!
此文章来自区块链技术社区,未经允许拒绝转载。
Go的内存管理话题很大,一边学习,一边记录,持续更新。
提纲挈领
和C、C++不同,一般来说,Go程序员无需关心变量是在堆上还是在栈上。
Go语言中内存分配大致有3种模式:Stack、Heap、Fixed Size Segment。
栈
栈的概念类似传统Linux下C、C++的概念,即通过栈顶指针移动来分配内存。一般用来存储局部变量、函数参数等。每个Goroutine都有自己的执行栈,互相独立,无需加锁。Goroutine的栈是从Heap中分配的,如果运行中栈需要扩展,会使用连续栈技术进行扩展(通过Heap的操作---- allocate new,copy old to new,free old)。
Goroutine中的栈很小,初始只有2K,因此创建代价很小。
堆和GC
Go语言支持GC,针对堆中的对象。因此,它在分配内存时需要着重考虑如下两个问题:
如何平衡内存分配效率和内存利用率?
如何支持快速的GC迭代,不对业务造成较大冲击?
同时,由于堆是所有Goroutine共有的,因此需要加锁,后面详解中可以观察下它是如何优化这个问题的。
具体到实现上,Go采用了类似tmalloc的做法,在系统调用上封装了一层,减少直接系统调用的性能损耗;同时,会定期扫描释放长时间不使用的空闲内存。具体实现技巧,详见下文。
垃圾回收扫描会STW,从Go1.5起,不会超过执行时间的1/5(eg. 10ms of 50ms execution)。回收时只扫描堆上的对象,但如果对象在栈上有引用,也会分析栈上对应变量,具体如下文。
The garbage collector has to be aware of both heap and stack allocated items. This is easy to see if you consider a heap allocated item, H, referenced by a stack allocated item, S. Clearly, the garbage collector cannot free H until S is freed and so the garbage collector must be aware of lifetime of S, the stack allocated item.
固定大小对象分配
顾名思义,固定大小内存分配器。一般用作,内存管理对象的分配和回收,如mspan、mcache、mcentral等;另外也被用来分配data segment和code segment。在运行期内,data segment的大小不能变化,因此,动态内存对象不能在data segment内分配。
Fixed sized segments are defined at compile time and do not change size at runtime. Read-write fixed size segments (e.g., the data segment) contain global variables while read-only segments (e.g., code segment and rodata segment) contain constant values and instructions.
性能优化
使用runtime/pprof和go tool pprof采样分析,如果growslices和newobject调用占用很多,即可考虑内存方面的优化 ---- 减少堆上分配(变量逃逸),增加栈上分配。
Reuse memory you’ve already allocated.
Restructure your code so the compiler can make stack allocations instead of heap allocations. Use go tool compile -m to help you identify escaped variables that will be heap allocated and then rewrite your code so that they can be stack allocated.
Restructure your CPU bound code to pre-allocate memory in a few big chunks rather than continuously allocating small chunks.
栈
Goroutine的栈管理模式和线程栈类似,但实现差异巨大。
栈的内存分配
Goroutine的栈是作为一个object,由内存管理器管理的span上分配的。当对象在栈上分配时,由于栈内存已存在,仅仅移动寄存器和栈顶指针即可,性能自然高很多。对GC而言,整个栈内存被视作单一的对象。总体数量少了,压力自然减轻。但对象在栈还是堆中,是由编译器决定的。较小的栈是从fixed-size的freelist进行分配的(32k以下),更大的栈从空闲的span分配。
所谓的fixed-size,指固定大小栈的数组,如linux下为2k、4k、8k、16k这样的栈,fixed-order=4
fixed-size stack
32k以下的栈,有两种分配方式:
从全局的stackpool分配(加锁),计算对应的fixed-order,从链表中获取,如果失败,从heap中分配一个手动管理的span,并串成span链表。从span中获取一个manualFreeList的对象返回。
从线程M私有的stackcache进行分配,计算对应的fixed-order,从链表中获取,并更新统计。
stackpool和stackcache类似,也是fixed-size的数组,元素是双向链表。
stackcache是M的mcache中的成员,线程私有,无需加锁。和alloc同级别(堆中的概念,详见下文),也是个数组[fixed-order],每个元素是个单向链表。
更大的栈直接使用span块。从stackLarge中分配,stackLarge是一个全局的大span的缓存池(使用加锁,数组,每个元素是一个双向链表),如果stackLarge.free[npages]为空,则从heap中分配一个手动管理的span(肯定是goroutine释放时,执行了stack的free操作放入pool或者stackcache中)。
栈增长难题
在C语言中,启动一个线程,标准库(the standard lib)会负责分配一块内存(默认8M)给线程当作栈来使用。标准库会分配一个内存块,然后告诉内核开始执行代码。假设进程要执行一个高度递归的函数,耗尽了栈的空间,这时怎么办呢?
修改系统进程栈大小,统一调大(设为16M)。这样会浪费内存空间,即使其他进程不需要那么多的栈空间,依然会在启动进程时分配。
精确计算每个进程的栈空间大小,这样过于繁琐,对开发人员很不友好。
那么Go里面,是如何处理这个问题的呢?Go尝试给每个Goroutine按需分配栈空间。在Goroutine创建时,会初始分配2K内存用作栈空间。当检测到栈空间不够时,会调用morestack增长栈空间。
Go中如何检测Goroutine栈空间耗尽呢?
Go中每个函数在执行前,会先检查是否已经用尽了栈空间(runtime包装了代码逻辑,无需开发者关注)
分离栈
在Go1.5之前,采用分离栈Segmented stacks技术解决这个问题。顾名思义,在Goroutine执行中发现栈空间不够时,会重新分配一块内存作为这个Goroutine的延续栈,用指针串联,无需虚拟地址空间上相邻。当执行函数回归后,Goroutine不需要这么多栈空间了,会将之前分配的栈释放。
分离栈示意图
+---------------+
| |
| unused |
| stack |
| space |
+---------------+
| Foobar |
| |
+---------------+
| |
| lessstack |
+---------------+
| Stack info |
| |-----+
+---------------+ |
|
|
+---------------+ |
| Foobar | |
| | <---+
+---------------+
| rest of stack |
| |
上图是执行Foobar函数耗尽栈空间后,调用morestack形成的新的栈空间。上面的是新分配的栈stack1,下面的是被耗尽的栈stack。stack1底部stack info是stack的相关信息。在stack1分配后,在stack1上重新执行Foobar函数,执行完后,陷入lessstack逻辑。lessstack通过stack info找到原始返回地址,返回旧栈stack,并释放stack1。
Hot Split
但是分离栈有个瑕疵 ---- hot split,如果恰好有一些Goroutine在这个临界点反复执行(如for loop),那么会造成反复的morestack、lessstack、morestack、lessstack操作(Shrinking stack is a relatively expensive op to runtime),影响服务性能。为了解决这个问题,从Go1.5之后,改为采用连续栈技术来处理栈增长的问题。
连续栈
连续栈(stack copying),Goroutine初始栈大小和栈耗尽检测逻辑不变。连续栈会新分配一个size = stack*2的新栈stack1,同时把stack的数据拷贝到stack1。这样,后续栈缩减仅仅是一个free操作,对runtime来说,无需做任何事,即使缩减后再次要扩充,runtime也无需做任何操作,可以复用之前分配的空间。
栈拷贝的细节
主要是栈里面如果有指针,那么copy后,指针的地址也要相应改变。
栈里面的对象指可能被栈里面的指针关联,不可能被堆上的指针关联(变量逃逸)。
GC时,已经知道了栈上哪些变量时指针,因此迁移栈时,更新栈里面对应的指针指向新的栈里面的target即可。对于不能确定指针变量的栈(主要是runtime中很多C代码,无法进行GC,确认指针信息),回退到分离栈。 ---- 这也是为什么现在在用Go重写runtime逻辑的原因。同时,由于栈上指针信息已知,也为后续进行并发GC提供了可能。
堆内存分配与回收详解
三级管理组件:Cache(M私有)、Central、Heap。
image.png
image.png
Heap是全局的,所有Goroutine共享,管理的单位是span。span是n个页的内存块,free是个128个元素的数组,每个元素是对应页数的span链表,如free65表示65个页的span链表码,超过127页以上的span位于freelarge中。对象分配是以字节数为单位的。Heap初始化时,初始化了如下规格表,共67种,分配完span后,会把对应页数的span切分为对应objsize的小对象,挂在span.freelist上。Central数组用于管理每一种objsize对应的span链表。
image.png
Mcentral是中间管理层,提高复用效率。包含两个指针,分别挂载对应objsize的empty span和nonempty span。当cache中内存不足时,向central请求分配,需要加锁。
Cache是M独有的,为了减少共享锁的损耗,每个线程一个Cache。分配内存时优先从当前M的cache中去获取。alloc为67种objsize对应的span指针数组。
组件内存如Span结构、Cache结构都是从FixAlloc中分配的,图中的spanalloc负责span对象的分配,cachealloc负责cache对象的分配。
初始化
所有的空间分配都是虚拟地址空间。64bit机器目前低48位可用于寻址,因此地址空间为256TB。Heap在初始化时,首先尝试从固定地址占用如下地址:
image.png
其中,spans为256M,bitmap为32G,arena为512G。
arena是用户内存对象分配区域,内存扩展,即移动arena.used的标记位。
bitmap为每个对象提供4bit的标记为,用以保存指针、GC标记等信息。
spans TODO。
arena、bitmap、spans是同步扩张的,意即TODO。
分配
假设Goroutine需要从分配14byte的动态对象,
roundup 到合适的objsize,此处为16byte。
找到g当前的m,从m的cache的alloc中尝试分配,从规格表可知应寻找alloc2上的span,从span.freelist中提取可用object。
如果span.freelist为空,从heap对应的central2获取新的span。如果central2.nonempty非空,则返回central2.nonempty上的span。 加锁
如果central.nonempty为空,从heap.free中提取,从规格表可知,应该找size=1的span,即free1,提取后切分为16byte的obj链表,返回。 加锁
如果heap1为空,继续查找heap2.....,如果heap的free、freelarge都为空,则向操作系统申请新内存块(最少位1MB),分配后切分为span。 ---- 检查arena.used+size < arena.end,通过后,调用mmap(FIXED)从arena.used开始分配内存。使用线程私有的M,减少锁发生的概率,提高效率。
以上讨论的都是常规对象的分配,另有如果是大对象,会直接到heap上去获取span,tinyobj会有对应的优化措施,复用16bit的内存块,利用偏移量记录分配位置等。
结合GC,这里需要考虑central中获取可用span时,span可能正在被sweep。对应的逻辑,如果span要执行sweep会先执行sweep,再获取span。参见源码。
回收
内存回收并不意味着释放,也会考虑复用。内存管理器的目的是要在内存使用率和内存分配效率之间做平衡。
内存回收的单位不是obj,而是span。通过扫描bitmap中对象的标记位,逐步将obj收归span,上交给central或者heap.free复用。
遍历span,将可回收的obj合并到freelist,如果全部obj都被回收了,则尝试从central交还给heap复用。交还给heap时,会检查左右是否可合并,合并为更大的span一起放到heap.free中。
image.png
释放
运行时入口函数main.main中启动了一个sysmon的goroutine,每个一段时间,会检查heap里面的闲置内存块。如果超过了闲置时间,则释放其关联的物理内存。这个释放,并不是真正的释放,而是通过madvise告知os,建议内核回收该虚地址对应的物理内存。内核收到建议后,如果内存充足,就会被忽略,避免性能损耗。而当再次使用该虚拟地址内存块时,内核会捕捉到缺页,重新关联对应物理页。
释放,并不释放虚拟内存地址,因此虚地址不会形成空洞,这个地址空间依然可被访问,与之相反mmap。
GC算法详解
GC算法优化的目的是尽可能减少GC导致的STW对用户逻辑的影响。Go中GC的基本特征是: 非分代、非紧缩、写屏障、并发标记清理。此处的并发是指,GC thread和mutator thread(用户线程)并发执行。相关代码位于: runtime/mgc.go,详细说明也可参考代码头文件说明。
并发会带来很多问题,例如mutator thread可能随时修改已经被GC扫描过的区域;标记过程中还不断新分配对象。
核心问题:抑制堆增长、充分利用CPU资源。
三色标记和写屏障
Mark
三色标记是GC标记和mutator thread并发执行的基本保障。基本原理:
起始所有对象都是白色。
扫描出所有可达对象,标记位灰色,放入待处理队列。
从队列提取灰色对象,将其引用对象标记为灰色放入队列,自身标记为黑色。
写屏障监视对象内存修改,重新标色或放入队列。
Sweep
当完成全部标记扫描工作后,剩余的不是黑色就是白色,分别代表活跃对象和待回收对象,清理操作将白色对象内存回收即可。
辅助回收
如果对象分配速度高于GC后台标记速度,会造成一系列严重的后果,例如堆恶性扩张,甚至让GC永远无法完成。
因此,让mutator thread在为堆对象分配内存时适当参与GC后台标记就非常有必要,具体可参见堆对象分配中的一些源码逻辑。
控制器
控制器会记录GC过程中的一些状态信息,并根据当前GC信息(通过反馈)动态调整下一次GC的相关策略(例如确定下一次GC执行的时间),平衡CPU资源占用。
变量逃逸
简单来说,编译器会自动选择变量应该在stack还是在heap中分配,并不受var声明还是new声明影响(和CC++不同)。一般来说,
编译器通过逃逸分析来决定一个对象放在栈上还是堆上,逃逸的对象放在堆上,不逃逸的对象放在栈上。
如果一个变量很大,那么有可能被分配到栈上。
type struct T { xxx}
func f() *T {
var ret T
return &ret // ret从func f()中逃逸了,在堆中分配
}
func g() {
y := new(int)
*y = 1 // y虽然用了new,但是只在g()作用域生效,在栈上分配
}
参考Go的FAQ,仅从正确性的角度出发,使用者无需关心变量是在stack还是在heap中,Go会保证,如果变量还可能被访问(通过地址),那就不会失效。
How do I know whether a variable is allocated on the heap or the stack?
From a correctness standpoint, you don't need to know. Each variable in Go exists as long as there are references to it. The storage location chosen by the implementation is irrelevant to the semantics of the language.
The storage location does have an effect on writing efficient programs. When possible, the Go compilers will allocate variables that are local to a function in that function's stack frame. However, if the compiler cannot prove that the variable is not referenced after the function returns, then the compiler must allocate the variable on the garbage-collected heap to avoid dangling pointer errors. Also, if a local variable is very large, it might make more sense to store it on the heap rather than the stack.
In the current compilers, if a variable has its address taken, that variable is a candidate for allocation on the heap. However, a basic escape analysis recognizes some cases when such variables will not live past the return from the function and can reside on the stack.
Yet if you are so determined to find out where actually Go allocates the variables, here is a quick trick that you can use, suggeseted here.
If you need to know where your variables are allocated pass the "-m" gc flag to "go build" or "go run" (e.g., go run -gcflags -m app.go).
具体示例可参考 下面附录中1,2项。
参考文章
[1]Go的变量到底在堆还是栈中分配
[2]Golang变量逃逸分析小探
[3]The Go Programming Language Report
[4]50 Shades of Go: Traps, Gotchas, and Common Mistakes for New Golang Devs
[5]Confused about Stack and Heap?
[6]Go Memory Management
[7]Memory Management in Go
[8]How Stacks are Handled in Go
[9]Go学习笔记
有疑问加站长微信联系(非本文作者)