今天大家讨论一下 Golang 中垃圾回收的一些基础知识,这些知识往往让人难以理解。要知道,在垃圾回收过程中,Go 会首先:
(1)将根对象标记为灰色,
(2)然后将它们自己标记为黑色,将它们的后代标记为灰色,
(3)最后删除剩余的(白色)对象。
这就是所谓的标记和清除。但为什么我们需要三种颜色呢?为什么不是两种呢?让我们在下文中进一步讨论这个问题,并确定 STW (stop the world)的实际开销。
标记和清除
如果 GC 的全部任务都归结为标记不能删除的对象和删除未标记的对象,那么为什么不使用双色算法呢?事实上,我们可以简单地用黑色标记所有可到达的对象,然后删除白色的对象。
这一切似乎都合乎逻辑,但现在我们决定让垃圾回收器增量运行,也就是说,我们赋予它将标记阶段分成几个低延迟阶段的能力。
如图所示,在第一阶段,我们首先将节点 1 标记为黑色,然后查找该节点的连接,找到节点 2,并将其也标记为黑色。然后,标记暂停,给应用程序一点 CPU 时间来执行其主要任务。最后,我们进入第二阶段标记。由于没有灰色/黑色之分,我们不知道节点 1 的引用是否已被检查过,因此必须再次检查。算法最终很可能会完成,但不能保证它会在程序本身完成之前完成(导致 OOM)。
为了解决这个问题,我们增加了一个不变量:黑色物体应被视为扫描对象。
这个算法似乎是有效的,即使在增量垃圾回收的情况下也能正确终止。但有一个问题却打破了一切:在这种天真版本的标记和清扫算法的整个运行过程中,我们必须保持程序处于 “STW”状态。
如果在没有 STW 的情况下进行标记和清扫,会发生什么情况?
正如你所看到的,在这两个标记阶段之间,已经添加了许多新对象,但我们并没有标记表明需要对它们进行扫描。这就导致了内存泄漏。唯一的解决办法就是禁止我们的程序(突变器)在垃圾回收器运行时进行任何更改。
三色标记和清扫是如何实现并发垃圾回收的?
正如我们之前所发现的,在标记和扫描算法中,我们遇到了 “世界停止”(STW)的问题。不过,如果我们引入一个额外的标记 “未扫描但不符合删除条件”(灰色),STW 问题就会自动解决!
需要注意的是,在 Golang 中,GC 不仅是增量式的,而且是并发式的。其逻辑与增量方法相同–GC 工作被分为不同的部分,但它们不是按顺序执行,而是由后台工作者并发执行。
下面是 Golang 中的三色标记:
我们将黑色节点引用但尚未扫描的对象标记为灰色节点。让我们看看这如何帮助我们消除 STW。
新添加的对象会被标记为灰色,并由 gcBgMarkWorker2 或其他可用的工作程序进行扫描。这并不能完全消除对 STW 的需求,但会大大减少花费在 STW 上的时间。至少在标记阶段,我们不需要让世界停止运行,以防止堆发生变化。接下来,我们将深入探讨 Golang 中仍会出现的特定 STW。
“Stop the world” in Go is not a problem
其实,仔细想想,在 99% 的情况下,这都与你无关。垃圾回收器的主要工作与程序的执行同时进行,而 STW 在两个阶段之间发生的时间很短:
- 从扫频过渡到标记(扫频终止)。
- 从标记过渡到扫描(标记终止)。
在这两种情况下,世界的停顿都是为了下一阶段做准备:启动 Worker、清除 P 缓存(用 Go 调度器术语来说就是处理器)、等待清扫阶段完成(对于在运行时启动垃圾回收的同时从程序代码中触发 runtime.GC()的情况)、设置/取消写入障碍–而这一切都与已分配对象的数量无关。
如果你分配了 1 兆字节,然后开始分配千兆字节的对象,这并不意味着 STW 会按比例增长,因为 STW 只在下一个标记或扫描阶段的初始化过程中短暂需要。该数据数组的实际标记和扫描是在后台进行的。
STW 的持续时间取决于所涉及的 P 的数量和 goroutines 的数量,并与管理其状态的需要有关。STW 不受堆上分配数量的影响。
世界只在两个短时间内停止:标记终止和扫描终止。标记和扫描在后台进行,不会阻塞应用程序。
Benchmark
这里做一个简单的测试,测试两种分配小对象的情况。我使用 env GOGC=off 禁用了垃圾回收器,并在每次测试结束时直接调用 runtime.GC() 来触发垃圾回收:
1 | func Test1000Allocs(t *testing.T) { |
我们运行每个测试时都启用了跟踪信息:
1 | GOGC=off go test -run=Test1000Allocs -trace=trace1.out |
Test1000Allocs
Test10000000000Allocs
Test10000000000Allocs
在 Test1000Allocs 测试的扫描终止阶段,STW 为 80384 ns。测试 10000000000Allocs 时为 88384 ns。有区别吗?我也没发现。
在 Test1000Allocs 测试的标记终止阶段,STW 为 87616 ns。测试 10000000000Allocs 时为 120128 毫微秒。这里的差异更为明显,但我们谈论的还是毫秒级的微小差异。在 GC 运行的大部分时间里,我们的程序成功并行运行。
在分配数量多、GOGC 值低的情况下,这些带有小 STW 阶段的短 GC 循环可能会频繁出现,并可能被视为一个问题。由于我禁用了 GC,并且只通过直接调用触发了一次,所以测试有点像是合成的。但这恰恰说明,有时值得考虑所选的 GOGC 值。
总之,Golang 中的垃圾回收器显然是并发运行的,在大多数情况下,STW 时的停顿都是可以忽略不计的问题。