看了一周多的文章,垃圾回收器的实现的确很复杂,而且,Go的垃圾回收实现,每个版本都在改进。

Go目前使用的并发(与用户代码并发执行)三色标记清除算法。如下图

t8lass.png

  • Mutator - 用户程序
  • Collector - 垃圾回收器
  • Mutator Assist(或者称为Mark Assist) - 辅助回收器

垃圾回收的整个过程

t8t2hq.jpg

下面是各个阶段

  1. 清理终结阶段
    暂停应用程序,为下一阶段的标志阶段做准备工作。
  2. 标志阶段

    1. 将状态切换至_GCmark开启写屏障、用户程序协助(Mutator Assiste)并将根对象入队;
    2. 恢复执行程序,标记进程和用于协助的用户程序会开始并发标记内存中的对象。
    3. 开始扫描根对象,包括所有Goroutine的栈、全局对象以及不在堆中的运行时数据结构,扫描Goroutine栈期间会暂停当前处理器
    4. 依次处理灰色队列中的对象,将对象标记成黑色并将它们指向的对象标记成灰色;
    5. 使用分布式的终止算法检查剩余的工作,发现标记阶段完成后进入标记终止阶段;
  3. 标志终止阶段
    暂停程序、将状态切换至_GCmarktermination并关闭辅助标记的用户程序;
  4. 清理阶段

    1. 将状态切换至_GCoff开始清理阶段,初始化清理状态并关闭写屏障
    2. 恢复用户程序,所有新创建的对象会标记成白色;
    3. 后台并发清理所有的内存管理单元,当 Goroutine 申请新的内存管理单元时就会触发清理;

目前整个GC流程会进行两次STW(Stop The World),这段时间内,应用程序处于暂停状态。
第一次发生在清理终结阶段,然后在标记阶段恢复。
第二次发生在标志终结阶段,然后在清理阶段恢复。

可以看到STW的时间越长,对应用程序造成的影响(例如延迟)就越大。Go一直在如何减少STW的方向上改进。

Go1.14之前

Go在1.14之前的版本,需要暂停每一个正在运行的Goruntine才能暂停整个应用程序。正常情况下,整个暂停过程仅需要10-30微秒。

垃圾回收前正在运行的四个Goruntine
tJRNZj.png

垃圾回收器等待Goruntine进行函数调用,函数调用保证Goruntine在一个安全点被暂停。但是可能会出现下面一种情况,其他的Goruntine进行了函数调用,其中一个一直在循环,没有进行函数调用。

func add(numbers []int) int {
     var v int
     for _, n := range numbers {
         v += n
     }
     return v
}

这时候运行在p4上的Goruntine就没有机会被暂停,这时候其它P上的Goruntine被暂停了,同时垃圾回收也无法正常开始。
tJWdne.png

Go1.14引入了抢占式调度,这类Goruntine能够被异步抢占了,不会出现仅仅等待一个Goruntine的暂停而停顿了。

垃圾回收的具体算法

Go垃圾回收使用的是算法是三色标记清除
我们先不说三色,先说下标记清除。整个过程分为两个阶段

  • 标记
    从根对象(全局变量,每个Goruntine栈上的变量)出发查找并标记堆中所有存活的对象。
  • 清除
    回收堆中未被标记的垃圾对象。

Go1.5版本之前使用的就是标记清除,整个过程需要STW,时间到达几百微秒。为了降低整个过程带来的延迟,Go1.5实现了基于三色标记清除并发垃圾回收器。

新的垃圾回收器,垃圾回收的标记过程应用程序能够并发地运行,当第一次STW结束后,垃圾回收器就正式开始标记了。垃圾回收器会使用25%的CPU去执行标记过程。整个标记也是像正常的应用代码一样,在Goruntine里执行。这意味着在一个4线程的Go程序里,1个线程会专门地来执行垃圾回收的标记同时其他3个线程还在继续执行应用程序

tJHWut.png

因为只有1个线程在进行标记,其他3个线程仍在运行,如果正在运行的3个线程不断的分配新的内存,导致内存分配速度超过了标记清除的速度,这就可能会导致内存被分配完的问题。

