Go垃圾回收系列之九:内存屏障

Go语言精选

共 2555字,需浏览 6分钟

 ·

2021-03-20 10:32

并发标记由于标记协程与用户协程共同工作,带来了很多难题。如果说辅助标记是为了保证垃圾回收能够正常的结束与循环、那么本小节将解决更棘手的问题:准确性。如下所示,假设在垃圾收集已经扫描完根(此时根对象都为黑色),并继续扫描期间,白色对象Z正被一个灰色对象引用。但是此时,工作协程在执行过程中,让黑色的根对象指向了白色的对象Z。由于黑色的对象不会被扫描,这将导致白色对象Z被视为垃圾对象,最终被回收。这就导致了致命的错误。

那么是不是黑色对象一定不能够指向白色对象呢?其实也不一定。如下所示,即便是黑色对象引用了白色对象,但只要是白色对象有一条路劲被灰色对象引用了,那么此白色对象也一定能够被扫描到。


这其实引出了并发标记要保证准确性,即垃圾收集器能够扫描到所有活着的对象,需要遵守的原则。

这就是强弱三色不变性。

强三色不变性 指的是 所有白色的对象都不能够被黑色的对象引用,这是一种比较严格的保证。与之对应的弱三色不变性。

弱三色不变性允许白色对象被黑色对象引用,但是白色对象指向有一条路径,最终是被黑色对象引用的,这保证了该对象最终能够被扫描到。

在并发标记写入和删除对象时,可能会破坏三色不变性,因此必须要有一种机制能够维护三色不变性,这就是屏障策略。屏障策略的原则是保护活着的对象在写入或者能够删除对象的时候将适当的对象变为灰色实现的。例如上例中,如果能够在对象写入时, 将Z对象设置为灰色,那么Z对象将最终被扫描到。


上图提到的这种简单的屏障技术是Dijkstra[1976, 1978]风格的插入屏障,其实现形式如下,如果目标对象src为黑色,则将新引用的对象标记为灰色。

Write(src, i, ref):

src[i] ← ref

  if isBlack(src)

    shade(ref)

又如另一种常见的策略是在删除引用的时候做文章,Yuasa [1990]删除写屏障一旦取消了原引用后,就立即将原引用标记为灰色。


这样即便没有写屏障,插入时也不会破坏三色不变性,如下所示。但是Z对象可能是垃圾对象。


插入屏障与删除屏障通过在写入和删除时重新标记颜色保证了三色不变性,解决了并发标记期间的准确性问题,但是他们都存储浮动垃圾的问题。插入屏障在删除引用的时候,可能一个已经变成垃圾的对象仍然被标记了。而删除屏障在删除了删除引用的时候可能把一个垃圾对象标记为了灰色。这叫做垃圾回收的精度问题但是不会影响其准确性。因为浮动垃圾会在下一次垃圾回收中被收集。

插入屏障与删除屏障独立存在并能够良好工作的前提是对于并发标记期间所有的写入都应用了屏障技术,但现实情况不会如此。大多数垃圾回收语言不会对栈上的操作或寄存器上的操作进行相同的屏障技术,因为栈上操作是最频繁的,每个写入或删除操作应用屏障技术会大大减慢程序的速度。所以在Go1.9之前,尽管应用了插入屏障,但是仍然需要在标记终止期间STW阶段重新重新扫描根对象,来保证三色标记的一致性。而Go1.9之后,使用了混合写屏障技术,结合了类似Dijkstra 与 Yuasa 两种风格。为了了解为什么需要混合写屏障技术,首先来看一看单纯的插入屏障和删除屏障在现实中的困境。

假设栈上一开始的情况如下图,栈上变量P指向了堆区内存。假设现在垃圾回收扫描完了根对象,这时old变量是不会被扫描的,同时进入到了标记阶段

在并发标记阶段,old对象引用了p.x,但是赋值给栈上的变量不会经过写屏障。如果下一步,p.x引用了一个新的内存对象k,并把k标记为灰色,但是并不把原始的对象标记为灰色,这时,原始的对象虽然被栈上的对象old标记了,但是却无法被扫描到,因此会出现致命问题。所以必须在p.x=&k应用删除屏障,在取消引用时,将p.x的原值标记为灰色。

如果只有删除屏障而没有写屏障,也会面临问题:

如下,假设一开始根对象还没开始扫描,全为白色对象时的情形,栈上变量p引用堆区o,栈上变量a引用堆区k, 在并发标记期间,假设开始扫描过了变量p还没开始变量a时的情形如下。

此时,工作协程将变量a置为nil,p.x = &k将对象p指向了k。此时如果只有删除屏障而不启用写屏障(不标记新的值k),会看到当前直接违背三色不变性,让黑色的对象引用了白色的对象。这会导致k无法被标记。

因此,要想在标记终止阶段不用重新扫描根对象,需要使用写屏障与删除屏障混合的屏障技术,其伪代码如下所示:

writePointer(slot, ptr):
shade(*slot)
shade(ptr)
*slot = ptr

在Go语言中,混合屏障依赖于编译时与运行时的共同努力。在标记准备阶段的STW阶段会打开写屏障,具体是让全局变量writeBarrier.enabled设置为true.

var writeBarrier struct {
enabled bool
pad [3]byte
needed bool
cgo bool
alignme uint64
}

编译器会在所有堆写入或删除操作前判断当前是否为垃圾回收标记阶段,如果是则会执行对应的混合写屏障标记对象。在汇编代码中表示如下,其中gcWriteBarrier是与平台相关的操作,执行标记逻辑。

CMPL	runtime.writeBarrier(SB), $0
CALL runtime.gcWriteBarrier(SB)

Go语言构建了一个写屏障指针的缓存池,gcWriteBarrier首先所有被标记的指针会放入到缓存池中,并在容量满之后,一次性全部刷新到扫描工作池中。



推荐阅读


福利

我为大家整理了一份从入门到进阶的Go学习资料礼包,包含学习建议:入门看什么,进阶看什么。关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「进群」,和数万 Gopher 交流学习。

浏览 56
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报