查看原文
其他

走进 Golang 垃圾回收

Go开发大全 2021-01-31

(给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』阶段。比如:

  1. 『无lock』把图遍历一遍,同时记录遍历期间的 modify
  2. STW, 把 modify 的变量再遍历一遍

3.1 如何避免 LOCK

遍历过程中的并发编辑如何处理?

『转移节点』两种处理角度:

  • 赋值路径 hookA.child=B.child 后,将 A.child 标为有用
  • 删除路径 hookB.child=NULL 前,将 B.child 标为有用

注:Golang 1.5 使用第一种方案;Golang 1.7 混用两种方案,以最大化优化

路径 hook 分析

对应于可行的两种方案:

  • 在所有的『赋值路径』(不区分转移/赋值)后,无脑标记『有用』
  • 在所有的『删除路径』(不区分转移/删除)前,无脑标记『有用』

遍历结果会遇到两个问题

  • 错标:本来还有用的内存,被标为无用(必须杜绝)
  • 多标:本来无用的内存,被标为了有用(可以接受,比如:删除了唯一路径)

可见,通过 hook 的思路,可以『避免 Lock』。但是,真的 ok 吗?

3.2 Hook 带来的问题

复杂度 参数压栈(栈自动释放),这里增加大量 hook,直至需要 hook pop/push/ret 等汇编指令;协程栈 resize 导致转移,又需要大量 hook…… !!

性能问题 工作线程的『赋值/删除操作』增加标记逻辑,性质等同于『引用计数』的counter +/- 且逻辑更复杂。局部性原理,这种主要影响栈上的操作。

结论,栈上的操作不可以加 Hook,只能作用在堆操作上。

3.3 Dijskra 屏障:Golang1.5 的阶段锁

由上面分析,轻易得出方案思路:

  1. 先『无 Lock』 遍历内存依赖图
  2. 遍历期间通过 『赋值路径 hook』保证堆上的并发 modify 正确
  3. 遍历期间记录发生了 modify 的栈
  4. 遍历结束,『加 Lock』,re-scan 发生了 modify 的栈

进一步,由于只有 GC 期间需要 hook:

  1. 先 『加 Lock』:标记将要开始 GC
  2. ...
  3. ...
  4. ...
  5. 遍历结束,『加 Lock』:re-scan 发生了 modify 的栈,结束 GC 遍历

3.4 Yuasa 屏障:阶段锁另一种可行方案

  1. 加 Lock

  2. 遍历所有的临时/全局等变量,获取堆依赖图的『广度遍历的初始节点』

  3. 标记将要开始 GC

  4. 通过 『删除路径 hook』 保证堆上的并发 modify 正确

  5. GC 线程开始遍历内存依赖图

  6. 遍历结束,『加 Lock』,取消 GC 遍历标记

未被选用的原因

  • 相对于『Dijskra 屏障』 没有明显优势
  • 对节点的处理过于悲观,由『路径分析』一节可知,并发中被『删除』『唯一路径』的节点仍被标为『有用』

4. 局部小锁

4.1 进一步思考阶段锁

为什么赋值路径 hook不像Yuasa 屏障 起始扫描 STW 扫描栈?

  • 扫描栈收集了『对象 A』但是由于栈上编辑没有新增路径屏障,对象 B 无法被保护

为什么 删除路径 hook 不像 Dijskra 屏障 结尾 STW 扫描栈?

  • 由于栈上没有删除路径屏障,对象 B 无法无法被扫描到

由上面分析可知,屏障只能加在堆上

  1. 新增路径屏障,无法保护『栈上的新增』
  2. 删除路径屏障,无法保护『栈上的删除』

4.2 再次分析转移节点

天才,两个屏障一起用,可以解决堆上所有问题。即可避免 Lock。

4.3 混合屏障

// https://github.com/golang/proposal/blob/0bdeab75fa2b7e79fc41fd33f269a5ef6d92e407/design/17503-eliminate-rescan.md#alternative-barrier-approaches

writePointer(slot, ptr):
shade(*slot)
shade(ptr)
*slot = ptr

既然在 『Yuasa 屏障』中,在获取堆的初始节点后,仅靠『删除路径屏障』就可以正常 work。那么,就没必要一直『混合屏障』。

// https://github.com/golang/proposal/blob/0bdeab75fa2b7e79fc41fd33f269a5ef6d92e407/design/17503-eliminate-rescan.md#proposal

writePointer(slot, ptr):
    shade(*slot)
    if any stack is scanning:
        shade(ptr)
    *slot = ptr
    
// 进而演化
writePointer(slot, ptr):
    shade(*slot)
    if current stack is scanning:
        shade(ptr)
    *slot = ptr

4.4 栈上的写怎么处理

局部小锁:锁住被扫描栈对应的协程

// https://github.com/golang/go/blame/release-branch.go1.15/src/runtime/mgc.go#L1945
// https://github.com/golang/go/commit/d6625caf5397a52edc38e19d523a597b531a5f12

systemstack(func() {
    // 当前栈不被 modify, 保证 scan 是安全的
    // We must not modify anything on the G stack. However, stack shrinking is disabled for mark workers, so it is safe to read from the G stack.
 casgstatus(gp, _Grunning, _Gwaiting)
  switch _p_.gcMarkWorkerMode {
   default:
    throw("gcBgMarkWorker: unexpected gcMarkWorkerMode")
   case gcMarkWorkerDedicatedMode:
        ...

4.5 相关源码

参考下面代码 & 汇编结果

// go build -gcflags "-N -l -m" ./a.go

package main
type BaseStruct struct {
name string
age int
}
type Tstruct struct {
base *BaseStruct
field0 int
}

func funcAlloc0 (a *Tstruct) {
a.base = new(BaseStruct)
}

func main() {
a := new(Tstruct)

go funcAlloc0(a)
}
(gdb) disassemble
Dump of assembler code for function main.funcAlloc0:
...
0x0000000000456b4c <+60>: test %al,(%rdi)
# 这里判断是否开启了屏障(垃圾回收的扫描并发过程,才会把这个标记打开,没有打开的情况,对于堆上的赋值只是多走一次判断开销)
0x0000000000456b4e <+62>: cmpl $0x0,0x960fb(%rip) # 0x4ecc50 <runtime.writeBarrier>
...
# 如果是开启了屏障,那么完成 a.base = xxx 的赋值就是在 gcWriteBarrier 函数里面了
0x0000000000456b68 <+88>: callq 0x44d170 <runtime.gcWriteBarrier>
....

攒 Buffer 工作线程需要把自己标记的『灰色』节点提交给 GC 线程,通过 buffer 减少了线程通信的次数

TEXT runtime·gcWriteBarrier(SB),NOSPLIT,$120
  ...
  MOVQ  R14, (p_wbBuf+wbBuf_next)(R13)
  # 检查 buffer 队列是否满?
  CMPQ  R14, (p_wbBuf+wbBuf_end)(R13)
  # 赋值的前后两个值都会被入队
  # 把 value 存到指定 buffer 位置
  MOVQ  AX, -16(R14)  // Record value
  # 把 *slot 存到指定 buffer 位置
  MOVQ  (DI), R13
  MOVQ  R13, -8(R14)
  # 如果 wbBuffer 队列满了,flush,比如置灰,置黑等操作
  JEQ  flush
ret:
  # 真正的赋值:*slot = val
  ...
  RET

flush:
  ...
  //  队列满了,统一处理,这个其实是一个批量优化手段
  CALL  runtime·wbBufFlush(SB)
  ...

5. 附注

5.1 理论依据

5.1.1 三色不变式

通过不变式约束,保证任何有用的内存节点都不会错标为无用。但不负责无用内存多标为有用。

三色标记

广度遍历过程中,不同遍历状态节点的『助记』标记。

  • 黑色:已经被遍历过了
  • 灰色:待遍历节点
  • 白色:未触及节点

强三色不变式

白色节点不允许存在黑色的上游节点

助解:如果某节点的上游节点已经『扫描结束』(黑色),当前节点至少为『待扫描』状态(灰色)。

如果黑色节点为白色节点的『唯一』上游,这个节点就会被扫描漏掉。

弱三色不变式

强三色可以不满足;必须保证一个前提,这个白色对象必须处于灰色对象的保护下。

助解:如果发生『扫描结束』节点变成某节点的上游,至少保证有一个『待扫描』节点为其上游。

只要有灰色节点作为白色节点的上游,这个节点就不会被扫描漏掉。

5.1.2 Dijskra 屏障示意图(网络盗图)

5.1.3 Yuasa 屏障示意图(网络盗图)

5.1.4 混合屏障示意图(网络盗图)

场景一:对象被一个堆对象删除引用,成为栈对象的下游

场景二:对象被一个栈对象删除引用,成为栈对象的下游

场景三:对象被一个堆对象删除引用,成为堆对象的下游

场景四:对象被一个栈对象删除引用,成为另一个堆对象的下游

5.2 Golang 的技巧

5.2.1 内存管理 BitMap

5.2.2 逃逸分析

为什么需要了解逃逸分析?因为只有堆上对象的写才会可能有写屏障

go build \-gcflags "-N \-l \-m" ./a.go

在保证程序正确性的前提下,尽可能的把对象分配到栈上,这样性能最好;栈上的对象生命周期就跟随 goroutine ,协程终结了,它就没了。

5.2.3 禁止跨栈指针

不允许存在跨栈指针,因为当栈空间增长/缩减时,栈的内存段可能会变。而且,如果支持跨栈指针,那么 Runtime 需要单独维护这些指标,势必增加 GC 开销。

5.2.4 GC Assist

当存在新的内存分配时,会暂停分配内存过快的那些 goroutine,并将其转去执行一些辅助标记(Mark Assist)的工作,从而达到放缓继续分配、辅助 GC 的标记工作的目的。

5.3 策略优缺对比

5.3.1 引用计数

优点

  1. 直观,每被引用一次 counter += 1
  2. 简单,无需额外的线程(计算资源)、内存(存储资源)去维护复杂的 GC 状态
  3. 及时,一旦没有使用,最后一个释放的逻辑相继释放内存

缺点

  1. 影响效率:counter 的 +/-counter=0 的 free,导致『工作线程』拖慢计算逻辑
  2. 循环依赖:内存泄漏,进而引入程序 OOM 风险

5.3.2 标记清除

优点

  1. 无需维护 counter, 工作线程专心工作
  2. 通过扫描所有节点的依赖关系,不担心循环依赖引起的内存泄漏

缺点

  1. 额外的线程(计算资源)、内存(存储资源)去维护复杂的 GC 状态
  2. GC 线程不能保证及时回收掉无用资源,导致系统颠簸
  3. GC 线程与工作线程并发工作,需要一定的锁机制,严重时系统『假死』

6. 参考资料 & 致谢

  • Golang GC基本原理与优化方向
  • Golang源码探索(三) GC的实现原理
  • golang 垃圾回收(二)屏障技术
  • Eliminate STW stack re-scanning

 - EOF -

推荐阅读(点击标题可打开)

1、亲测好用的 K8s & DevOps 工具

2、使用人工智能优化 Golang 编译器

3、K8S 集群内 Debug 微服务的最佳实践

如果觉得本文不错,欢迎转发推荐给更多人。


分享、点赞和在看

支持我们分享更多好文章,谢谢!

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存