取消STW的栈重新扫描

STW:Stop The Word

☞原文传送

摘要

在Go 1.7中,主要的STW时间消耗来自栈重新扫描。我们建议通过切换到混合写屏障来消除对堆栈重新扫描的需要,该屏障结合了Yuasa风格的删除写屏障和Dijkstra风格的插入写屏障。初步实验表明,这可以将最坏情况的STW时间减少到50μs以下,并且这种方法可能同时消除标记终止时的STW。

消除堆栈重新扫描将反过来简化和消除垃圾收集器的许多其他部分,这些部分仅用于提高堆栈重新扫描的性能。比如栈屏障(在运行时的许多部分引入了显著的复杂度)以及维护重新扫描列表。因此,除了显着改善STW时间之外,混合写入屏障还可以降低垃圾收集器的整体复杂性。

背景

Go的垃圾收集器是一个三色并发垃圾收集器。每个对象都被标记为白色、灰色或黑色。在GC循环开始时,所有对象都是白色,垃圾收集器的目标是将所有可达对象标记为黑色,然后释放所有白色对象。为了达到这个目标,垃圾收集器将GC根对象(主要是栈中的对象和全局变量)标记为灰色,然后努力将所有灰色对象标记为黑色,同时满足强三色不变量。

强三色不变量:没有黑色对象包含指向白色对象的指针。

强三色不变量是指在引用关系图中,没有从黑色节点到白色节点的边。也就是说任何黑色节点都不会指向白色节点。

有强三色不变量就有弱三色不变量。

弱三色不变量是指在引用关系图中,黑色节点指向的所有白色节点也可以通过一连串白色节点链从某些灰色节点到达。

在存在并发指针更新的情况下保证三色不变量需要在指针写入和读取上设置屏障。屏障种类有很多,Go 1.7采用的是简单的Dijkstra写屏障,其中,指针写入实现如下:

writePointer(solt, ptr):
	shade(ptr) 	// 将ptr设置为灰色
	*slot = ptr // 指针写入

shade(ptr)ptr指向的对象标记为灰色,如果ptr原本就是灰色或黑色则跳过。这里通过假设*slot是黑色对象并且保证将ptr保存到*slot之前ptr一定不会是白色来保证强三色不变量。

Dijkstra屏障与其他类型的屏障相比具有几个优势。它不需要对指针读取进行任何特殊处理,这具有性能优势,因为指针读取次数往往超过指针写入次数一个数量级或更多。[它也保证了前面的进程](It also ensures forward progress),不像Steele写屏障,对象只能单调的从白色变成灰色再转成黑色,因此受限于堆的大小。

然而,它也有缺点。特别是栈上的指针,对栈上指针的任何写入都必须有写屏障,对于栈来说这是非常昂贵的,或者栈必须是永久的。Go选择了后者,意味着许多栈必须在STW期间被重新扫描。垃圾收集器首先在GC循环开始时扫描所有栈来收集根。但是如果没有栈写屏障,我就不能保证栈不会在稍后包含一个白色对象的引用,所以一个扫描过的栈只能在它的goroutine再次运行之前保持是黑色的,一旦它的goroutine再次运行,栈就会转回灰色。注意,GC的goroutine和程序的goroutine是并行运行的。所以在GC循环的最后,垃圾收集器必须重新扫描灰色的栈,把它们标记为黑色,并完成剩余堆指针的标记。因为必须保证栈在此期间不再变化,因此整个重扫描过程必须要求STW。

上面的过程用图表示如下:

  1. 栈扫描完成以后,栈中的对象A、B、C都标记为黑色,堆中的对象E为灰色,D为白色。

取消STW的栈重新扫描_第1张图片

  1. 这是变更指针,将D对象的地址赋给A。

取消STW的栈重新扫描_第2张图片

  1. 此时如果不进行栈重新扫描,则明明可达的对象D会在GC清除时被无情清除,致使程序出错。因此必须将栈中的对象再变回灰色,等到GC标记阶段的最后再重新扫描栈,并大喊一声:刀下留人。这样对象D才能保留下来。

取消STW的栈重新扫描_第3张图片

