零 前置知识
操作系统的每个进程都认为自己可以访问计算机的所有物理内存,但由于计算机必定运行着多个程序,每个进程都不能拥有全部内存。
为了避免了进程直接访问实际的物理地址,操作系统会将物理内存虚拟为一个数组,每个元素都有一个唯一的物理地址(PA)。
物理存储其器中存储着一个页表(page table),该表即虚拟地址与物理地址的映射表,读取该也表,即可完成地址翻译。
假设一个程序访问地址为0x1001的内存,实际上,该数据并不一定是存储在0x1001的物理地址中,甚至也不在物理内存中(如果物理内存满了,则可以转移到磁盘上)。这些地址不必反映真实的物理地址,可以称为“虚拟内存”。
一 内存分区
1.0 程序的内存使用
现在使用命令来查看Go程序的内存使用:
go build main.go
size main
此时会显示Go程序在未启动时,内存的使用情况:
此时可执行程序内部已经分好了三段信息,分别为:
- text 代码区
- data 数据区
- bss 未初始化数据区
贴士:
data和bss区域可以一起称呼为静态区/全局区
- 上述三个区域大小都是固定的
程序在执行后,会额外增加栈区、堆区。
1.1 text 代码区
代码区用于存放CPU执行的机器指令,一般情况下,代码区具备以下特性:
- 共享:即可以提供给其他程序调用,这样就可以让代码区的数据在内存中只存放一份即可,有效节省空间。
- 只读:用于放置程序修改其指令
- 规划局部变量信息
1.2 data 数据区
数据区用于存储数据:
- 被初始化后的全局变量
- 被初始化后的静态变量(包含全局静态变量、局部静态变量)
- 常量数据(如字符串常量)
1.3 bss 未初始化数据区
未初始化数据区用于存储:
- 全局未初始化变量
- 未初始化静态变量
如果是C语言,未初始化,却被使用了,这会产生一个随机的值。Go语言中,为了防止C的这种现象,该区域的数据会在程序执行之前被初始化为零值(0或者空)。
1.4 stack 栈区
栈是一种先进后出(FILO)的内存结构,由编译器自动进行分配和释放。一般用于存储:函数的参数值、返回值、局部变量等。栈区大小一般只有1M,也可以实现扩充:
- Windows最大可以扩充为10M
- Linux最大可以扩充为16M
1.5 heap 堆区
栈的内存空间非常小,当我们遇到一些大文件读取时,栈区是不够存储的,这时候就会用到堆区。堆区空间比较大,其大小与计算机硬件的内存大小有关。
堆区没有栈的先进后出的规则,位于BSS区域栈区之间,用于内存的动态分配。
在C、C++等语言中,该部分内存由程序员手动分配(c中的malloc函数,c++中的new函数)和释放(C中的free函数,C++的delete函数),如果不释放,可能会造成内存泄露,但是程序结束时,操作系统会进行回收。
在Java、Go、JavaScript中,都有垃圾回收机制(GC),可以实现内存的自动释放!
注意:Go语言与其他语言不同,对栈区、堆区进行虚拟管理。
1.6 操作系统内存分配图
操作系统会为每个进程分配一定的内存地址空间,如图所示:
[图片上传失败...(image-55277a-1605788450758)]
上图所示的是32位系统中虚拟内存的分配方式,不同系统分配的虚拟内存是不同的,但是其数据所占区域的比例是相同的:
- 32位:最大内存地址为232,这么多的字节数换算为G单位,即为4G。(换算为1G=1024MB=10241024KB=10241024*1024B)
- 64位:最大内存地址为264,这么多的字节数换算为G单位,数值过大,不便图示
注意:栈区是从高地址往低地址存储的
一 变量逃逸
由于栈的性能相对较高,变量是分配到了栈,还是堆中,对程序的性能和安全有较大影响。
逃逸分析是一种确定指针动态范围的方法,用来分析程序的哪些地方可以访问到指针。当一个变量或对象在子程序中分配内存时,一个指向变量或对象的指针可能逃逸到其他执行线程中,甚至去调用子程序。
指针逃逸:一个对象的指针在任何一个地方都可以访问到。
逃逸分析的结果可以用来保证指针的声明周期只在当前进程或线程中。
func toHeap() *int {
var x int
return &x
}
func toStack() int {
x := new(int)
*x = 1
return *x
}
func main() {
}
上述两个函数分别创建了2个变量,但是申请的位置是不一样的。打开逃逸分析日志:
go run -gcflags '-m -l' main.go
# command-line-arguments
./main.go:4:6: moved to heap: x
./main.go:9:10: toStack new(int) does not escape
如上所示,toHeap()中的x分配到了堆上,toStack()中的x最后分配到了栈上。does not escape
表示未逃逸。同样是变量内存的申请,两个函数获得的位置却是不一样的!!
这是因为go在一定程序上消除了堆和栈的区别,在编译的时候会自动进行变量逃逸分析,不逃逸的对象就放到栈上,可能逃逸的对象就放到堆上。
- 一般情况下,函数的局部变量会分配到函数栈上
- 变量在函数return之后还被引用,会被分配到堆上,比如上述的案例
toHeap()
Go的GC判断变量是否回收的实现思路:从每个包级的变量、每个当前运行的函数的局部变量开始,通过指针和引用的访问路径遍历,是否可以找到该变量,如果不存在这样的访问路径,那么说明该变量是不可达的,也就是说它是否存在并不会影响后续计算结果。
示例:
var global *int
func f() {
var x int
x = 1
global = &x
}
func g() {
y := new(int)
*y = 1
}
上述的函数调用结果说明:
- 虽然x变量定义在f函数内部,但是其必定在堆上分配,因为函数退出后仍然能通过包一级变量global找到,这样的变量,我们称之为从函数f中逃逸了
- g函数返回时,变量*y不可达,因此没有从函数g中逃逸,其内存分配在栈上,会马上被被回收。(当然也可以选择在堆上分配,然后由Go语言的GC回收这个变量的内存空间)
二 变量逃逸分析案例
2.1 案例一
在C++中,开发者需要自己手动分配内存来适应不同的算法需求。比如,函数局部变量尽量使用栈(函数退出,内部变量也依次退出),全局变量、结构体使用堆。
Go语言将这个过程整合到了编译器中,命名为“变量逃逸分析”,这个技术由编译器分析代码的特征和代码生命期,决定是堆还是栈进行内存分配。
func test(num int) int {
var t int
t = num
return t
}
//空函数,什么也不做
func void() {
}
func main() {
var a int //声明变量并打印
void() //调用空函数
fmt.Println(a, test(0)) //打印a,并调用test
}
运行上述代码:
# -gcflags参数是编译参数,-m表示进行内存分析,-l表示避免程序内联(优化)
go run -gcflags "-m -l" test.go
得到结果:
# command-line-arguments
./test.go:22:13: a escapes to heap # 29行的变量a逃逸到堆
./test.go:22:21: test(0) escapes to heap # test(0)调用逃逸到堆
./test.go:22:13: main ... argument does not escape # 默认提示
0 0
test(0)调用逃逸到堆,但是test()函数会返回一个整数值,这个值被fmt.Println()使用后还是会在其声明后继续在main函数中存在。
test函数中的声明的变量t是整型,该值通过test函数返回值逃出了函数,t变量的值被复制并作为test函数的返回值返回,即使t在test函数中分配的内存被释放,也不会影响main函数中使用test返回的值,t变量使用栈分配不会影响结果。
2.2 案例2
type Data struct {
}
func test() *Data {
var d Data
return &d // 返回局部变量地址
}
func main() {
fmt.Println(test()) //输出 &{}
}
继续使用命令:go run -gcflags "-m -l" test.go
# command-line-arguments
./test.go:11:9: &d escapes to heap
./test.go:10:6: moved to heap: d # 新增提示:将d移到堆中
./test.go:15:18: test() escapes to heap
./test.go:15:13: main ... argument does not escape
&{}
moved to heap: d
表示go编译器已经确认如果c变量被分配在栈上是无法保证程序最终结果的,如果坚持这样做,test()的返回值是僵尸Data结构的一个不可预知的内存地址。这种情况一般是C/C++语言中容易犯错的地方:引用了一个函数局部变量的地址。Go最终选择将d的Data结构分配到堆上,然后由垃圾回收期去回收c的内存。
三 原则总结
在使用Go语言进行编程时,Go语言设计者不希望开发者将精力放在内存应该分配在栈还是堆上,编译器会自动帮助开发者完成这个纠结的选择。
编译器觉得变量应该分配在堆还是栈上的原则是:
- 变量是否被取地址
- 变量是否发生逃逸
一 内存分配器
1.0 Golang的内存分配器TCMalloc
Go的内存分配基于TCMalloc(Thread-Cacing Malloc,Google开发的一款高性能内存分配器),源码位于runtime/malloc.go
。但是经过多年发展,Go的内存分配算法已经大幅进化,但是学习TCMalloc仍然能看到一些Go内存分配的基础。
Go采用离散式空闲列表算法(Segregated Free List)分配内存,主要核心算法思想是:
- 线程私有性
- 内存分配粒度
1.1 线程私有性
TCMalloc内存管理体系分为三个层次:
- ThreadCache:一般与负责小内存分配,每个线程都拥有一份ThreadCache,理想情况下,每个线程的内存申请都可以在自己的ThreadCache内完成,线程之间无竞争,所以TCMalloc非常高效,这既是TCMalloc的线程私有性
- CentralCache:内部含有多个CentralFreelist
- PageHeap:与负责大内存分配,是中央堆分配器,被所有线程共享,可以与操作系统直接交互(申请、释放内存),大尺寸内存分配会直接通过PageHeap分配
TCMalloc具备线程私有性质,然而现实往往是骨感的!ThreadCache中内存不足时,还需要其他2个组件帮助,内存的分配、释放从上述三个层级中依次递进:当最小的Thread层分配内存失败,则从下一层的CentralCache中分配一批补充上来。
CentralFreeList是TheadCache和PageHeap之间协调者。
- 分配内存:CentralFreeList会将PageHeap中的内存切分为小块,分配给ThreadCache。
- 释放内存:CentralFreeList会获取从ThreadCache中回收的内存,归还给PageHeap。
1.2 内存分配粒度
内存分配调度的最小单位也称为粒度,TCMalloc有两种分配粒度:
- span:用于内部管理。span是由连续的page内存组成的一个大块内存,负责分配超过256KB的大内存
- object:用于面向对象分配。object是由span切割成的小块,其尺寸被预设了一些规格(class),如16B,32B(88种),不会大于256KB(交给了span)。同一个span切出来的都是相同的object。
贴士:ThreadCache和CentralCache是管理object的,PageHeap管理的是span。
在申请小内存(小于256KB时),TCMalloc会根据申请内存的大小,匹配到与之大小最接近的class中,如:
- 申请O~8B大小时,会被匹配到 class1 中,分配 8B 大小
- 申请9~16B大小时,会被匹配到 class2 中,分配 16 B大小
上述的分配方式可以既非常灵活,又能极大避免内存浪费!
二 内存分配
2.0 分配的第一步
分配器以page为单位,向操作系统申请“大块内存”,这些大块内存由n个地址连续的page组成,并用名为span的对象进行管理。
示例:现在拥有128page的span,如果要申请1page的span,则该span会被划分为2个:1+127,再把127page的span记录下来。
2.1 小内存分配
小内存分配对应的ThreadCache是TCMalloc三级分配的第一层,是一个TSL线程本地存储对象,负责小于256KB的内存申请。每个线程都独立拥有各自的离散式空闲列表,所以分配过程不需要锁,分配速度很高。
ThreadCache在分配小内存时,首先会通过SizeMap查找要分配的内存所对应的class以及object大小,然后检查空闲列表(free list)是否为空:
- 如果非空,表示线程还有空闲的内存,那么直接从列表中移除第一个object并返回,这个过程不需要任何锁!
- 如果未空,表示线程没有空闲的内存,那么从哪个CentralFreeList中获取若干object,因为CentralCache是被所有线程共享的,能够获取多少object是由慢启动算法决定的。获取的object会被分配到ThreadCache对应的class列表中,最终取出其中一个object返回
如果CentralFreeList中的object也不够用,则会向PageHeap申请一连串页面,这些页面被切割为一系列object,再将部分object转移给ThreadCache。
如果PageHeap也不够用了,则会向操作系统申请内存(page为单位),Go中此处使用mmap方法申请,或者通过在/dev/mem中映射。申请完毕后继续上面的操作,将内存逐级递送给线程。
2.2 CentralCache
CentralCache内部含有多个CentralFreelist,即针对每一种class的object。ThreadCache维护的是object链表,CentralFreelist维护的是span链表。
CentralFreelist示意图:
[图片上传失败...(image-2eb56b-1605788450758)]
在 span 内的 object 都已经空闲(free)的情况下,将 span 整体回收给 PageHeap。( span.refcount_记录了被分配出去的 object 个数〉。但是如果每个回收的 object 都需要寻找自己所属的 span,然后才能挂进freelist,这样就比较耗时了。所以 CentralFreeList 里面还
维护了一个缓存 (tc_slots_),回收的若干 object 先往缓存里塞,不管 object 大小如何,缓存满了再分类放进相应 span 的 object 链。相反,如果 ThreadCache 申请 object,也是先尝试在缓存里面给,没了再去 span 链那里申请。
那么这个若干具体是多少个 object 呢?其实这是预定义的,称作 batch size,不同的class 可能有不同的值。 ThreadCache 向 Central Cache 分配或回收 object 时,都尽量以batch_size 为一个批次。而为了使得缓存简单高效,如果某次分配或者回收的 object 个数小于 batch size,则会绕过缓存,直接处理。
为了避免在分配 object 时判断 span 是否为空,CentralFreeList 里的 span 链表被分为两个,分别是 nonempty_ 和 empty_,根据 span 的 objects 链是否有空闲,放入对应链表。当到了需要分配时,只需要在由空变非空、或者由非空变空时移动 span 就可以了。
CentralFreeList 作为整个体系的中间人,它从 PageHeap 中获得 span ,并按照预定大小( SizeMap 中的定义)将其分割成大小固定的 object ,然后 ThreadCache 可以共享 CentralFreeList 列表。
当 ThreadCache 需要从 CentralFreeList 中获取 object 时,会从 nonempty 链表中获取第一个 span,并从这个 span 的 object 链表中获取可用 object 返回 。 当该 span 无可用 object时,将此span 从 nonempty_链表移除,并挂到 empty一链表上。
当 ThreadCache 把 object 归还给 CentralFreeList 时,object 会找到它所属的 span,并挂载到 object 链表表头,如果 span 处在 empty_链表, CentralFreeList 会重新将其挂载到nonempty_链表。
span 里还有一个值用于计算 object 是否己满,每次分配出 去一个 object, refcount 值就会加 1 ,每回收一个 object 就会减 1 ,如果 refcount 等于 0 就表示此 span 所有 object 都
回家了,然后 span 会从 CentralFreeList 的链表中释放,并将其退还给上一层 的 PageHeap 。
2.3 大内存分配
如果遇到要分配的内存大于page这个单位,就需要多个page来分配,即将多个page组成一个span来分配。
TCMalloc中定义的page大小为8KB(Linux中为4KB),其每次向操作系统申请内存的大小至少为1page。
PageHeap虽然按page申请内存,但是其内存基本单位是span(一个地址连续的page)。PageHeap内部维护了一个核心关系:page与span的映射关系。 当释放回收一个 object 时 ,把 object 放回原来的位置需要 CentralFreeList 来处理( object 放回原来的 span,然后才还给 PageHeap ),但是之所以能够放回对应的 span 里是因为有 page 到 span 的映射
关系,地址值经过地址对齐,很容易知道它属于哪一个 page。再通过 page 到 span 的映射关系就能知道 object 应该放到哪里。
span.sizeclass 记录了 span 切分的 object 属于哪个 class ,那么属于这个 span 的 object在释放时就可以放到 ThreadCache 对应 class 的 FreeList 上面,接下来 object 如果要回收还给 CentralFreeList,就可以直接把它挂到对应 span 的 objects 链表上。
page 到 span 的映射关系是基于 radix tree 实现的,你可以把它理解为一种很大的数组,用 page 值作为偏移可以访问到 page 所对应的 span (也有多个 page 指向 同一个 span 的情况,因为 span 有时可不止一个 page ) 。查询 radix tree 需要消耗一定时间,所以为了避免这些开销, PageHeap 和 CentralFreeList 类似,维护了 一个最近活跃的 page 到 class 对应关系的缓存。为了保持缓存的效率,缓存只有 64KB,旧的对应关系会被新来的对应关系替换掉。
当需要某个尺寸的 span 没有空闲时,可以把更大尺寸的 span 拆分,如果大的 span也没有了,就是向操作系统要的时候了;回收时-,也需要判断相邻的 span 是否空闲,以便将它们组合。 判断相邻 span 还是使用 radix tree 查询,这种数据结构就像一个大数组,可以获取当前 span 前后相邻的 span 地址。span 的尺寸有从 1 page 到 255 page 的所有规格,所以 span 可以以 page 为单位,用任意尺寸进行拆分和组合。
一 Go运行时简述
1.1 Go Runtime简介
Go语言的内存分配是自主管理的,所以内置了运行时(Runtime),这样能自主实现内存使用模式,如内存池、预分配等。这样的好处是不会让每次内存分配都进行系统调用(会从用户态切换到内核态)。
Golang的运行时内存分配算法基于TCMalloc算法,即Thread-Caching Malloc,其核心思想是把内存分为多级管理,降低了锁的粒度。在Go中,可用的堆内存采用二级分配的方式进行管理。
Go中的每个线程都会自行维护一个独立的内存池,进行内存分配是会优先从该内存池中分配,当内存池不足时才会向全局内存池申请,以避免不同线程对全局内存池的频繁竞争。
1.2 内存分配过程
Go程序在启动时会从操作系统申请一大块内存(可以减少系统调用,所以Go在刚启动时占用很大)。实际中,申请到的大块内存并不一定是连续的,Go会将这些零散的内存构建为一个链表,如图所示:
[图片上传失败...(image-e2cb8e-1605788450758)]
mspan结构体即链表中的节点对象,位于 src/sruntime/mheap.go:
type mspan struct {
next *mspan // 双向链表下一个节点
prev *mspan // 双向链表前一个节点
startAddr uintptr // 起始序号
npages uintptr // 当前管理的页数
manualFreeList gclinkptr // 待分配的 object 链表
nelems uintptr // 剩余可分配块个数
allocCount uint16 // 已分配块个数
}
启动后申请到的内存在Go中会被重新分配虚拟地址空间,在X64上分别是 512MB、16GB、512GB,如图所示:
[图片上传失败...(image-e9b64c-1605788450759)]
图中的三块区域:
- arena:即堆区,Go在这里进行动态内存分配,该区域被分割成了每块8KB大小的页Page,这些页组合成为 mspan
- bitmap:表示页中具体的信息,即arena区哪些地址保存了对象,bitmap使用4bit标志位表示对象是否包含指针、GC标记信息
- spans区域:表示具体页,即mspan指针,每个指针对应一页,spans区域的大小即为:
- 512GB/8KB:得到arena区域的页数
- 上述结构*8B:得到spans区域所有指针大小,其值为512MB
源码位于:src/runtime/malloc.go
_PageShift = 13
_PageSize = 1 << _PageShift // 1左移13 (1后面有13个0) 8KB
注意:内存分配器只负责内存块的创建、提取等,其回收动作是由GC清理后触发的,不会主动回收!
内存分配器会将管理的内存分为两种:
- span:由多个连续的页组成
- object:span会被按照特定大小切分成多个小块,每个小块都可以用于存储对象
具体的分配过程:
- 为对象分配内存时,只需要从链表中取出一个大小合适的节点即可
- 为对象回收内存时,会将对象使用的内存重新插回到链表中
- 如果闲置内存过多,也会尝试归还部分内存给操作系统,降低整体开销
1.3 内存分配器的组件
内存分配器包括3个组件:cache、central、heap。
cache:
每个运行期工作线程都会绑定一个cache,用于无锁obeject的分配,在本地缓存可用的mspan资源,这样就可以直接给运行时分配,因为不存在多个go协程竞争的情况,所以不会消耗资源。
macache结构体位于 src/runtime/mcache.go:
type mcache struct {
alloc [numSpanClasses]*mspan // mspan结构体指针数组,以该值为索引管理多个用于分配的span
}
central:
为所有mcache提供切分好的后备span资源,每个central保存一种特定大小的全局mspan列表,包括已经分配出去的和未分配出去的。每个mcentral都会对应一种mspan,根据mspan的种类不同,分割的object大小不同。
mcentral结构体位于 src/runtime/mcentral.go
type mcentral struct {
lock mutex
sizeclass int32 // 规格
nonempty mSpanList // 尚有空闲object的mspan链表
empty mSpanList // 无空闲object的mspan链表,或者是已被mcache取走的mspan链表
nmalloc uint64 // 已累计分配的对象个数
}
sizeclass 规格即内存分配大小的规格,依据不同的规格描述不同mspan。
heap:
管理闲置span,需要时想操作系统申请内存
Go要求尽量复用内存,其复用机制总结如下:
- Go程序启动时,向操作系统申请一大块内存,之后自行管理
- Go内存管理的基本单元是mspan,由若干页组成,每种mspan都可以分配特定大小的object
- mcache、mcentral、mheap是go内存管理的是哪个组件,其关系依次推进
- mcache:管理线程在本地缓存的mspan
- mcentral:管理全局的mspan供所有线程使用
- mheap:管理go所有动态分配的内存
- 一般小对象通过mspan分配内存,大对象直接由mheap分配内存
二 Mspan 内存管理器详解
在Go语言中,内存被划分为两部分:
- 堆:供内存分配
- bitmap:管理堆
这两部分的内存都是从同一个地址开始申请的,向高地址的方向增长的就是内存池,向低地址方向增长的就是 bitmap 。
Go语言的内存管理缓存结构:
Go 语言为每个系统线程分配了 一个本地 MCache (类似 TCMalloc 中的 ThreadCache,不过 Go 语言改了名称),少量的地址分配就是从 MCache 分配的,并且定期进行垃圾回收,所以 Go 语言的分配器包含了显式与隐式的调用。 Go 语言定义的小块内存与 TCMalloc 基本一致, Go 语言底层会把这些小块内存按照指定规格(和 TCMalloc 的 class 类似)进行切割,整个过程结构都与 TCMalloc 相似。
Go语言内存分配的主要组件:
- MCache:每个尺寸的 class 都有一个空 闲链表 。 每个 goroutine C 线程〉都有自己的局部 MCache (小对象从它取,无须加锁,没有竞争,因此十分高效)
- MCentral:与 TCMalloc 的 CentralCache 类似, MCache 可以从这里获取更多 内存,当自身无空闲内存时,可以向 MHeap 申请一个 span (只能一个〉,申请的 span 包含多少个 page 由 central 的 sizeclass 确定
- MHeap:负责将 MSpan 组织和管理起来。分配过程和 TCMalloc 类似,从 free 数组中分配,如果发生切割则将剩余的部分放回 free 数组中。回收过程也类似,回
收一个 Mspan 时,先查找它的相邻地址,再通过 map 映射得到对应的 Mspan,如果 Mspan 的状态是未使用,则可以将两者合井 。 最后将这个 page 或者合并后的 page归还到台ee 数组分配池或者 large 中。
Go的alloc示意图:
[图片上传失败...(image-cebaad-1605788450759)]
struct Mcache alloc from 'cachealloc' by FixAlloc
意思是newobject是从arena区域分配的,runtime层自身管理的结构如mache等是专门设计了fixAlloc来分配的,这里与TCMalloc不一样!
三 内存分配代码详解
在 Go 语言中,内存分配器只管理内存块,并不关心对象的状态,而且不会主动回收内存,需要由垃圾回收器完成清理操作后,再触发内存管理器回收内存 。
3.1 初始化
初始化过程大体是通过 sysReserve 向系统申请一块连续的内存(由 spans+bitmap+arena 组成)。其中 arena 为各级别缓存结构提供的内存块, spans 是一个指针数组,用来按照 page 寻址 arena 区域。 sysReserve 最终调用的是系统函数 mmap,会申请 512GB 的虚拟地址空间 ( 64 位机器上为 spans 512MB,bitmap 16GB 、 arena 512GB ) ,当然真正的物理内存则是用到的时候发生缺页才真实占用。
MHeap 在 mallocinit()中 初始化,而 mallocinit 被 schedinit()调用,代码详见/src/runtime/proc.go mallocinit()
。
MCentral 的初始化比较简单,设置自身级别并将两个 mspanList 初始化 。 而 MCache 在 procresize(nprocs int32) * p 中初始化(代码如下) , procresize 也在 schedinit()中调用,顺序在 mallocinit()之后,也就是说发生在 MHeap 与 MCentral 的初始化后面 。代码见 func p 「oc 「esize(np 「ocs int32) *p
。
所有的 P 都存放在一个全局数组 allp 中, procresizeO的目的就是将 allp 用到的 P 进行初始化,同时对多余的 P 的资源隔离 。至此, 管理结构 h征leap 、 MCentral 及每个 P 的 MCache 都初始化完毕,接下来进入分配阶段。
3.2 分配
分配的整个流程是:将小对象所需 内存大小向上取整到最近的尺寸类别或者称为规格( class ),查找相应 的 MCache 的 空 闲 链表, 如 果链表不空,直接从上面分配一个对象,这个过程不加锁;如果 MCache 空闲链表是空的,通过 MCentral 的空闲链表取一些对象进行补充:如果 MCentral 的空闲链表也是空的, 则在 h哑leap 中取用一些 page 对 MCentral 进行补充,然后将这些内存分割成特定规格:如果 MHeap 没有足够大的 page 时,从操作系统分配一组新的 page 。
为了避免逃逸的情况,假设关闭了内联优化,现在来看源码,当 时W 一个对象时,调用的是 newobject(), 但实际上调用的是 mallocgc()
对于小于 16B 的内存块, Mcache 有一个专门的内存区域“ tiny”用来分配, tiny 指针指向 tiny 内存块的起始地址 。 如上所示, tinyoffset 表示 tiny 当前分配的地址位置,之后的分配根据 tinyoffset 寻址 。 先根据要分配的对象大小进行地址对齐, 比如 size 是 8 的倍数, tinyoffset 就和 8 对齐 ,然后进行分配 。 如果 tiny 剩余 的 空 间不够用,则重新 申 请一个 16B 的内存块, 并分配给 object 。 如果有余,则记录在 tiny 上。
对于大于 32阻的内存分配,直接跳过 mcache 和 mcentral,通过 mheap 分配。大于 32KB 的内存分配都是分配整数页,先右移然后低位与计算需要的页数。详见func largeAlloc(size uintptr, needzero bool) *mspan
最后是对于大小介于 16KB~ 32KB的小对 象内存分配,首先计算应该分配的sizeclass ,然后去 mcache 里面申请,如果不够,就让 mcache 向 mcentral 申请再分配 。 Mcentral为 mcache 分配完之后会判断自己需不需要扩充,如果需要就向 mheap 申请 。源码位于 sizeclass
部分。
3.3 回收释放
这里的回收并非是垃圾回收,而是更简单的内存回收。MSpan 里有 sweepgen 回收标记,回收的内存会先全部回到 MCentral,如果己经回收所有的 MSpan,就还给 MHeap 的空闲列表 。 回收内存的一个很重要的原因是为了复用,所以很多时候并不会直接释放内存。
对于MCache,使用内存时有两种情况:第一种是用完了闲置,第二种是用了但没用完,前者直接标记等待回收就可以,至于多出来没用到的部分就需要另外想办法还给
MCentral,代码见 func freemcache(c *mcache)
和 func (c * mcache )releaseAll()
,以及标记回收func (s *mspan) sweep(preserve bool) bool
。
源码中sysmon 是监控线程,它会遍历 MHeap 中 large 列表里 的所有空闲的 MSpan , 发现空闲时间超过阔值就调用 madvise,让系统内核释放这个线程相关的物理内存 。
经过上面的步骤 , MCache 的空闲 MSpan 己经还给 MCentral 了, 接下来就是 MCentral还给 MHeap 了,这个过程简单来说是当 MSpan 的 object 全部收回时,将 MSpan 归还给Mheap,代码见func (c * mcentral)freeSpan(s *mspan, preserve bool, wasempty bool) bool
。
到了最后, MHeap 那里并不会定时向操作系统归还内存,而是先把相邻的 span 合井,使之成为一块更大的内存,以 page 为单位调度回收。
一 Go垃圾回收机制概述
C++使用指针引用计数方式来回收对象,但是该方式不能处理循环引用!所以之后的GC算法进行了改进,出现了:标记清理、节点复制、分代收集等算法。
Go语言的垃圾回收使用标记清理算法,将需要的内存块进行标记(mark),没有标记的内存块将会被清理(sweep)。Go用到的策略有:
- 并发标记和清理(concurrent mark and sweep)
- 写屏障(write barrier)
- 非分代(non-generational)
- 非紧缩(non-compacting))
二 标记清理算法
2.0 标记清理算法简介
标记清理算法中包含2个区域:
- 标记初始的root区:程序运行到当前时刻的栈和全局数据区域
- 受控堆区:该区域大多数据都是以后不会被用到的,将会被当做垃圾进行回收
判断一个对象是否是垃圾,需要看这个对象是否被当前栈或全局数据区域(root区域)的对象直接或间接地引用 。 如果没有任何对象引用到它,则说明它没有被使用,因此可以安全地当作垃圾回收。
标记清理算法分为两阶段:
- 标记阶段:从 root 区域出发,扫描所有 root 区域的对象直接或间接引用到的对象,将这些对象全部加上标记
- 清理阶段:扫描整个堆区,对所有无标记的对象进行回收
Go在1.5之前,垃圾回收的标记和清理都是STW(Stop The World),即要停止所有的 goroutine ,以此来保证已经被标记的区域不会被再次修改 引用关系,造成清理错误。这样做,每次标记都要STW,效率极低。
Go对GC算法的改进,即避免STW:
- 标记阶段:1.5版本开始,使用三色标记法实现节点的并发
- 清理阶段:1.8版本开始,加入混合写屏障(hybrid write barrier),使GC达到了毫秒级以下
这里会引 出几个问题:怎样实现井发标记?标记记录在哪里?怎样知道对象是否被引
用?什么时候触发清理动作?回收时进程怎么办?
2.1 三色标记法
三色标记的步骤是:
- 1、最开始所有对象都是白色
- 2、扫描所有可达对象,标记为灰色,放入待处理队列;
- 3、从队列里提取灰色对象,将其引用对象标记为灰色放入队列,自身标记为黑色
- 4、写屏障,监控对象内存修改,重新标色或放入队列
- 5、完成标记后对象不是白色就是黑色,清理操作只需把白色对象回收即可。
2.2 并发标记
所谓并发标记:
- 一是指通过 write-barrier (写屏障)能够与用户代码并发进行
- 二是指通过 gc-work 队列实现非递归地标记可达对象,换而言之标记工作不是递归进行的,而是多个 goroutine 并发进行的
贴士:用户程序会一直修改内存,而此时又使用与用户程序并发运行的垃圾回收算法,就需要写屏障。当发现对象己经标记为黑色了,但该对象引用的对象却变了,那么把后来引用的对象变灰入队,原来的被引用对象保持灰色不变。这个 write barrier 是编译器在每一处内存写操作前生成一小段代码来做的。
并发标记要实现非递归地遍历标记可达节点,就需要一个队列。这个队列还可以有助于区分黑色对象和灰色对象,因为标记位只有一个。在队列中的标记是灰色对象,标记了但是不在队列中的是黑色对象,未标记的是白色对象。
实现源码位于函数 gcDrain()
,scanobject()
,greyobject()
。
2.3 标记位
Go将标记位存放在bitmap区域,该区域每个字对应4位标记位
2.4 清理触发
如果频繁垃圾回收会导致CPU的浪费,如果回收启动太晚,则会导致堆内存累计太多,所以需要合理设计垃圾回收的触发条件。
每一次 mallocgc 都会检查是否需要 gcsta扰,触发条件由两个参数决定: gc一trigger 和 gcpercent。
gc_trigger 初始为 4MB, next_gc 初始为 4MB ,之后每次标记完成时将重新计算动态调整值大小 。但 gc_trigger 至少要大于初始的 4MB ,同时至少要比当前使用的 heap 大 1MB才会触发 GC 操作。
这个检查是在堆上分配大于 32KB 对象的时候进行,此时检查是否满足垃圾回收条件,如果满足则进行垃圾回收。
自动垃圾回收相关函数malloc()
,gcShouldStart()
,gcinit()
,gcMark()
。
Go也可以通过 runtime.GC()
手动阻塞触发GC。gcmark 在每次标记结束后会重置阈值大小。如果当前使用了 4MB 内
存,这时设置 gc_trigger 为 2 × 4MB,也就是当内存分配到 8MB 时会再次触发 GC; 回收之后内存为 5MB ,那下一次就要达到 10MB 才会触发 GC 。 这个比例 C triggerRatio )是由gcpercent/ 100 决定的。
gcpercent 的值是通过环境变量 GOGC 获取的,如果不设置这个环境变量,默认值是100。 如果将它设置成 off,则关闭垃圾回收。
如果系统启动或短时间内大量分配对象,会将垃圾回收的 gc_trigger 推高。 在服务正常后,活跃对象远小于这个阈值,造成垃圾回收无法触发,这个问题交给 sysmon 解决,它每隔 2 分钟强制触发 GC 一次。 这个 forcegc 的 goroutine 一直驻留在后台,直到 sysmon 它唤醒开始执行 GC 而不用检查阔值。
三 标记实现
GC 开始之前,有一些准备工作,整个 GC 启动过程都是 STW 状态,它启动了所有将并发执行标记工作的 goroutine,然后进入 GCMark 状态启动写屏障,启动 gcController,对应函数是 gcStart()
。
gcstart 会为所有的 P 都准备好对应的 goroutine worker,但是这些 worker 需要被 gcController 的 findRunnableGCWorker 唤醒才能工作。 M 启动后会一直通过 schedule 查找可执行的 G,其中 gcworker 也是 G 的一部分,但是首先要检查当前状态是不是回收状态,如果是才唤醒 worker 开始标记工作。
标记阶段是并行的,通过在后台一直运行标记worker老实现,对应源码函数是 gcBgMarkStartWorkers()
。
结束后调用 gcMarkDone , 这里会引起 StopTheWorld,接下来进入 gcMarkTermination 中的 gcMark 阶段 。
四 清理
GC标记结束后会触发清理 gcSweep,如果是并发清除,需要回收从 gc_trigger 到当前活跃内存大小相同的 heap 区域,
唤醒后台的 sweep goroutine。
对于并行式清理,在 GC 初始化的时候就会启动 bgsweep(),然后在后台一直循环,它会执行 gosweepone。 sweepone ( 一个内置的检查方法)首先会遍历所有的 spans 看它的 sweepgen 是否需要检查,如果要就检查这个 MSpan 里所有的 object 的 bit (位),看是否需要回收。这个过程可能触发 MSpan 到 MCentral 的回收,最终可能回收到 MHeap 的空闲列表当中。在空闲列表当中的内存在超过一定阁值时间后会被 sysmon 建议交还给内核。
五 监控
Go的GC除了可以自动检测、用户主动调用触发外,Go本身还会对运行状态进行监控,如果超过两分钟没有GC,则触发GC。监控函数是sysmon()
,在主 goroutine 中启动。该goroutine不管有没有P,都会一直运行,所以也不允许写障碍。
一 运行时追踪
1.0 使用gdb追踪
使用gdb可以直观的追踪到程序运行信息,使用步骤如下:
# 随便编译一个有main函数的go文件
go build -o main
# 在gdb模式下开始追踪
info files # 会输出 Entry point: 0x44ae10
b *0x44ae10 # 断点 这个文件,此时会输入如下信息
Breakpoint 1 at 0x44ae10: file /usr/local/go/src/runtime/rt0_linux_amd64.s, line 8.
info symbol 0x44ae10
_rt0_amd64_linux in section .text
b _rt0_amd64
Breakpoint 2 at 0x44ae10: file /usr/local/go/src/runtime/asm_amd64.s, line 15.
b runtime.rt0_go
Breakpoint 3 at 0x44ae10: file /usr/local/go/src/runtime/asm_amd64.s, line 89.
1.1 运行时入口
Go编译出来的程序包含2个部分:
- 运行时:入口为
runtime
包下的asm_amd64.s
文件,完成命令行参数、操作系统、调度器初始化工作,然后创建main goroutine
运行runtime.main
函数。 - 用户逻辑:以main函数为入口
贴士:asm_amd64.s文件只针对linux64平台,入口文件会依据平台不同而不同,该文件核心代码如下:
CALL runtime·args(SB)
CALL runtime·osinit(SB)
CALL runtime·schedinit(SB)
// create a new goroutine to start program
MOVQ $runtime·mainPC(SB), AX // entry
PUSHQ AX
PUSHQ $0 // arg size
CALL runtime·newproc(SB) // 用于创建 goroutine任务
POPQ AX
POPQ AX
// start this M
CALL runtime·mstart(SB) // 让线程进入任务调度模式
MOVL $0xf1, 0xf1 // crash
RET
从运行时创建main goroutine
来看,go程序的整个进程从一开始就以并发模式运行了。
1.2 运行时大致流程
[图片上传失败...(image-afe09f-1605788450759)]
二 运行时初始化
运行时需要对命令行参数、操作系统、调度器初始化等进行初始化。其中最重要的是操作系统和调度器。
本章节只记录系统相关初始化操作,调度器相关位于后文中。
2.1 CPU数量
CPU处理器数量在并发编程中是最重要的指标之一,决定了并行策略、架构设计。
当然现在也有超线程技术(Hyper-Threading),在单个物理核心内虚拟出多个逻辑处理器,类似多线程,将等待时间挖掘出来执行其他任务,以提升整体性能。但是相应的,逻辑处理器之间需要共享一些资源(如缓存刷新),可能也因此拖慢执行效率。
那么Go中的runtime.NumCPU
返回的是物理核数量还是包含超线程的结果呢? (答案是后者)
// runtime2.go中的源码
var ncpu int32
// os_linux.go中的源码:
func osinit() {
ncpu = getproccount() // 返回逻辑处理器数量
}
// debug.go中的源码:
func NumCPU() int {
return int(ncpu)
}
2.2 schedinit
我们看看初始化时候做了哪些操作:
// proc.go
func schedinit() {
sched.maxmcount = 10000 // M最大数量限制
stackinit() // 内存相关初始化
malloclinit() // 内存相关初始化
mcommoninit(_g_.m) // M相关初始化
goargs() // 存储命令行参数
goenvs() // 存储环境变量
parsedebugvars() // 解析GODEBUF参数
gcinit() // 初始化gc
sched.lastpoll = uint64(nanotime()) // 初始化poll时间
// 设置 GOMAXPROCS,新版golang默认设置为cpu核心数
procs := ncpu
if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
procs = n
}
if procresize(procs) != nil {
throw("unknown runnable goroutine during bootstrap")
}
}
完成上述初始化操作后执行:runtime.main
函数。
三 逻辑层初始化
上述初始化是针对运行时内核而进行的,并非逻辑层里的init函数,如runtime包里的init函数,标准库、第三方库中的init函数。
逻辑层初始化位于proc.go
:
// The main goroutine.
func main() {
// 栈最大值:64位位1G
if sys.PtrSize == 8 {
maxstacksize = 1000000000
} else {
maxstacksize = 250000000
}
// 启动后台监控
systemstack(func() {
newm(sysmon, nil)
})
// 执行runtime包内初始化函数
runtime_init()
// 启动时间、启动垃圾回收期
runtimeInitTime = nanotime()
gcenable()
// 执行用户、标准库、第三方库初始化函数
fn := main_init
fn()
// 如果是库,不还行用户入口函数
if isarchive || islibrary {
return
}
// 执行用户入口函数
fn = main_main
fn()
// 退出
exit(0)
}
由上看出,执行了runtime.init
,main.init
,main.main
三个函数。
其中前二者是初始化函数,由编译器动态生成,职能分别为:
- runtime.init:只负责runtime包的初始化
- main.init:标准库、第三方库、用户自定义函数在这里初始化
有疑问加站长微信联系(非本文作者)