Go会暂停那些分配内存过快的Goruntine,并将其转去执行一些辅助标记(Mark Assist),从而达到放缓内存分配的目的。

func mallocgc(t typ.Type, size uint64) {
    if enableMarkAssist {
        // 进行标记辅助,此时用户代码没有得到执行
        (...)
    }
    // 执行内存分配
    (...)
}

tJOuPU.png

三色标记清除法

三色标记清除法是为了让标记过程应用程序能够并发执行。

三色标记法将程序中的对象分为白色、黑色和灰色三类。

  • 白色对象
    未被标记的对象,潜在的垃圾对象
  • 灰色对象
    已经被标记的对象,但还没扫描它们指向的对象。
  • 黑色对象
    已经被标记的对象,同时已经将它们指向的对象标为灰色。

整个标记过程

在垃圾收集器开始工作时,程序中不存在任何的黑色对象,垃圾收集的根对象会被标记成灰色。然后会进行下面几个步骤:

  1. 从灰色对象的集合中选择一个灰色对象并将其标记成黑色。
  2. 将黑色对象指向的所有对象都标记成灰色,保证该对象和该对象被引用的对象不会被回收。
  3. 重复上述两个步骤知道对象图中不存在灰色对象,这时候标记阶段结束,对象图中只有黑色的存活对象白色的垃圾对象

tY57Ae.png

存在的问题

如果上面的标记过程用户程序并发执行,那么可能会出现对象被垃圾回收器错误回收的问题。

tYopKx.png
如上图,当A被标记为黑色,且A指向的对象B被标记为灰色后,这时候,如果用户程序建立了从A对象到D对象的引用,因为A已经被标记为黑色了,后续的标记过程,D是没有机会被标记为黑色或灰色,标记阶段结束时,D是白色的对象,会被垃圾回收期回收。

为了解决上面这种问题,Go使用了屏障技术

屏障技术

关于屏障技术,我也是查阅了很多文章,才初步弄懂了。可能我的理解会有问题,可以一起探讨下。先列出一些参考文章。

什么是写屏障、混合写屏障,如何实现?
屏障技术

为了在并发的三色标记算法中,保证正确性,我们需要在用户程序修改对象图后,达成以下两种三色不变性中的任一种

  1. 强三色不变性 - 黑色对象不会指向白色对象,只会指向灰色或黑色对象。
  2. 弱三色不变性 - 黑色对象指向的白色对象,必须包含一条从灰色对象经由多个白色对象的可达路径。

tU3hM6.png

个人的理解:

如果对象图有黑色对象A指向白色对象D(此时强三色不变性被打破),我们已经知道三色标记清除法不会再扫描黑色对象和它引用指向的对象,所以B在标记结束阶段就可能仍然是白色,最终被错误回收。

但是如果存在一条从灰色对象经由多个白色对象的达到对象D的路径(满足弱三色不变性),就像上图右的可以从灰色对象B出发,经过对象E, 到达白色对象D。这样,在后续的标记过程,D最终会被标记为黑色,就不会被错误回收。

如果弱三色不变性都不满足,那D在标记结束时,只能是白色,被错误回收。

Go使用的写屏障(Write barrier)技术,更像一种钩子方法,在用户程序进行指针写操作,能够通知回收器,对对象图中的相应对象着色,保证对象图仍满足三色不变性

Go使用两种写屏障技术,Dijkstra插入写屏障Yuasa删除写屏障

Dijkstra插入写屏障

Dijkstra插入写屏障是为了保证强三色不变性。

// 伪代码
func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(ptr)
    *slot = ptr
}

为了防止黑色对象指向白色对象,应该假设*slot可能已经是黑色,为了确保ptr不会在被赋值到*slot前变为白色,shade(ptr) 会先将指针ptr标记为灰色

tUtnj1.png

如上图,c.ref3 = c.ref3.ref1时,会触发插入写屏障,此时会将被指向的对象B标记为灰色。

tUryPP.png