接下来就是正常的GC扫描和清除过程。

在具有大量活动goroutine的应用程序中,重新扫描栈可能需要10到100毫秒。

方案

我们建议消除栈重新扫描并用混合写屏障取代Go的写屏障,该屏障结合了Yuasa风格的删除写屏障和Dijkstra风格的插入写屏障。混合写屏障实现如下:

writePointer(slot, ptr):
	shade(*slot) // slot对象持有的引用将被重写,于是写屏障将它着色
	if current stack is grey: // 当前栈是灰色的,表示还未被GC扫描
		shade(ptr) // 将被写入引用着色
	*slot = ptr // 写入引用

写屏障将持有引用被重写的对象变色,并且,如果当前goroutine的栈未被扫描,被写入的引用也变色。

使用混合写屏障不需要重新扫描栈,一旦栈被扫描并标记为黑色,它就会一直保持为黑色。混合写屏障消除了栈重新扫描以及为了支持栈重新扫描而存在的机制,比如栈屏障和重扫描列表。

混合屏障要求新分配的对象被标记为黑色(常见策略是标记为白色,但是和此屏障不兼容)。虽然当前Go的写屏障不要求将新分配对象标记为黑色,但是由于其他原因Go早就将新分配对象标记为黑色,因此在对象分配上不需要修改。

混合写屏障等价于IBM实时Java实现中改编的Metronome中的“双写屏障”。在这种情况下,垃圾收集器是增量的,而不是并发的,但是最终都需要面临限制STW时间的问题。

证明

文章最后给出了混合写屏障的完整证明。这里我们从直观上分析混合写屏障。

与Dijkstra写屏障不同,混合屏障不满足强烈的三色不变量:例如,一个黑色goroutine(栈被扫描过的goroutine)可以将一个白色对象的地址写入一个黑色对象而不将这个白色对象变色。然而它确实满足弱三色不变量。

任何被黑色对象指向的白色对象都可以通过一连串白色指针从一个灰色对象到达(灰色保护)。

弱三色不变量认为一个黑色对象指向一个白色对象时可以的,只要有一条路径保证垃圾收集器可以标记这个白色对象即可。

任何写屏障都必须禁止一个mutator(赋值表达式)隐藏一个对象(隐藏对象:将一个可达对象标记成了不可达);也就是说,不能重新排列堆引用关系图以违反弱三色不变量导致垃圾收集器无法标记可到达的对象。例如在某种意义上,Dijkstra屏障允许mutator(赋值表达式)通过将指向白色对象的唯一指针移动到已经扫描的栈中来隐藏这个白色对象。Dijkstra屏障通过使栈永久化(也就是不可更改,函数式编程语言如haskell就不予许改变变量)并在STW期间重新扫描栈来解决这个问题。

在混合屏障中,通过两个变色函数和一个条件(当前栈是否被扫描)来防止一个mutator(赋值表达式)隐藏对象。

  1. shade(*slot)通过将指向对象的唯一指针从堆移动到栈来防止一个mutator(赋值表达式)隐藏该对象。如果它试图断开和一个堆中的对象的连接,这句代码就会将该对象着色。
  2. shade(ptr)通过将指向对象的唯一指针从栈移动到一个堆中的黑色对象来防止一个mutator(赋值表达式)隐藏该对象。如果试图将指针保存到一个黑色对象中,这句代码就会将这个指针着色。
  3. 如果一个goroutine的栈是黑色的,那么shade(ptr)就没有必要了。shade(ptr)通过将对象从栈移动到堆来防止隐藏它。但是这要求首先栈上要有一个隐藏的指针。栈扫描后,栈只指向着色的对象(灰色+黑色),因此不会隐藏任何对象,并且shade(*slot)避免了隐藏栈上的其他指针。

混合屏障结合了Dijkstra屏障和Yuasa屏障的优点。Yuasa屏障需要在标记开始到扫描以及标记开始到获取栈快照之间STW,但是不需要在标记结束时重新扫描。Dijkstra屏障支持并行标记,但是在标记结束时需要STW来重新扫描栈(尽管可以使用更复杂的非STW方法)。混合屏障继承了两者的优点,允许在标记阶段开始时并行扫描栈,同时在初始扫描后保持栈是黑色的。

