Go 实现的垃圾回收器是无分代(对象没有代际之分)、 不整理(回收过程中不对对象进行移动与整理)、并发(与用户代码并发执行)的三色标记清扫算法。 从宏观的角度来看,Go 运行时的垃圾回收器主要包含五个阶段:
阶段 说明 赋值器状态 写屏障状态
清扫终止 为下一个阶段的并发标记做准备工作,启动写屏障 STW 启动
标记 与赋值器并发执行,写屏障开启 并发 启动
标记终止 保证一个周期内标记任务完成,停止写屏障 STW 关闭
内存清扫 将需要回收的内存归还到堆中,写屏障关闭 并发 关闭
内存归还 将过多的内存归还给操作系统,写屏障关闭 并发 关闭
https://github.com/changkun/go-under-the-hood/blob/master/book/zh-cn/part2runtime/ch08GC/basic.md
对象整理的优势是解决内存碎片问题以及“允许”使用顺序内存分配器。但 Go 运行时的分配算法基于 tcmalloc,基本上没有碎片问题。 并且顺序内存分配器在多线程的场景下并不适用。Go 使用的是基于 tcmalloc 的现代内存分配算法,对对象进行整理不会带来实质性的性能提升。
分代 GC 依赖分代假设,即 GC 将主要的回收目标放在新创建的对象上(存活时间短,更倾向于被回收),而非频繁检查所有对象。但 Go 的编译器会通过逃逸分析将大部分新生对象存储在栈上(栈直接被回收),只有那些需要长期存在的对象才会被分配到需要进行垃圾回收的堆中。也就是说,分代 GC 回收的那些存活时间短的对象在 Go 中是直接被分配到栈上,当 goroutine 死亡后栈也会被直接回收,不需要 GC 的参与,进而分代假设并没有带来直接优势。并且 Go 的垃圾回收器与用户代码并发执行,使得 STW 的时间与对象的代际、对象的 size 没有关系。Go 团队更关注于如何更好地让 GC 与用户代码并发执行(使用适当的 CPU 来执行垃圾回收),而非减少停顿时间这一单一目标上。
内存模型
语言的内存模型定义了并行状态下拥有确定读取和写入的时序的条件。 Go 的 goroutine 采取并发的形式运行在多个并行的线程上, 而其内存模型就明确了 对于一个 goroutine 而言,一个变量被写入后一定能够被读取到的条件。 在 Go 的内存模型中有事件时序的概念,并定义了 happens before ,即表示了在 Go 程序中执行内存操作的一个偏序关系。
我们不妨用 < 表示 happens before,则如果事件 e1 < e2,则 e2 > e1。 同样,如果 e1 ≥ e2 且 _e1 ≤ e2,则 e1 与 e2 happen concurrently (e1 = e2)。 在单个 goroutine 中,happens-before 顺序即程序定义的顺序。
我们稍微学院派的描述一下偏序的概念。 (严格)偏序在数学上是一个二元关系,它满足自反、反对称和传递性。happens before(<)被称之为偏序,如果满足这三个性质:
(反自反性)对于 ∀e1∈{事件},有:非 e1 < e1;
(非对称性)对于∀e1, e2∈{事件},如果 e1 ≤ e2,e2 ≤ e1 则 e1 = e2,也称 happens concurrently;
(传递性)对于∀e1, e2, e3 ∈{事件},如果 e1 < e2,e2 < e3,则 e1 < e3。
可能我们会认为这种事件的发生时序的偏序关系仅仅只是在探讨并发模型,跟内存无关。 但实际上,它们既然被称之为内存模型,就是因为它们与内存有着密切关系。 并发操作时间偏序的条件,本质上来说,是定义了内存操作的可见性。
编译器和 CPU 通常会产生各种优化来影响程序原本定义的执行顺序,这包括:编译器的指令重排、 CPU 的乱序执行。 除此之外,由于缓存的关系,多核 CPU 下,一个 CPU 核心的写结果仅发生在该核心最近的缓存下, 要想被另一个 CPU 读到则必须等待内存被置换回低级缓存再置换到另一个核心后才能被读到。
Go 中的 happens before 有以下保证:
初始化:main.init < main.main
goroutine 创建: go < goroutine 开始执行
goroutine 销毁: goroutine 退出 = ∀ e
channel: 如果 ch 是一个 buffered channel,则 ch<-val < val <- ch
channel: 如果 ch 是一个 buffered channel 则 close(ch) < val <- ch & val == isZero(val)
channel: 如果 ch 是一个 unbuffered channel 则,ch<-val > val <- ch
channel: 如果 ch 是一个 unbuffered channel 则,len(ch) == C => 从 channel 中收到第 k 个值 < k+C 个值得发送完成
mutex: 如果对于 sync.Mutex/sync.RWMutex 的锁 l 有 n < m, 则第 n 次调用 l.Unlock() < 第 m 次调用 l.Lock() 的返回
mutex: 任何发生在 sync.RWMutex 上的调用 l.RLock, 存在一个 n 使得 l.RLock > 第 n 次调用 l.Unlock,且与之匹配的 l.RUnlock < 第 n+1 次调用 l.Lock
once: f() 在 once.Do(f) 中的调用 < once.Do(f) 的返回.
TODO: 谈及内存模型与实现的 barrier 之间的关系
编译标志 go:nowritebarrier、go:nowritebarrierrec 和 go:yeswritebarrierrec
如果一个函数包含写屏障,则被 go:nowritebarrier 修饰的函数触发一个编译器错误,但它不会抑制写屏障的产生,只是一个断言。 go:nowritebarrier 主要适用于在没有写屏障会获得更好的性能,且没有正确性要求的情况。 我们通常希望使用 go:nowritebarrierrec。
如果声明的函数或任何它递归调用的函数甚至于 go:yeswritebarrierrec 包含写屏障,则 go:nowritebarrierrec 触发编译器错误。
逻辑上,编译器为每个函数调用添加 go:nowritebarrierrec 且当遭遇包含写屏障函数的时候产生一个错误。 go:yeswritebarrierrec 则反之。go:nowritebarrierrec 用于防止写屏障实现中的无限循环。
两个标志都在调度器中使用。写屏障需要一个活跃的 P (getg().m.p != nil)且调度器代码通常在没有活跃 P 的情况下运行。 在这种情况下,go:nowritebarrierrec 用于释放 P 的函数上,或者可以在没有 P 的情况下运行。 而且go:nowritebarrierrec 还被用于当代码重新要求一个活跃的 P 时。 由于这些都是函数级标注,因此释放或获取 P 的代码可能需要分为两个函数。
这两个指令都在调度程序中使用。 写屏障需要一个活跃的P( getg().mp != nil)并且调度程序代码通常在没有活动 P 的情况下运行。 在这种情况下,go:nowritebarrierrec 用于释放P的函数或者可以在没有P的情况下运行并且去: 当代码重新获取活动P时使用 go:yeswritebarrierrec。 由于这些是功能级注释,因此释放或获取P的代码可能需要分为两个函数。
进一步阅读的参考文献
The Go Memory Model
[Hiltner 2017] Rhys Hiltner, An Introduction to go tool trace, July 13, 2017
Simplify mark termination and eliminate mark 2
Runtime: error message: P has cached GC work at end of mark termination
Request Oriented Collector (ROC) Algorithm
Proposal: Separate soft and hard heap size goal
Go 1.5 concurrent garbage collector pacing
runtime/debug: add SetMaxHeap API
runtime: mechanism for monitoring heap size
在诸多屏障技术中,Go 使用了 Dijkstra 与 Yuasa 屏障的结合, 即混合写屏障(Hybrid write barrier)技术 [Clements and Hudson, 2016]。 Go 在 1.8 的时候为了简化 GC 的流程,同时减少标记终止阶段的重扫成本, 将 Dijkstra 插入屏障和 Yuasa 删除屏障进行混合,形成混合写屏障,沿用至今。
基本思想
该屏障提出时的基本思想是:对正在被覆盖的对象进行着色,且如果当前栈未扫描完成, 则同样对指针进行着色。
但在最终实现时原提案 [Clements and Hudson, 2016] 中对 ptr 的着色还额外包含 对执行栈的着色检查,但由于时间有限,并未完整实现过,所以混合写屏障在目前的实现是:
// 混合写屏障
func HybridWritePointerSimple(slot unsafe.Pointer, ptr unsafe.Pointer) {
shade(slot)
shade(ptr)
*slot = ptr
}
在 Go 1.8 之前,为了减少写屏障的成本,Go 选择没有启用栈上写操作的写屏障, 赋值器总是可以通过将一个单一的指针移动到某个已经被扫描后的栈, 从而导致某个白色对象被标记为灰色进而隐藏到黑色对象之下,进而需要对栈的重新扫描, 甚至导致栈总是灰色的,因此需要 STW。
混合写屏障为了消除栈的重扫过程,因为一旦栈被扫描变为黑色,则它会继续保持黑色, 并要求将对象分配为黑色。
混合写屏障等同于 IBM 实时 JAVA 实现中使用的 Metronome 中使用的双重写屏障。 这种情况下,垃圾回收器是增量而非并发的,但最终必须处理严格限制的世界时间的相同问题。
混合写屏障的正确性
直觉上来说,混合写屏障是可靠的。那么当我们需要在数学上逻辑的证明某个屏障是正确的,应该如何进行呢?
TODO:补充正确性证明的基本思想和此屏障的正确性证明
实现细节
TODO:
批量写屏障缓存
在这个 Go 1.8 的实现中,如果无条件对引用双方进行着色,自然结合了 Dijkstra 和 Yuasa 写屏障的优势, 但缺点也非常明显,因为着色成本是双倍的,而且编译器需要插入的代码也成倍增加, 随之带来的结果就是编译后的二进制文件大小也进一步增加。为了针对写屏障的性能进行优化, Go 1.10 和 Go 1.11 中,Go 实现了批量写屏障机制。 其基本想法是将需要着色的指针统一写入一个缓存, 每当缓存满时统一对缓存中的所有 ptr 指针进行着色。
TODO:
小结
并发回收的屏障技术归根结底就是在利用内存写屏障来保证强三色不变性和弱三色不变性。 早期的 Go 团队实践中选择了从提出较早的 Dijkstra 插入屏障出发, 不可避免的在为了保证强三色不变性的情况下,需要对栈进行重扫。 而在后期的实践中,Go 团队提出了将 Dijkstra 和 Yuasa 屏障结合的混合屏障, 将强三色不变性进行了弱化,从而消除了对栈的重新扫描这一硬性要求,使得在未来实现全面并发 GC 成为可能。
进一步阅读的参考文献
[Clements and Hudson, 2016] Eliminate STW stack re-scanning
[Dijkstra et al. 1978] Edsger W. Dijkstra, Leslie Lamport, A. J. Martin, C. S. Scholten, and E. F. M. Steffens. 1978. On-the-fly garbage collection: an exercise in cooperation. Commun. ACM 21, 11 (November 1978), 966-975.