Dijkstra插入写屏障存在着一些缺点:

  • 相对保守,它会将有可能存活的对象都标记为灰色以满足强三色不变性。在一次回收过程中,会残留一部分对象没有被回收,例如上图的对象B,只有在下一个回收过程才会被回收。
  • 在标记阶段中,每次进行指针赋值操作时,都需要引入写屏障,这无疑会增加大量性能开销;为了避免造成性能问题,Go 团队在最终实现时,没有为所有栈上的指针写操作(因为一个Go程序可能会有几百个协程,为几百个协程栈上的根变量启用写屏障,可能会造成性能问题),启用写屏障,而是当发生栈上的写操作时,将栈标记为灰色,但此举产生了灰色赋值器,将会需要标记终止阶段STW时对这些栈进行重新扫描。

tUa68e.jpg
Go1.7和之前的版本只使用Dijkstra插入写屏障。

Yuasa删除写屏障

Yuasa删除写屏障的引入是为了解决标记终止阶段对栈的重新扫描。

func YuasaWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(*slot)
    *slot = ptr
}

tUskse.png
当一个对象的引用被删除时,会将对象标记为灰色。这样删除写屏障就可以保证弱三色不变性,老对象引用的下游对象一定可以被灰色对象引用。

之前这里我有点理解错了,例如上图里,用户程序,将A的对象指向B对象的引用指向C对象后,B的引用被删除了,所以被标记的对象是B,而不是A(我之前理解是被标记的是A)
还有将B对象指向C对象的指针引用删除后,被标记的是C。

上述过程中的第三步触发了 Yuasa 删除写屏障的着色,因为用户程序删除了 B 指向 C 对象的指针,所以 C 和 D 两个对象会分别违反强三色不变性和弱三色不变性:

强三色不变性 — 黑色的 A 对象直接指向白色的 C 对象;
弱三色不变性 — 垃圾收集器无法从某个灰色对象出发,经过几个连续的白色对象访问白色的 C 和 D 两个对象;
Yuasa 删除写屏障通过对 C 对象的着色,保证了 C 对象和下游的 D 对象能够在这一次垃圾收集的循环中存活,避免发生悬挂指针以保证用户程序的正确性

Yuasa删除写屏障的缺点:

删除写屏障不需要在结束时STW来重新扫描栈,但需要在开始前SWT扫描栈来记录栈的快照。这是因为对栈指针的写入操作上,同样不会开启删除屏障,初始栈指针指向的堆对象不能被保护到,需要通过快照来保护。

假设一个场景,栈上有A对象,堆上有B对象,栈A对象有引用指向堆上的C对象。比较过程开始,A被标为灰色,B被标为灰色 -> 假如B被标为黑色后,执行 B.p = A.p, A.p = nil, 因为此时栈没有开启删除屏障,C对象仍然是白色,黑色的B对象指向了白色的C对象。最终,C对象会被错误地回收。所以需要栈的快照,通过快照来将C标记为灰色,最终C为黑色。

什么是写屏障、混合写屏障,如何实现?里的Yuasa删除写屏障的图可能有点问题,后面我去问下作者。

混合屏障

Go1.8将Dijkstra插入写屏障和Yuasa 删除写屏障结合到一起,形成了混合屏障。混合屏障使得可以并发标志,而且标记结束后,不需要重新扫描栈。

go官方的一段源码注释

// Go uses a hybrid barrier that combines a Yuasa-style deletion
// barrier—which shades the object whose reference is being
// overwritten—with Dijkstra insertion barrier—which shades the object
// whose reference is being written. The insertion part of the barrier
// is necessary while the calling goroutine's stack is grey. In
// pseudocode, the barrier is:
//
//     writePointer(slot, ptr):
//         shade(*slot)
//         if current stack is grey:
//             shade(ptr)
//         *slot = ptr
//
// slot is the destination in Go code.
// ptr is the value that goes into the slot in Go code.
//
// Shade indicates that it has seen a white pointer by adding the referent
// to wbuf as well as marking it.
//
// The two shades and the condition work together to prevent a mutator
// from hiding an object from the garbage collector:
//
// 1. shade(*slot) prevents a mutator from hiding an object by moving
// the sole pointer to it from the heap to its stack. If it attempts
// to unlink an object from the heap, this will shade it.
//
// 2. shade(ptr) prevents a mutator from hiding an object by moving
// the sole pointer to it from its stack into a black object in the
// heap. If it attempts to install the pointer into a black object,
// this will shade it.
//
// 3. Once a goroutine's stack is black, the shade(ptr) becomes
// unnecessary. shade(ptr) prevents hiding an object by moving it from
// the stack to the heap, but this requires first having a pointer
// hidden on the stack. Immediately after a stack is scanned, it only
// points to shaded objects, so it's not hiding anything, and the
// shade(*slot) prevents it from hiding any other pointers on its
// stack.
//

