不时以屏障遮羞的三色标记丨Golang GC 是怎么减少STW影响的?

317 阅读6分钟

介绍

(自动)垃圾回收这个概念是现代高级语言中出现的概念,相比传统手动管理内存的语言,垃圾回收意味着「引用的丢失」。(这在手动gc语言中反而直接意味着内存泄漏,因为我们没法再手动清理)

现代语言都使用的是“不够实时的”标记清除的基础gc算法,来进行垃圾回收。释放不需要的对象,回收空闲内存。好处是能够处理循环引用,整体算法上对cpu负担更小一点。而其中最大的坏处则是有 Stop The World 的 gc pause 时间(因为他没有即时去维护,所以“标记”的过程必须去暂停)。

java使用了分代回收的思路来对此进行优化,而golang整体上是 「三色标记 + 混合写屏障」 的机制

STW和并发引入

在 go 1.5 之前,GC 是独立的,完全 STW 的。

这个意味着,开始进行垃圾回收的时候,其他所有协程是完全停止的。

之后为了减少 gc pause,引入了并发

回顾标记清除的过程:标记所有“存活对象中的可达对象”,清理掉不可达的对象。

并发会造成的问题:

  1. 错删:gc协程已经确定一个对象是可删的,但是并发的用户协程在这之后写进来了引用
  2. 漏删:gc协程已经确定一个对象是不可删的,但是并发的用户协程在这之后删除了引用

这两个并发问题其实很自然。根源在于你gc协程对一个对象的状态确定,整体并没有一个原子性,用户协程可以并发,状态难以保持一致,标记阶段结束后引用关系会被随意修改,而这个时候还在你的gc流程中(还没开始删)。

这两问题实际上跟三色标记并没有关系。 三色对象是标记清除的一种实现,他的要点是把对象的标记阶段分为黑白灰三个阶段,黑色意味着扫描结束,灰色意味着等待被扫描。

具体到三色标记中,再把这两个问题展开就好了

  1. 错删:一个对象已经扫完置黑,那么他假如引用了一个白对象。这个白对象就会被误删。至于这个白对象是怎么来的?就经常就是用户协程中刚刚被删除引用的对象了。
  2. 漏删:一个本有引用的对象,刚刚被gc协程置灰,然后用户协程就把这个引用删了。这个对象后续是会变黑的。

可以看到,“三色”的目的,其实就是为了让并发问题更清晰。“黑色”是已经扫完的了,不可能再把他引用的对象置白。

屏障的出现

上面描述的两个并发问题,漏删是可以接受的,等下一轮就好了。但是错删明显是不可接受的。

问题解决的思路,跟一般并发问题解决有所不同。这里要加锁的话,那就基本回到 1.4 的 完全STW 版本了。对单个对象加细粒度的锁也不合适,消耗太大。

所以引入了屏障机制。这是个比较厉害的想法:允许你并发做这些操作,我们可以通过增加一些可能冗余的操作来确保你并发操作不会出现问题。

对于「错删」的并发问题,里边涉及到删除引用使得一个对象应该被删,但是后面又被写入引用两个操作。 这两个操作,如果我们监测到,都能加以保护。

具体屏障指的就是,在gc过程中的用户协程,会被运行时给监控。当你进行了指定操作的时候,会触发保护性的操作。

而go 1.5-1.7 使用的是「插入屏障」:

  1. 监测到黑色对象引用白色对象
  2. 触发操作:把白对象置灰

对象都灰了,那么就不会出现误删这种严重的问题。

伪代码:

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

也就是说你其实写指针的相关操作会被替换成这个函数

stack rescan

这个方法看似完美,可惜我们并没有那么充裕,能够在栈区也应用写屏障。

  • 我们知道栈上的对象,不管是大小和类型,一般都是编译时就确定好的。
  • 同时栈区资源非常宝贵,他是被频繁上下文切换的。

在栈上的操作极其昂贵,所以我们并不能把「插入屏障」应用到栈上。

go 1.5-1.7 采用的方案是:

  1. 正常在栈上用三色标记扫一遍,得到黑白对象
  2. 由于我们没用我们的「插入屏障」,所以不能确定白色对象是一定要被清除的,可能有被黑色对象引用
  3. 基于这个考虑,我们保守起见又把所有白色m对象置灰
  4. 进stw,resacn,重新按正常流程扫一遍。因为stw了,所以现在可以确保

为什么不一开始就 STW?

我们前面扫一遍其实还是有节约一部分性能的。rescan操作之前,他面对的所有灰色,之前可能并不知道。这就是之前没stw的扫描成果。

混合写屏障

stw、stack rescan操作是非常耗时的,跟最开始的问题一样,不过是范围缩小到了栈且性能有部分优化而已。

目标:避免 rescan

核心问题落在“如何确保扫一遍的过程中,没有栈上白色对象被黑色对象引用”

栈上的昂贵开销,让我们不禁思考:我们直接不管栈对象,是否能解决这个问题? 栈毕竟是临时分配的内存,也不用太担心漏删。

最终方案基本还真是这样。如果只考虑栈的话,漏删不担心,“黑色引用的白色会被误删”的问题其实也是没有的。 因为这个白色能够建立引用,一定在栈内部可达。

但是如果把堆也考虑上的话,那就不一定了。比如说堆上有黑色对象新写入一个白色引用,这个引用其实是通过栈上对象来间接引用的。这个时候你对已经扫完了,那这个白色对象肯定会被删了。 这个时候就需要双屏障来确保这个白对象一定会被置灰。

我们再考虑一个情况。栈上有黑色对象引用了一个堆上白色。这个时候插入屏障会起作用。

也就是说所以情况都能在 堆侧屏障 被解决。

最终方案:

  • 新在栈上建立的对象在gc过程中直接置黑
  • 堆上启用混合写屏障

伪代码

writePointer(slot, ptr):
    shade(*slot)
    if current stack is grey:
        shade(ptr)
    *slot = ptr

Refs&Acknowledgements