理由

混合屏障的优点是它允许栈扫描永久的将栈变为黑色(没有STW,也没有栈写入屏障),这完全消除了对栈重新扫描的需要,从而消除了对栈屏障和重扫描列表的需求。特别是栈屏障对整个运行时引入了显着的复杂度,并且干扰了外部工具(如GDB和基于内核的分析器)对栈的遍历。

此外,与Dijkstra风格的写屏障一样,混合屏障不需要读屏障,因此指针读取是常规的内存读取;[它确保了GC进展](and it ensures progress),因为物体单调地从白色到灰色再到黑色。

混合屏障的缺点很少。它可能会导致更多的浮动垃圾,因为它会在标记阶段的任何时刻保留从根(栈除外)可到达的所有对象。然而,在实践中,目前的Dijkstra屏障也保留着差不多的垃圾。混合屏障也禁止了某些优化:特别是,如果Go编译器可以静态显示一个指针是nil,则它可以省略写屏障,但在这种情况下,混合屏障需要写屏障。这可能会略微增加二进制大小。

替代屏障的方法

也有提出的屏障的几个变种,但我们认为拟议的屏障代表了最好的权衡取舍。

一个基本的变化是取消Dijkstra风格屏障的条件:

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

这个屏障的主要优点是它更容易推理。它直接保证了堆中没有黑色对象指向白色对象的指针,因此黑色对象指向白色对象指针的唯一来源只能是扫描过的栈。栈一旦被扫描,它要指向一个白色对象只能通过遍历可达对象,而任何可以被栈是黑色的goroutine访问的白色对象都是受一个堆中对象灰色保护的。

这种屏障的缺点在于它在大部分标记阶段的消耗是所提出的屏障的两倍。

同样,我们也可以简单的放宽栈的条件:

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

这样做的好处是可以像通道那样安全的进行跨栈写入而不需要特殊的处理,但是当需要执行shade(ptr)时,条件判断需要花更长时间,最终减慢指针写入的速度。

另一种不同的方案是要求在所有堆对象被标记为黑色之前将所有栈着为黑色,这将导致纯Yuasa风格的删除写屏障:

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

就像之前说的,Yuasa屏障需要在继续标记之前获取栈的完整快照。Yuasa认为这对于可以非常快速地执行大容量内存复制的硬件来说是合理的。然而,Yuasa的方案适用于具有一个相对较小的栈的单线程系统环境,而Go程序通常拥有上千个栈,加起来会是一片很大的内存空间。

好消息是该方案并不需要完整的栈快照。只要保证在扫描堆对象之前所有栈都是黑色就足够了。这使得栈扫描可以并行进行,但是也有一个缺点,那就是它在栈扫描和堆扫描之间的标记阶段引入了一个并行瓶颈。这个瓶颈会对goroutine的可用性产生[下游效应](downstream effects),因为分配发生在标记阶段。

最后还有一种[黑色变更](black mutator)屏障技术。然而,正如Pirinen所示,除Yuasa屏障之外的所有可能的[黑色变更](black mutator)屏障都需要读屏障。鉴于指针读写的相对频率,我们认为这对应用程序性能来说是不可接受的。

重扫描的替代方案

更进一步,并发进行栈重扫描而不是取消它也是可能的。这不需要改变写屏障,但确实在堆栈重新扫描中引入了显着的额外复杂性。提案#17505提供了如何在Go中进行并发堆栈重新扫描的详细设计。

其他考虑

通道操作和go语句

混合屏障假设一个goroutine无法对另一个goroutine的栈进行写入。但是有两个操作例外:

  1. 通道发送
  2. 启动goroutine

这两个操作都可以直接将值从一个栈复制到另一个栈。对应通道操作,如果源栈或目标栈是灰色的则shade(ptr)是必须的。对于启动goroutine,目标栈总是黑色的,因此如果源栈是灰色的则shade(ptr)是必须的。

竞争程序(Racy programs)

