8.1 设计原则

8.1 设计原则

TODO: 本节内容编排中,请谨慎阅读

Go 实现的垃圾回收器是无分代(对象没有代际之分)、 不整理(回收过程中不对对象进行移动与整理)、并发(与用户代码并发执行)的三色标记清扫算法。 从宏观的角度来看,Go 运行时的垃圾回收器主要包含五个阶段:

阶段说明赋值器状态
清扫终止为下一个阶段的并发标记做准备工作,启动写屏障STW
标记与赋值器并发执行,写屏障处于开启状态并发
标记终止保证一个周期内标记任务完成,停止写屏障STW
内存清扫将需要回收的内存归还到堆中,写屏障处于关闭状态并发
内存归还将过多的内存归还给操作系统,写屏障处于关闭状态并发

对象整理的优势是解决内存碎片问题以及“允许”使用顺序内存分配器。 但 Go 运行时的分配算法基于 tcmalloc,基本上没有碎片问题。 并且顺序内存分配器在多线程的场景下并不适用。 Go 使用的是基于 tcmalloc 的现代内存分配算法,对对象进行整理不会带来实质性的性能提升。

在这五个阶段中,只有标记、内存清扫和内存归还三个阶段的写屏障状态是保持不变的。 在清扫终止过程中,写屏障先出于关闭状态, 而后对上个垃圾回收阶段进行一些收尾工作(例如清理缓存池、停止调度器等等), 然后才被启动;在标记终止阶段,写屏障先出于启动状态,完成标记阶段的收尾工作后, 写屏障被关闭,并随后对整个 GC 阶段进行的各项数据进行统计等等收尾工作。 而在实际实现过程中,垃圾回收器通过 _GCoff_GCMark_GCMarktermination 三个标记来确定写屏障状态,这时写屏障的启动状态严格的在 _GCoff_GCMark_GCMarktermination 再到 _GCoff 的切换中进行变化。

分代 GC 依赖分代假设,即 GC 将主要的回收目标放在新创建的对象上(存活时间短,更倾向于被回收), 而非频繁检查所有对象。但 Go 的编译器会通过逃逸分析将大部分新生对象存储在栈上(栈直接被回收), 只有那些需要长期存在的对象才会被分配到需要进行垃圾回收的堆中。 也就是说,分代 GC 回收的那些存活时间短的对象在 Go 中是直接被分配到栈上, 当 goroutine 死亡后栈也会被直接回收,不需要 GC 的参与,进而分代假设并没有带来直接优势。 并且 Go 的垃圾回收器与用户代码并发执行,使得 STW 的时间与对象的代际、对象的 size 没有关系。 Go 团队更关注于如何更好地让 GC 与用户代码并发执行(使用适当的 CPU 来执行垃圾回收), 而非减少停顿时间这一单一目标上。

编译标志 go:nowritebarriergo:nowritebarrierrecgo: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的代码可能需要分为两个函数。