Golang 三色标记和混合写屏障
一个没有垃圾回收(Garbage Collection,简称GC)机制的编程语言的内存管理问题绝对会让人头疼,一个友好的编程语言会设计一个垃圾回收机制——垃圾收集器,来自动回收不再使用的对象和内存空间。
Go 作为一个秉承着“少即是多”理念的编程语言,所以能为开发者考虑到的内容都应该由编程语言自己完成,而不需要开发者手动处理,所以 Go 自然也会有自己的垃圾收集器。
垃圾收集器的简单实现是暂停程序(Stop The World, 简称 STW):
当程序使用的内存越来越多,系统中的垃圾也就增加。当程序内存增涨到一个阈值后,整个程序就全部暂停,垃圾收集器会扫描已经分配的所有对象并回收已经不再使用的内存空间。垃圾回收过程结束后,程序才能继续执行。
Go 早期也是使用 STW 进行垃圾回收,但现在已经进化了许多。
一、标记清除
标记-清除(Mark-Sweep)算法是常见的垃圾回收算法,主要有两步:
- 标记(Mark phase):从根对象出发查找并标记堆中所有存活的对象
- 清除(Sweep phase):遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表
GC开始时会 STW,标记所有不可达对象。
如下图所示,内存空间中包含多个对象,我们从根对象出发遍历所有对象及其子对象,并将所有根节点可达的对象都标记为绿色,所有根节点不可达对象标记为白色,这些白色对象就是垃圾:
标记完成后,垃圾收集器会依次遍历堆中的对象并清除其中的垃圾对象:
只有将标记的所有垃圾对象清除后,程序才能恢复运行。
在程序运行的过程中,会不断执行这个垃圾回收过程,直到程序退出。
这是最传统的标记清除算法,因为在垃圾回收过程中需要暂停用户程序—— STW,这是对资源的浪费,需要用更复杂的机制来解决 STW 的问题。
二、三色标记
为了解决原始标记清除算法带来的长时间 STW,多数现代的追踪示垃圾收集器都会实现三色标记算法的变种以缩短 SWT 的时间。
三色标记算法将程序中的对象分为白色、黑色和灰色三类:
- 白色对象:潜在的垃坡,其内存可能会被垃圾收集器回收
- 黑色对象:活跃的对象,包括不存在任何引用外部指针的对象以及从根对象可达的对象
- 灰色对象:活跃的对象,因为存在指向白色对象的外部指针,垃圾收集器会扫描这些对象的子对象
在 GC 开始时,程序中不存在任何的黑色对象,垃圾收集的根对象会被标记为灰色,垃圾收集器只会从灰色对象集合中取出对象开始扫描。当灰色对象集合中不存在任何对象时,即标记完成。
其过程为:
- 将根节点标记为灰色,其他节点标记为白色
- 在灰色对象中选择一个标记为黑色
- 将黑色对象指向的所有对象都标记为灰色
- 重复前两个步骤直到所有对象中没有灰色对象
- 清除所有白色对象
标记阶段完成时,应用程序的堆中不存在任何灰色对象,只有黑色的活跃对象和白色的垃圾对象,垃圾收集器就会回收这些白色对象,下图中的白色对象 \(2\) 就是即将被回收的垃圾:
因为用户程序可能在标记执行的过程中修改对对象的引用,所以三色标记法本身是不可以并发或者增量执行的,仍需要 STW。
在下图所示的标记过程中,如果将对象 \(1\) 标记为黑色后,根对象 \(R_1\) 引用了对象 \(2\) 。由于对象 \(2\) 是被黑色对象引用而不是被灰色对象引用,所以对象 \(2\) 虽然被引用了,标记点却已走到了对象 \(1\) ,对象 \(2\) 也就不可能被标记为灰色,直到标记完成时,对象 \(2\) 仍是白色,就会被垃圾收集器错误回收。
一个正常引用的对象被回收是一个非常严重的错误,这个错误被挑不悬挂指针或野指针,即指针指向了非法的内存地址。
为了解决这个问题,需要并发或增量标记对象,手段就是使用写屏障技术。
三、写屏障技术
内存屏障技术可以让 CPU 或者编绎器在执行内存相关操作时遵守特定的约束。
目前多数的现代处理器都会乱序执行指令以发挥最大性能,但内存屏障技术能保证内存屏障前的操作一定会在内存屏障后的操作之前执行,不会被编绎器或 CPU 打乱执行顺序。
1 三色不变式
想要在并发或增量的标记算法中保证正确性,需要满足下列两种三色不变式:
- 强三色不变式:黑色对象不会指向白色对象,只能指向灰色或黑色对象
- 弱三色不变式:黑色对象可以指向白色对象,但此白色对象链路的上游中必须有一个灰色对象
强三色不变式容易理解,就是上示意图了,下面是弱三色不变式的示意图:
写屏障技术就是在并发或增量标记过程中保证三色不变式的重要技术。
2 插入写屏障
在对象 \(A\) 引用对象 \(C\) 的时候,如果对象 \(C\) 是白色,就将对象 \(C\) 标记为灰色,其他情况则保持不变。
伪码如下:
writePointer(slot, ptr):
shade(ptr)
*slot = ptr
插入写屏弹是一种相对保守的屏障技术,它会将有存活可能的对象都标记为灰色以满足强三色不变式。
在上图所示的垃圾回收过程中,实际上不再存活的的对象 \(B\) 也保留到了最后,没有被回收。如果在第二步时再指 \(A\) 到 \(C\) 的指针指向 \(B\) ,虽然 \(C\) 没有被任何对象引用,但其依然是灰色,不会被回收,只有在下次 GC 时才会被回收。
2.1 优点
- 性能优势
不需要对指针进行任何处理,因为指针的读作操作通常比写操作高出一个或多个数量级
- 前进保障
对象可以从白色到灰色单调转换为黑色,因此总工作量受到堆大小的限制
2.2 缺点
- 由于过于保守,可能会有一部分被误染黑的垃圾对象,只能在下次垃圾回收时被回收
- 在标记过程中,每次进行指针赋值操作时,都需要引入写屏障,会大大增加性能开销。如果关闭栈上的写屏障,当栈上新创建对象时,将新创建的栈对象直接标记为灰色,但这样会产生额外的灰色对象,需要在标记结束前 STW 对栈上的对象重新扫描
3 删除写屏障
删除屏障技术,又称基于起始快照的屏障。其思想是:
当白色或灰色的对象的引用被删除时,将白色对象变为灰色。
删除写屏障通过对对象 \(C\) 的着色,保证了对象 \(C\) 和下游的对象 \(D\) 能够在这一次垃圾收集的循环中存活,避免发生野指针以保证用户程序的正确性。
这样删除写屏障就可以保证弱三色不变式,能够保证白色对象的上游链路中一定存在灰色对象。
四、增量和并发
传统的垃圾收集算法会在垃圾回收的执行期间 STW,一旦触发垃圾回收,垃圾收集器会抢占 CPU 的使用权,占据大量的计算资源以完成标记和清除工作。然而,对于追求实时性的应用程序是无法接受长时间的 STW 的。
以前的计算资源不像现在这么充足,现在的计算机基本都是多核处理器,垃圾收集器一旦开始执行,就会浪费大量的计算资源,为了减少应用程序暂停的时间和垃圾收集的总暂停时间,可以使用下面的策略优化现在的垃圾收集器:
- 增量垃圾回收:增量地标记和清除垃圾,降低用户程序暂停的最长时间
- 并发垃圾回收:利用多核的计算资源,在用户程序执行时并发标记和清除垃圾
因为增量和并发两种方式都可以与用户程序交替进行,所以我们需要使用写屏障技术保证垃圾回收的正确性。与此同时,应用程序也不能等到内存溢出时触发垃圾回收,因为当内存不足时,应用程序已经无法分配内存,这与暂停程序没有区别。
增量和并发的垃圾回收需要提前触发,并在内存不足前完成整个循环,避免程序的长时间暂停。
1 增量回收
增量式的垃圾回收是减少程序最长暂停时间的一种方案,它并不会等 GC 执行完,才将控制权交回程序,而是一步一步执行,跑一点,再跑一点,逐步完成垃圾回收,在程序运行中穿插进行,极大地降低了 GC 的最大暂停时间。
增量垃圾回收需要与三色标记法一起使用,为了保证垃圾回收的正确性,需要在垃圾收集开始前打开写屏障,这样用户程序修改内存会先经过写屏障的处理,保证堆内存中对象关系的强三色不变式或弱三色不变式。
虽然增量垃圾回收能够减少最大暂停时间,但也会增加一次 GC 循环的总时间。在垃圾回收期间,因为写屏障的影响,用户程序也需要承担额外的计算开销,所以增量式的垃圾回收也有缺点,但总体来说是利大于弊。
2 并发回收
并发式的垃圾回收不仅能够减少程序的最大暂停时间,还能减少整个垃圾回收阶段的时间,通过开启读写屏障、利用多核优势与用户程序并行执行。
虽然并发回收能够与用户程序一起运行,但是并不是所有阶段都可以与用户程序一起运行,部分阶段仍需要暂停用户程序。不过与传统的算法相比,并发回收可以将能够并发执行的工作尽量并发执行。
当然,因为读写屏障的引入,并发的垃圾回收也一定会带来额外开销,不仅会增加垃圾回收的总时间,还会影响用户程序。
五、混合写屏障
在 Go 语言 v1.7 版本之前,运行时会使用插入写屏障保证强三色不变式,但是运行时并没有在所有的垃圾回收根对象上开启写屏障。因为应用程序可能包含成百上千个 Goroutine,而垃圾回收的根对象一般包括全局变量和栈对象。
如果运行时需要在几百个 Goroutine 的栈上都开启写屏障,会带来巨大的额外开销,所以 Go 团队在实现上选择了在标记阶段完成时暂停程序、将所有栈对象标记为灰色并重新扫描。在活跃的 Goroutine 非常多的程序中,重新扫描的过程需要 10 ~ 100ms 的时间。
在 v1.8 版本中,由插入写屏障和删除写屏障构成了如下所示的混合写屏障,其流程如下:
- GC 开始,将栈上的全部可达对象标记为黑色,之后便不再需要进行重新扫描
- GC 期间,任何在栈上新创建的对象都标记为黑色
- 写屏障将被删除的对象标记为灰色
- 写屏障将新添加的对象标记为灰色
写屏障不在栈上应用
writePointer(slot, ptr):
shade(*slot)
shade(ptr)
*slot = ptr
下面是一些混合写屏障的场景示意图。
场景一
对象被一个堆对象删除引用,被一个栈对象引用。
第一步,将栈上可达对象全部标记为黑色:
第二步,对象 \(6\) 被对象 \(1\) 引用:
- 写屏障在作用在栈上,所以对象 \(6\) 直接被对象 \(1\) 引用。
第三步,断开与对象 \(5\) 的引用关系:
- 对象 \(5\) 删除与对象 \(6\) 的引用关系,触发写屏障,将对象 \(6\) 标记为灰色
场景二
对象被一个栈对象删除引用,被另外一个栈对象引用。
第一步,栈上新建对象 \(5\) ,栈上新创建的对象是黑色:
第二步,对象 \(5\) 引用对象 \(3\) :
- 直接引用,栈上没有写屏障
第三步,删除对象 \(2\) 和对象 \(3\) 之间的引用关系:
- 直接删除,栈上没有写屏障