在并发程序中,两个goroutine可能同时将值存储到同一个指针并在同一个槽上并发调用写屏障。这样做的风险是可能导致屏障无法将那些在顺序执行时应该被着色的对象着色,特别是在宽松的内存模型中。虽然racy Go程序通常是未定义的,但到目前为止我们仍然认为,一个有趣的程序不能轻易地破坏垃圾收集器的健全性(因为一个racy程序可以绕过类型系统,它可以在技术上做任何事情,但只要该程序保持在类型系统内,我们就试图保持垃圾收集器正常运作)。

假设optr是写入之前槽(slot)的值,ptr1ptr2是将被写入槽的指针。Go支持的所有框架都具有一致性,因此对单个内存地址的写入和读取有一个总顺序。如果每个goroutine的栈都被扫描了,那么ptr1ptr2必将被着色,因为shade函数不会从内存中读取。因此难点是goroutine的栈已经被扫描。在这种情况下,屏障简化如下:

Goroutine G1 Goroutine G2
optr1 = *slot
shade(optr1)
*slot = ptr1
optr2 = *slot
shade(optr2)
*slot = ptr2

鉴于我们只处理一个内存位置,一致性属性意味着我们可以推断它是按照顺序执行的。鉴于此,并发执行写屏障允许出现顺序执行不被允许的结果:如果两个屏障在赋值前读取了*slot,则只有optroptr1optr2)会被着色,ptr1ptr2都不会被屏障着色。例如:

Goroutine G1 Goroutine G2
optr1 = *slot
shade(optr1)
*slot = ptr1
optr2 = *slot
shade(optr2)


*slot = ptr2

我们断言这是安全的。假设先写入ptr1。那么上面的代码和直接的跳过写入ptr1是没什么区别的。唯一有区别的情况是另一个Goroutine G3在两次写入的空隙读取了slot。但是,由于我们假设栈已经被扫描了,ptr1一定是被着色过的并且可以从堆中某处到达(并且最终将被着色),因此并发访问 ptr1并不会影响它的着色和可达性。

cgo

如果C代码用nil或C指针覆盖Go内存中的Go指针,则混合屏障可能会成为问题。目前此操作不需要屏障,但是无论用何形式的删除屏障,这里确实需要一个屏障。但是,执行此操作的程序会违反cgo指针传递规则,因为不允许Go代码将内存传递给包含Go指针的C函数。

展望

删除写屏障

在编译器知道正在写入的指针是永久shaded(黑色)的情况下可以省略当前的写屏障,例如nil指针,指向全局变量的指针和指向静态数据的指针。对于混合屏障,这些优化通常是不安全的。但是,如果编译器发现槽(slot)的当前值和正在写入的值都是永久shaded(黑色)的,那么它仍然可以安全地省略写屏障。新分配的对象自动初始化为零值有助于这种优化,因此所有指针变量初始时都指向nil,这是永久着色的(shaded)。

低延迟栈扫描

目前,垃圾收集器在栈扫描时会暂停goroutine。如果goroutine的栈太大,会引入显著的[尾部延迟效应](tail
latency effects)。使用混合屏障并删除现有的栈屏障机制使得栈扫描时只短暂的暂停goroutine。

在此设计中,栈扫描在扫描活动帧时短暂的暂停goroutine。然后在返回下一帧时进行阻塞栈屏障并恢复goroutine。然后栈扫描继续扫描外面的帧,将栈屏障上移。如果goroutine直到栈屏障才返回,在它返回到一个未扫描的帧之前,栈屏障将阻塞直到可以扫描该帧并将屏障进一步向上移动到栈。

有个问题是运行的goroutine可能会在栈扫描期间尝试增长栈。最简单的解决方案是在扫描完成之前阻塞goroutine。

与当前的栈屏障一样,这依赖于通过指针写入其它帧时的写屏障。例如,在部分扫描栈中,一个活动帧可以利用上移指针将一个白色对象的指针从未扫描帧移动到活动帧中。如果写入时没有写入屏障将指针从未扫描帧中移除,那么这个白色对象就可能被隐藏。

