简介
Go语言和C++一样,是静态类型的编程语言,因此Go也不需要VM。Go应用程序中嵌入了一个小型运行时,可以处理诸如垃圾收集(GC)、调度和并发之类的语言功能。
Go internal memory structure
Go内部的内存结构介绍。
Go运行时将Goroutines(G)调度到逻辑处理器(P)上执行。每个P都有一台逻辑机器(M)。详细资料参阅《Go调度程序:Ms,Ps和Gs》,对应本站。
每个Go程序进程都由操作系统(OS)分配了一些虚拟内存,这是该进程可以访问的全部内存。在这个虚拟内存中实际正在使用的内存称为Resident Set(驻留内存)。该空间由内部内存结构管理,如下所示:
Page Heap(mheap)
这里是Go存储动态数据(在编译时无法计算大小的任何数据)的地方。它是最大的内存块,也是进行垃圾收集(GC)的地方。
驻留内存(resident set)被划分为每个大小为8KB的页,并由一个全局 mheap对象管理。
大对象(大小>32kb的对象)直接从mheap分配。这些大对象申请请求是以获取中央锁(central lock)为代价的,因此在任何给定时间点只能满足一个P的请求。
mheap通过将页归类为不同结构进行管理的:
mspan:mspan是mheap中管理的内存页的最基本结构。这是一个双向链接列表,其中包含起始页面的地址,span size class和span中的页面数量。像TCMalloc一样,Go将内存页按大小分为67个不同类别,大小从8字节到32KB,如下图所示
容易发现,该结构和STL库中的内存分配模型比较相似,STL如下,也是将内存按照不同的大小块分别进行管理。
Each span exists twice, one for objects with pointers (scan classes) and one for objects with no pointers (
noscan
classes). This helps during GC asnoscan
spans need not be traversed to look for live objects.针对上面这句话的解释,存在疑惑。我的理解是Span是可以知道自己存储是不是指针类型的数据。从而在垃圾回收过程中知道自己是不是叶子结点。scan class表明该Span是非叶子结点,其指向了其他的一些Span。no scan class表明该Span是叶子结点,其不再指向其他的Span
mcentral:将相同大小的Span统一管理,每个mcentral包含两个mSpanList
- empty:Span的双向链表,管理了有数据的Span或者在mchache中缓存的Span,当其中的数据被释放掉,将会从empty移动到no-empty
- no-empty:Span的双向链表,管理了所有空闲的(没有被使用)的Span,当向mcentral中申请一个Span,该Span将会从no-empty移动到empty。当no-empty没有可以提供的Span的时候,将会向mheap请求新的页
arean:堆在已分配的虚拟内存中根据需要增长或缩小。当需要更多内存的时候,mheap从虚拟内存中以每块64MB为单位获取内存,这块内存被称为arean,将会被划分为页,并映射成Span。
mcache:是一个块儿提供给P(Logic Processor)去存储<=32kb的数据,和线程栈(Thread Stack)很像,但它是堆的一部分,用于动态数据的存储。其中包含了scan和noscan两种类型的Span,并且包含所有种类的大小。Goroutines可以无锁从mcache中获取数据,因为P在某个时刻只会对应一个G。因此是更高效的,当mcache需要空间的时候会像mcentral申请。
Stack
每个G都有自己的一个栈内存区域,用于存储静态数据,包括函数栈帧,静态结构,原生类型值和指向动态结构的指针等。
值得一提的是,栈的管理是由操作系统负责的,而不是Go本身。
Go memory usage (Stack vs Heap)
下面将通过一个demo,展示Go是如何使用内存的
1 | package main |
1 | go build -gcflags '-m' gc.go # -gcflags '-m' 查看编译细节 |
1 | (base) ➜ learn go build -gcflags '-m' gc.go |
Go语言和其他带有垃圾回收机制的语言相比最大的不同点是,Go会将更多的对象直接分配在栈上。Go编译器使用了一种逃逸分析的技术,找出在编译时期生命周期就是已知的,将他们分配到栈上。代码执行动画,见此 若无法打开,可下载本站静态缓存。
流程如下:
- Main 函数首先被放入了栈中的一块Frame区域中
- 每一个被调用的函数都被以Frame的形式放入了栈中
- 包括参数和返回值在内的所有静态变量都被存储在了栈的函数Frame中
- 无论类型如何,所有静态值都被直接放入了栈中,也适用于全局变量类型,例如本demo中的BONUS_PERCENTAGE
- 所有动态类型都在堆上创建,并且被栈上的指针所引用。小于32k的对象,由mcache分配,同样适用于全局范畴
- 具有静态数据的结构体保留在栈上,直到该位置将任何动态值加到该结构体,该结构体被移动到堆上(动态值与静态值的定义?)
- 函数的调用和退出与C++的push到栈和pop出栈的逻辑相同。
- 当主函数退出,在堆上的数据将会变成孤儿
Go Memory management
Go的内存管理包括在需要内存时自动分配内存,在不再需要内存时进行垃圾回收。这是由标准库在运行时完成的。与C/C++不同,开发人员不必处理它,并且Go进行的基础管理得到了高效的优化。
Memory Allocation
许多采用垃圾收集的编程语言都使用分代内存结构来使收集高效,同时进行压缩以减少碎片。正如我们前面所看到的,Go在这里采用了不同的方法,Go在构造内存方面有很大的不同。Go使用线程本地缓存(thread local cache)来加速小对象分配,并维护着scan/noscan的span来加速GC。这种结构以及整个过程避免了碎片,从而在GC期间无需做紧缩处理。让我们看看这种分配是如何发生的。
Go根据对象的大小决定对象的分配过程,分为三类:
Tiny(size<16B):使用mcache的tiny allocator。多个对象的分配可以共用一个16byte的Span,十分高效
Small(size 16B~32KB):被分配到了对应G的对应的mcache的的对应大小的mSpan中(和tiny的区别是,对象之间不再共享一个Span)
和Tidy类似,如果Span耗尽了,则向mheap申请一个页用于Span的使用,如果mheap是空的或者没有页则向操作系统申请。
Large(size>32kb):被直接分配到对应大小的mheap中。如果mheap是empty并且没有页了,则向操作系统申请足够的空间(最小1M)
文中完整PPT见此 Go_memory_allocation.pdf
Garbage collection
理解Go的垃圾回收机制,对于应用程序的性能非常重要。当程序尝试在堆上分配的内存大于可用内存时,我们会遇到内存不足的错误(out of memory)。不当的堆内存管理也可能导致内存泄漏。
Go通过垃圾回收机制管理堆内存。简单来说,它释放了孤儿对象(orphan object)使用的内存,所谓孤儿对象是指那些不再被栈直接或间接(通过另一个对象中的引用)引用的对象,从而为创建新对象的分配腾出了空间。
从Go 1.12版本开始,Go使用了非分代的、并发的、基于三色标记和清除的垃圾回收器。收集过程大致如下所示,具体细节可参考系列文章。
当一定比例的堆被分配了之后,GC就开始工作了,收集器将会在不同的工作阶段执行不同的工作
- Mark Setup (Stop the world):收集器将打开写屏障,以便在下个阶段维护数据完整性。此步骤需要非常小的完全暂停(Stop the world)。
- Marking (Concurrent):一旦写屏障被打开了,实际的标记过程将会并发执行,这个过程将会使用可用CPU的25%的算力,相关的P都会被保留,直到标记的结束。相关操作是使用专用的Goroutines完成的。这个过程标记了堆中的活动对象(被任何活动的Goroutines的栈中引用)。当该过程耗时过久时,会征用活动的Goroutine来辅助标记过程(被称为Mark Assist)。
- Mark Termination (Stop the world):当标记完成,每个活动的Goroutines都会暂停,写屏障将会被关闭,清理任务开始执行。GC还会在此处计算下一个GC的目标,完成操作后,保留的Ps会释放会应用程序。
- Sweeping (Concurrent):当完成收集并尝试分配后,清除过程将未标记为活动的对象回收。清除掉的内存将会立即可重新分配出去。
整体流程图以一个Goroutine回收为例子。从栈中的指针出发,遍历在mcache和mheap中仍存活的Span。既不像BFS也不像DFS,但其实就是图的遍历。Stop the World的操作耗时并不多,对程序运行效率的影响并不大。