(给Go开发大全
加星标)
来源:DreamService
https://zhuanlan.zhihu.com/p/342792030
【导读】本文结合Golang底层源码和汇编代码,深入解析了go语言垃圾回收机制的设计和实现
0. 冲动&经历&演变
内存管理的目的是什么?目的,防止内存泄漏;核心点,防止程序没有可用内存,『回收』『不再被引用』的堆内存(因为栈内存随时收缩自动释放)。
那么接下来就是,如何知道哪些内存『不再被引用』?
显示管理 常见的有 C 语言,写 C 的人应该都有过内存泄漏的噩梦。后来演生出来 C++,虽然继承了 C 的各种包袱,但尽力通过析构函数、智能指针、异常机制等等途径尝试解决内存问题。
引用计数 c++ 的智能指针便是『引用计数』的思路,如果内存增加了新引用,counter += 1
,同理 counter \-= 1
,如果 counter == 0
释放内存。包括,python、php 在内的许多语言都采用此等思路。
引用计数每次指针操作要进行 counter 的维护,如果 counter 变成 0 还要去 free, 容易拖慢『工作线程』。同时,引用计数无法解决『循环依赖』问题,引入程序 OOM 风险,增大编码工作心智负担。
标记清除 一个与『引用计数』不同的派系,不通过 counter 维护。使用 GC 线程,扫描堆空间的内存依赖关系,标记不被依赖的内存,回收之。如,java/golang 等
附注:策略优缺对比
可见,引用计数是把依赖关系的维护 & 回收内存,分摊到了每次操作。工作线程 顺带做了内存管理的事。相对而言,标记清除是把 GC 工作完全托管给了 GC 线程,工作线程集中精力做业务逻辑。
引用计数没有太多延展空间,java/golang 的 GC 走『标记清除』派系。通过细节优化,以期拿到最大收益:GC 标记线程与工作线程协作:加锁。
如何减少锁粒度?全局大锁(原始的标记清除) => 全局分阶段锁(golang 1.5 的写屏障) => 局部小锁(golang1.7 的混合屏障)=> 期待更徍
1. 标记清除思路
标记出所有不需要回收的对象,在标记完成后统一回收掉所有未被标记的对象。
细节 以局部变量、全局变量等变量为入口,遍历堆内存的依赖关系。生成『堆节点依赖树』,不在树上的节点为可回收。
// https://github.com/golang/go/blob/release-branch.go1.15/src/runtime/mgcmark.go#L60
// gcMarkRootPrepare queues root scanning jobs (stacks, globals, and some miscellany)
// and initializes scanning-related state.
func gcMarkRootPrepare() {
// Compute how many data and BSS root blocks there are.
nBlocks := func(bytes uintptr) int {
return int(divRoundUp(bytes, rootBlockBytes))
}
work.nDataRoots = 0
work.nBSSRoots = 0
// Scan globals.
for _, datap := range activeModules() {
nDataRoots := nBlocks(datap.edata - datap.data)
if nDataRoots > work.nDataRoots {
work.nDataRoots = nDataRoots
}
}
....
}
2. 全局大锁
遍历图生成依赖树,其准确性依赖于遍历过程图不发生变化。最简单的做法是 Stop The World:通过一把大锁,这个期间除了 GC 线程,其它线程全部暂停。
显而易见,『全局大锁』不算一个极佳的方案。STW 导致程序的服务能力突然中止,而这种中止的时长 & 时机又具有不可遇见性。
所以,优化的重点就是让锁的影响降低……
3. 分阶段加锁
通过细化分析,我们拆成『必 lock』阶段 & 『不必 lock』阶段。比如:
- 『无lock』把图遍历一遍,同时记录遍历期间的 modify
- STW, 把 modify 的变量再遍历一遍
3.1 如何避免 LOCK
遍历过程中的并发编辑如何处理?
『转移节点』两种处理