但是,由于up-pointers上有写屏障,因此这是安全的。不必在“部分黑色”栈上争论,up-pointers上的写屏障让我们可以将栈视为一系列独立的帧,其中未扫描的帧被视为堆的一部分。没有写入屏障的写入只能发生在活动帧中,因此我们只需要将活动帧视为堆栈。

目前,这种设计在技术上是可行的,但是将它集成到现有的栈屏障机制上的复杂性使其没有吸引力。随着现有的栈屏障消失,这种方法的实现变得相对简单。它在许多方面也比现有的栈屏障更简单,因为任何时刻每个goroutine最多只有两个栈屏障,并且它们仅仅在栈扫描期间存在。

严格限制的标记终止

该提议在严格限制STW标记终止所花费的时间方面走了很长的路,但还有一些其他已知原因导致标记终止暂停时间较长。主要原因是竞争可能会触发标记终止,但仍有剩余的堆标记工作。

并行标记终止

由于栈重新在标记终止之后,并发标记终止的剩余工作以及完全消除标记终止STW将变得切实可行。另一方面,混合屏障可以大大缩短STW,以至于完全消除STW也不是问题。

以下是剩余的标记终止任务不完整列表以及如何解决它们。执行栈扫描的goroutine的栈的扫描可以通过标记期间让它自我扫描来取消掉。消除终结器队列的扫描可以通过向queuefinalizer添加明确的屏障。如果没有这两次扫描,标记终止将不会产生标记工作,因此完成工作队列排放也就没有必要了。

mcache可以在清除阶段滚动同步刷新。刷新堆配置文件可以在扫描开始时立即完成(事实上,这已经是并发安全的)。最后,可以使用原子和全局内存屏障来更新全局变量统计信息。

并发清除终止

同样,消除清除终止的STW也是可能的。由于混合屏障在标记阶段的开始需要一个全局内存栅栏来启动写屏障以及保证所有指针写入在启动写屏障之前对写屏障是可见的,因此要稍微复杂一些。目前,用于清除终止和设置标记阶段的STW实现了这一点。如果我们要让清除终止并发,我们可以使用一个ragged(参差不齐的)屏障来完成全局内存栅栏,或者在最新的Linux内核上使用membarrier系统调用。

兼容性

此提案不会影响语言或任何API,因此符合Go 1兼容性指南。

实现

Austin计划在Go 1.8开发周期中实施混合屏障。对于1.8,我们将在运行时保留堆栈重新扫描支持以进行调试,但默认情况下使用GODEBUG变量禁用它。如果进展顺利,我们将在Go 1.9中删除栈重扫描。

计划实施方法如下:

  1. 修复包含指针的内存行为怪异的地方以及确保他们兼容混合屏障。我们可能会在后续步骤中调试时发现更多问题,但目前我们必须至少进行以下更改:
    1. 确保通道发送引起的栈复制和启动goroutine上的屏障。
    2. 检查我们清除内存的所有位置,因为混合屏障需要区分清除初始化和清除已初始化的内存。这将需要一个屏障感知的memclr并禁用带有类型的指针的duffzero优化。
    3. 检查运行时中所有非托管内存的使用情况,以确保它已正确初始化。这对于非托管内存池尤其重要,例如可能重用内存的fixalloc分配器。
  2. 实现后台标记goroutine栈的并发扫描。目前这些都放在重新扫描列表中,仅在标记终止期间进行扫描,但我们将禁用重新扫描列表。我们可以让背景标记工作者扫描他们自己的栈,或者明确地跟踪背景标记工作者栈上的堆指针。
  3. 修改写屏障以实现混合写屏障,并修改编译器以禁用对混合屏障无效的写屏障省略优化。
  4. 除非设置了GODEBUG环境变量,否则通过在重扫描队列中入队一个无操作(no-op)来禁用栈重扫描。栈写入屏障也是一样。
  5. 使用复选标记模式(checkmark mode)和压力测试来验证没有遗漏任何对象。
  6. 等待Go 1.9开发周期。
  7. 移除栈重扫描、重扫描列表、栈屏障,取消通过GODEBUG变量启用重扫描。可能会切换到低延时栈扫描方案,这样可以重用一些栈屏障机制。

你可能感兴趣的:(Go,go)