Go - GC
逃逸分析
- 在传统的不带 GC 的编程语言中,需要关注对象的分配位置,是分配在
堆
上还是栈
上 - Go 集成了
逃逸分析
功能来自动判断
对象是应该分配在堆上还是栈上- 只有在
代码优化
时,才需要研究具体的逃逸分析规则
- 只有在
1 | package main |
1 | $ go build -gcflags='-m' escape.go |
较大的对象
会被放在堆
上- 如果对象分配在
栈
上,其管理成本
比较低
,只需要挪动栈顶寄存器
就可以实现对象的分配
和释放
- 如果对象分配在
堆
上,需要经过层层的内存申请
过程
逃逸分析
和垃圾回收
结合,可以极大地降低开发者的心智负担,无需再担心内存的分配和释放
抽象成本
Rust -
零成本抽象
- 一切
抽象
皆有成本
,要么花在编译期
,要么花在运行期
- GC 是选择在
运行期
来解决问题,在极端场景下,GC 可能会占用大量的 CPU 资源
内存管理
主要角色
mutator
- 一般指
应用
,即 Application - 可以将
堆
上的对象看作一张图,应用被称为 mutator 的主要原因- 应用在不停地
修改
堆对象的指向关系
- 应用在不停地
allocator
- 即
内存分配器
,应用需要向 allocator 申请内存 - allocator 要维护
内存分配
的数据结构,并考虑在高并发
的场景下,降低锁冲突
garbage collector
- 即
垃圾回收器
死掉的堆对象
和不用的堆内存
都由 garbage collector 回收,并归还给OS
- 过程简述
- 当 GC
扫描
流程开始执行时,garbage collector 需要扫描内存中存活
的堆对象 - 扫描完成后,未被扫描到的对象就是
无法访问
的堆上垃圾,需要回收其占用内存
- 当 GC
交互过程
- 内存分配
- mutator 需要在
堆
上申请内存时,会由编译器
自动调用runtime.newobject
- 此时 allocator 使用
mmap
系统调用从OS
申请内存 - 如果 allocator 发现之前申请的内存还有富余
- 会从本地
预先分配
的数据结构中划分出一块内存,并以指针
的形式返回给应用
- 会从本地
- 在内存分配的过程中,allocator 负责维护内存管理对应的数据结构
- mutator 需要在
- 垃圾回收
- garbage collector 扫描由 allocator 管理的数据结构
- 将应用不再使用的内存,通过
madvise
系统调用归回给OS
内存分配
分配视角
mmap
返回的是连续
的内存空间,mutator
以应用视角
来申请内存
,需要allocator
进行映射转换
应用视角
:需要初始化的 a 是一个 1024000 长度的切片内存管理视角
:需要管理的只是 start + offset 的一段内存
分配效率
- 应用运行期间会不断地创建和销毁
小对象
,如果每次对象的分配和释放都需要与OS
交互,成本极高
- 应该在
应用层
设计好内存分配的多级缓存
,尽量减少小对象高频
创建和销毁时的锁竞争
- 如 C/C++ 的
tcmalloc
,而 Go 的内存分配器基本1:1
搬运了 tcmalloc
- 如 C/C++ 的
- tcmalloc 通过维护一套
多级缓存
结构- 降低了应用内存分配过程中对
全局锁
的使用频率,使得小对象
的内存分配尽量做到无锁
- 降低了应用内存分配过程中对
在 Go 中,根据对象中是否有
指针
以及对象的大小
,将内存分配过程分为 3 类
Type | Cond |
---|---|
tiny | size < 16 bytes && has no pointer(noscan) |
small | has pointer(scan) || (size >= 16 bytes && size <= 32 KB) |
large | size > 32 KB |
tiny
可以将内存分配的路径与 CPU 的多级缓存作类比,
mcache
是本地
的,而mheap
为全局
的L4
是以页
为单位将内存向下派发,由pageAlloc
来管理arena
中的空闲内存
如果L4
依然无法满足
内存分配需求,则需要向 OS 申请内存
L1 | L2 | L3 | L4 |
---|---|---|---|
mcache.tiny | mcache.alloc[] | mheap.central | mheap.arenas |
small
与 tiny 的分配路径相比,缺少
mcache.tiny
L1 | L2 | L3 |
---|---|---|
mcache.alloc[] | mheap.central | mheap.arenas |
large
- 直接从
mheap.arenas
申请内存,直接走pageAlloc
页分配器 pageAlloc
页分配器在 Go 中迭代了多个版本,查找时间复杂度从O(N) -> O(log(n)) -> O(1)
- 在
O(1)
的时间复杂度便可确定能否满足
内存分配需求 - 如果不满足,则需要对 arena 继续进行
切分
,或者向OS
申请更多的 arena
- 在
数据结构
arena
是 Go 向 OS 申请内存的最小单位
,每个 arena 的大小为64 MB
,是一个部分连续
但整体稀疏
的内存结构- 单个 arena 会被切成以
8KB
为单位的page
,由page allocator
管理 - 一个或者多个
page
可以组成一个mspan
,每个 mspan 可以按照sizeclass
再划分
成多个element
- 同样大小的 mspan 又分为
scan
和noscan
两种,分别对应内部
有指针的对象和内部没有指针的对象
每个
mspan
都有一个allocBits
结构
从 mspan 里面分配
element 时,只需要将 mspan 对应的 element 位置的 bit 设为1
即可
垃圾回收
回收算法
- Go 使用的 GC 算法:
并发标记清除
- 将内存中
正在使用
的对象进行标记
,然后清除
那些未被标记
的对象
- 将内存中
- 并发:GC 的
标记
和清扫
过程,能够与应用代码
并发执行,不要与程序设计的并发设计
混淆 并发标记清除
算法有个无法解决的缺陷:即内存碎片
- 在 JVM 的
CMS
,会有内存压缩整理
的过程 - 而 Go 的内存管理是基于
tcmalloc
,本身基于多级缓存
,能在一定程度上缓解
内存碎片的问题
- 在 JVM 的
垃圾分类
语义垃圾
semantic garbage,也称
内存泄露
,从语法
上可达
的对象,但从语义
上,这些对象为垃圾,GC无法回收
语义垃圾
slice 缩容后,底层数组的后两个元素已经无法再访问了,但其关联的堆上内存依然
无法被释放
因此在 slice 缩容前,应该先将底层数组元素置为nil
语法垃圾
syntactic garbage,从
语法
上已经不可达
的对象,这才是 GC 的回收目标
1 | package main |
1 | $ go run -gcflags="-m" alloc_on_heap.go |
回收流程
关键流程
stw
可以使用pprof
的pauseNs
来观测,也可以直接采集到监控系统,官方宣称达到亚毫秒
级
标记
三色抽象
Go 使用
三色
抽象,主要为了让 GC 和应用能并发
执行
Color | Desc |
---|---|
黑 | 本节点已经扫描完毕,子节点扫描完毕(gcmarkbits = 1,且在队列外 ) |
灰 | 本节点已经扫描完毕,子节点尚未扫描完毕(gcmarkbits = 1,且在队列内 ) |
白 | 本节点未扫描,garbage collector 不知道任何相关信息 |
GC 扫描的起点是
根对象
(从.bss
、.data
、goroutine
的栈
开始扫描,最后遍历整个堆
上的对象树)
广度
优先遍历
gc mark worker 会一边从 gcw 弹出对象,一边把其子对象压入 gcw,如果 gcw 满,压入全局队列
堆
上对象的本质为图
,在标记过程中,会有简单的剪枝
逻辑,防止重复标记
,浪费计算资源
1 | // If marked we have nothing to do. |
多个 gc mark worker 可能会产生竞态条件,通过
atomic.Or8
来保证并发安全
1 | // setMarked sets the marked bit in the markbits, atomically. |
协助标记
- 当应用
分配内存过快
时,后台的 gc mark worker无法及时完成
标记工作 - 此时应用在进行
堆内存分配
时,会判断是否需要协助 GC 的标记过程,防止应用OOM
- 相当于让应用线程
让出部分算力
给 GC 线程 - 但协助标记会对
应用的响应延迟
产生影响
- 相当于让应用线程
- Go 内部通过一套
记账还账
系统来实现协助标记流程
对象丢失
在
并发标记
期间,应用还会不断地修改堆上对象的引用关系,可能会导致对象丢失问题
漏标 B,导致 B 被错误回收,如果堆上的对象引用关系满足
三色不变性
,便能解决对象丢失问题
强三色不变性
:禁止黑色对象指向白色对象
基于的假设:黑色已经处于终态
了,GC 不会再关注
弱三色不变性
:黑色对象可以指向白色对象,但指向的白色对象,必须有能从灰色对象可达
的路径
基于的假设:虽然黑色已经处于终态
了,但灰色还在处理中
,还能补救
无论在
并发标记
期间,应用如何修改对象的关系,只要能保证在修改后,堆上的对象能满足三色一致性
即可
三色一致性
的实现依赖于屏障
技术,在 Go 中,即写屏障
(Go 只有write barrier
,没有 read barrier)
回收
进程启动时会有两个特殊的 goroutine
goroutine | desc |
---|---|
sweep.g | 主要负责清扫死对象,合并 相关的空闲页 |
scvg.g | 主要负责向 OS 归还内存 |
- 当 GC 的标记流程结束后,sweep.g 会被唤醒,进行清扫工作
- 针对每个
mspan
,sweep.g 将并发标记期间生成的 bitmap替换
掉分配时使用的 bitmap
根据 mspan 中
槽位情况
来决定 mspan 的未来
- 如果 mspan 中存活对象数为 0,即所有 element 都是垃圾
- 执行
freeSpan
,归还组成该 mspan 所使用的 page,并更新全局的页分配器的摘要信息
- 执行
- 如果 mspan 中没有空槽,说明所有对象都是存活的,将其放入
fullSwept
队列中 - 如果 mspan 中有空槽,说明此 mspan 还可以用来做内存分配,将其放入
partialSweep
队列中
然后 scvg.g 被唤醒,将
页
内存归还给OS