Golang GC 相关笔记

Posted on 2023年7月22日周六 技术

模型

mark-sweep GC,从 root(比如局部和全局变量)开始遍历对象,给予标记,之后清除无法访问的对象。

分代回收对 Go 不是特别有效,因为 Go 是在基于逃逸分析选择在堆上或栈上创建对象,而不是直接创建对象在堆中。

涉及两个资源:CPU 和 内存。

目前 Go GC 的设计中,清理速度远快于标记速度,因此标记阶段主要影响 GO GC 的性能。

Go GC 的设计权衡点主要是多占用 CPU 还是多占用内存。

在内存消耗稳定的情况下,GC 的频次越少, CPU 占用越小,具体计算公式见图一,但说明这个关系不需要公式,想象一个极端情况:假如没有 GC,即 GC 频次为 0,那么就永远不会产生 GC 的 CPU 占用。

两个影响 GC 的参数:GOGC 和 memory limit

虚拟内存

特殊优化

在 Go1.19 及之前(不确定以后的实现细节是否会改变)这些改动可以加速 GC:

  1. 从结构体中删除指针字段:GC 扫描指针指向的对象时需要 cache 原结构体的位置,取消结构体中的指针可以避免这种递归扫描。
  2. 在结构体中把指针字段放在最前面:因为 GC 在扫描到最后一个指针字段时就不会扫描剩下的字段,那么提前扫描指针就提前结束。

标记算法

三色算法

步骤:

  1. 开始时所有的对象都是白色。
  2. 遍历所有 root,标记它们为灰色。
  3. 任选一个灰色对象,将其标记为黑色,将其关联的所有白色对象标记为灰色。
  4. 重复以上步骤,直到没有灰色对象。

性质:不允许黑色对象指向白色对象,这样当灰色对象都被染成黑色时,内存中只有黑白两种对象,且黑色与白色不相连,白色对象可被清除。

写屏障

用来保证没有一个黑色对象会指向白色对象。当一个白色对象因为写入而可以被追溯了,写屏障会将这个对象改为灰色。

延迟

https://go.dev/talks/2015/go-gc.pdf 这里的 Go 版本为 1.15。

GC 的主要流程:

  1. 栈扫描
  2. 标记
  3. 标记终止
  4. 清除

两次 stop the world:

  1. 栈扫描开始时,被扫描到 root 的 goroutines 会被暂停;这个过程很快,我理解这是因为具体操作是把 goroutines 栈内的变量放到等候队列里,而不是穷举所有和栈内关联的变量。
  2. 标记终止阶段会有个较长的 stop the world,此时需要确定哪些内存可以被清空,因此不允许内存变化了。

Go 1.15 之前的版本还会在标记时也 stop the world,Go 1.15 做出了并发标记的改动,标记阶段会占用 25% CPU 并在 goroutines 过多的情况下占用用户的 goroutines。右图中的 assist 我理解就是占用用户 goroutines,可能理解有误。

标记阶段中对指针会有写屏障,防止在写指针值的时候改变了依赖关系,导致标记结果出错。

以上改动有效将 Go GC 的延迟降低到了毫秒级别,对比见右图。在 17 年延迟已经被优化到了微秒级别。

注意:即使是毫秒级别,在一些场景下也是无法容忍的,如果各种调整依然无法解决,当前一个许多人正在尝试的方法是用 Rust 重写:

其他

栈空间管理

逃逸分析

两个原则

编译器通过对语法树的分析,找到违背这两个原则的指针,更改分配位置,不断循环,确保所有指针都满足以上两个原则,逃逸分析完成。

逃逸分析把大量小对象的内存位置都锁定在了栈上,降低了堆内存的使用,这是 Go GC 无需采用分代回收来优化 GC 的原因之一。

栈扩容

自 Go1.3 起,Go 不再使用分段栈而是连续栈,当栈空间不足时,以下流程会被触发:

  1. 在内存空间中分配更大的栈内存空间。
  2. 将旧堆中的栈内容复制到新堆中。
  3. 将指向旧栈对应变量的指针重新指向新栈;由于逃逸分析的保证,只需要对所有栈内指针加上偏移量就完成了重新指向。
  4. 销毁并回收旧栈的内存空间。

栈缩容

在 GC 期间,如果一个栈只使用了栈内存的 1/4,那么其内存减半。