下面是我的一些理解,先放几张图,注意,此时栈上的指针操作不会触发混合写屏障,图中红色的代码语句才会触发

tUqiSU.jpg
A.p = B.p 因为是栈上的指针操作,不会触发写屏障。

tUq9YV.jpg
B.p = nil会触发写屏障,会将对象C标记为灰色,避免了对象A(黑色)指向C对象时,C对象是白色。

整个过程就像注释里说的

shade(*slot) prevents a mutator from hiding an object by moving the sole pointer to it from the heap to its stack. If it attempts
to unlink an object from the heap, this will shade it.

tUqCWT.jpg

D.p = A.p会触发写屏障,因为开始时D.p没有指向对象,所有没有对象会被shade(*slot)标为灰色。

tUqpF0.jpg

但因为当前的栈还没被扫描(current_stack_is_gray),shade(pr)会将A.p指向的对象C标记为灰色。

A.p = nil,因为是栈上的指针操作,不会触发写屏障。如果D.p = A.p时不将C标记为灰色,那么最终就会是黑色对象D指向白色对象C,C就会被错误回收。

整个过程就像注释里说的:

shade(ptr) prevents a mutator from hiding an object by moving
the sole pointer to it from its stack into a black object in the heap. If it attempts to install the pointer into a black object, this will shade it.

为什么要栈还没被扫描时,才会执行shade(ptr),原因是,如果栈已经被扫描过,栈的对象都被标记为黑色,那么它们指向的对象都会是灰色。就拿上面的例子,如果栈内的A被扫描过了,标记为黑色,那么C此时应该就是灰色,后续的操作不需要使用shade(ptr)来标记了。

// 3. Once a goroutine's stack is black, the shade(ptr) becomes
// unnecessary. shade(ptr) prevents hiding an object by moving it from
// the stack to the heap, but this requires first having a pointer
// hidden on the stack. Immediately after a stack is scanned, it only
// points to shaded objects, so it's not hiding anything, and the
// shade(*slot) prevents it from hiding any other pointers on its
// stack.

为了移除栈的重扫描过程,除了引入混合写屏障之外,在垃圾收集的标记阶段,我们还需要将创建的所有新对象都标记成黑色。防止新分配的栈内存和堆内存中的对象被错误地回收,因为栈内存在标记阶段最终都会变为黑色,所以不再需要重新扫描栈空间。

写屏障缓存

go1.10增加了写屏障缓存队列,不马上处理写屏障的标色,而是放入队列。

tdSNg1.jpg

垃圾回收触发时机

内存分配量达到阀值触发GC

每次内存分配时都会检查当前内存分配量是否已达到阀值,如果达到阀值则立即启动GC。
阀值 = 上次GC后的内存分配量 * 内存增长率
内存增长率由环境变量GOGC控制,默认为100,即每当内存扩大一倍时启动GC。

定期触发GC

默认情况下,最长2分钟触发一次GC,这个间隔在src/runtime/proc.go:forcegcperiod变量中被声明。

手动触发

程序代码中也可以使用runtime.GC()来手动触发GC。这主要用于GC性能测试和统计。

参考资料

垃圾收集器

https://rainbowmango.gitbook.io/go/chapter04/4.2-garbage_collection

https://www.ardanlabs.com/blog/2018/12/garbage-collection-in-go-part1-semantics.html

An insight into Go Garbage Collection

GoLab 2019 - Fabio Falzoi - An insight into Go Garbage Collection

Last modification:April 20th, 2021 at 09:49 am
如果觉得我的文章对你有用,请尽情赞赏 🐶