Golang GC 基础: 三色标记清除和 STW
2024-6-6 16:54:11 Author: cloudsjhan.github.io(查看原文) 阅读量:1 收藏

发表于 | 分类于 | 阅读次数: |

| 字数统计: 1,767 | 阅读时长 ≈ 7

今天大家讨论一下 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
func Test1000Allocs(t *testing.T) {
go func() {
for {
i := 123
reader(&i)
}
}()

for i := 0; i < 1000; i++ {
ii := i
i = *reader(&ii)
}

runtime.GC()
}

func Test10000000000Allocs(t *testing.T) {
go func() {
for {
i := 123
reader(&i)
}
}()

for i := 0; i < 10000000000; i++ {
ii := i
i = *reader(&ii)
}

runtime.GC()
}

//go:noinline
func reader(i *int) *int {
ii := i
return ii
}

我们运行每个测试时都启用了跟踪信息:

1
2
3
4
GOGC=off go test -run=Test1000Allocs -trace=trace1.out
GOGC=off go test -run=Test10000000000Allocs -trace=trace2.out
go tool trace trace1.out
go tool trace trace2.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 时的停顿都是可以忽略不计的问题。


-------------The End-------------

cloud sjhan wechat

subscribe to my blog by scanning my public wechat account

0%


文章来源: https://cloudsjhan.github.io/2024/06/06/Golang-GC-%E5%9F%BA%E7%A1%80-%E4%B8%89%E8%89%B2%E6%A0%87%E8%AE%B0%E6%B8%85%E9%99%A4%E5%92%8C-STW/
如有侵权请联系:admin#unsafe.sh