深入理解 Golang 垃圾回收机制

Go 是一种有垃圾收集机制的语言。这使得 Go 代码编写更简单,用更少的时间管理已分配对象的生命周期。

Go 中的内存管理比 C++ 中的更容易。但这也不是我们作为 Go 开发人员可以完全忽略的领域。其中垃圾收集器是这个领域的关键部分。了解 Go 如何分配和释放内存可以让我们编写更好、更高效的应用程序。

为了更好地理解垃圾收集器的工作原理,我决定在实时应用程序上跟踪它的低级行为。在本次调查中,我将使用 eBPF uprobes 检测 Go 垃圾收集器。这篇文章的源代码在这里https://github.com/pixie-io/pixie-demos/tree/main/go-garbage-collector

背景
  • 为什么要 uprobe?

  • 垃圾收集的阶段

跟踪垃圾收集器
  • 运行时 GC

  • 标记和扫描阶段

  • STW 事件

垃圾收集器如何调整自己的速度?
  • 触发率

  • 标记和清理辅助工作

深入前的几件事

在深入研究之前,让我们快速了解一下 uprobes、垃圾收集器的设计以及我们将使用的演示应用程序。

为什么要 uprobe?

uprobes 很酷,因为它们让我们无需修改代码即可动态收集新信息。当您不能或不想重新部署您的应用程序时,这很有用 - 可能是因为它正在生产中,或者有的行为难以重现。

函数参数、返回值、延迟和时间戳都可以通过 uprobes 收集。在这篇文章中,我将把 uprobes 部署到 Go 垃圾收集器的关键函数上。这将让我看到它在我正在运行的应用程序中的实际表现。

0ac0852d91dfee9f74ef64534d492c86.png

uprobes 可以跟踪延迟、时间戳、参数和函数的返回值

注意:这篇文章使用 Go 1.16。我将在 Go 运行时中跟踪私有函数。但这些功能在 Go 的后续版本中可能会发生变化。

垃圾收集的阶段

Go 使用并发标记和清除垃圾收集机制。对于那些不熟悉这些术语的人,这里有一个快速摘要,以便您可以理解帖子的其余部分。您可以在此处https://agrim123.github.io/posts/go-garbage-collector.html找到更多详细信息。

Go 的垃圾收集器被称为并发,因为它可以安全地与主程序并行运行。换句话说,它不需要来停止你的程序的执行来完成它的工作。

垃圾收集有两个主要阶段:

  • 标记阶段:识别并标记程序不再需要的对象。

  • 清理阶段:对于标记阶段标记为“无法访问”的每个对象,释放内存以供其他地方使用。

2f23d6df0cafd2fcac66eb1b0846a56f.png

一种节点着色算法。黑色物体仍在使用中。白色物体已准备好清理。灰色物体仍然需要分类为黑色或白色。
一个简单的演示应用程序永久链接

这是一个简单的接口,我将使用它来触发垃圾收集器。它创建一个可变大小的字符串数组。然后它通过调用垃圾收集器 runtime.GC()。

通常,您不需要手动调用垃圾收集器,因为 Go 会为您处理。但是,这保证了它在每次 API 调用后启动。

http.HandleFunc("/allocate-memory-and-run-gc", func(w http.ResponseWriter, r *http.Request) {
   arrayLength, bytesPerElement := parseArrayArgs(r)
   arr := generateRandomStringArray(arrayLength, bytesPerElement)
   fmt.Fprintf(w, fmt.Sprintf("Generated string array with %d bytes of data\n", len(arr) * len(arr[0])))
   runtime.GC()
   fmt.Fprintf(w, "Ran garbage collector\n")
 })

跟踪垃圾收集器

现在我们已经了解了 uprobes 和 Go 垃圾收集器的基础知识,让我们深入观察它的行为。

跟踪 runtime.GC()

首先,我决定在 Go runtime 库中的以下函数中添加 uprobes。

74a98a85ced2469a6729225760dd11a9.png

如果你有兴趣了解 uprobes 是如何生成的,这里是代码https://github.com/pixie-io/pixie-demos/tree/main/go-garbage-collector

部署 uprobes 后,我点击接口并生成了一个包含 10 个字符串的数组,每个字符串为 20 个字节。

$ curl '127.0.0.1/allocate-memory-and-run-gc?arrayLength=10&bytesPerElement=20'
Generated string array with 200 bytes of data
Ran garbage collector

在 curl 调用之后,部署的 uprobes 观察到以下事件:

e309ae50af71413f38c72d0054d9c096.png

在运行垃圾收集器后为 GC、gcWaitOnMark 和 gcSweep 收集事件

从源代码来看这是有道理的——gcWaitOnMark 被调用两次,一次是在开始下一个循环之前对前一个循环进行验证。标记阶段触发清理阶段。

接下来,我在使用各种输入到达端点 runtime.GC 后对延迟进行了一些测量。/allocate-memory-and-run-gc

8158a0cd4289839964a85db8113699c8.png

跟踪标记和扫描阶段永久链接

虽然这是一个很好的高级视图,但我们可以发现更多细节。接下来,我探索了一些用于内存分配、标记和扫描的辅助函数,以获取下一级信息。

这些辅助函数有参数或返回值,可以帮助我们更好地可视化正在发生的事情(例如分配的内存页)。

$ curl '127.0.0.1/allocate-memory-and-run-gc?arrayLength=20000&bytesPerElement=4096'
Generated string array with 81920000 bytes of data
Ran garbage collector

在产生更多的垃圾收集器之后,以下是原始结果:

abc41d46afd8e8cccc430b25ff8fd852.png

调用垃圾收集器后为 allocSpan、gcDrainN 和 sweepone 收集的事件示例

绘制为时间序列时,它们更容易解释:

0d5b300ff6c10a194fe6dce15f64854f.png

allocSpan 随时间分配的页面
88bf7cb1a7bcd3e72e90b4eb9436611b.png
标记 gcDrain 随时间执行的工作
37cf35cfca3714a00b408e7f0b1f6b98.png
随时间清理的页面

现在我们可以看到发生了什么:

  • Go 分配了几千页,这是有道理的,因为我们直接向堆中添加了大约 80MB 的字符串。

  • 标记工作拉开了序幕(注意它的单位不是页,而是标记工作单位)

  • 标记的页面被清理过。(这应该是所有页面,因为在调用完成后我们不会重用字符串数组)。

追踪 STW

“Stopping the world”是指垃圾收集器暂时停止除自身之外的一切,以安全地修改状态。我们通常更喜欢最小化 STW 阶段,因为它们会减慢我们的程序速度(通常是在最不方便的时候……)。

一些垃圾收集器会在垃圾收集运行的整个过程中STW。这些是“非并发”垃圾收集器。虽然 Go 的垃圾收集器在很大程度上是并发的,但我们可以从代码中看到,它在技术上确实在两个地方STW。

让我们跟踪以下函数:

  • stopTheWorldWithSema

  • startTheWorldWithSema

并再次触发垃圾回收:

$ curl '127.0.0.1/allocate-memory-and-run-gc?arrayLength=10&bytesPerElement=20'
Generated string array with 200 bytes of data
Ran garbage collector

新探测器产生了以下事件:

6d0a7c49fb5d4b56c8c7fbceab2abd39.png

生成STW事件

我们可以从 GC 事件中看到垃圾收集需要 3.1 毫秒才能完成。在我检查了确切的时间戳之后,事实证明世界第一次停止了 300 µs,第二次停止了 365 µs。换句话说,~80%垃圾收集是同时执行的。当垃圾收集器在实际内存压力下“自然”调用时,预计这个比率会变得更好。

为什么 Go 垃圾收集器需要 STW?

  • 1st Stop The World(标记阶段之前):设置状态并打开写屏障。写屏障确保在 GC 运行时正确跟踪新的写入(这样它们就不会被意外释放或保留)。

  • 2nd Stop The World(标记阶段之后):清理标记状态并关闭写屏障。

垃圾收集器如何调整自己的速度?

何时运行垃圾收集是 Go 等并发垃圾收集器的重要考虑因素。

早期的垃圾收集器被设计为一旦达到一定的内存消耗水平就会启动。如果垃圾收集器是非并发的,这可以正常工作。但是使用并发垃圾收集器,主程序在垃圾收集期间仍在运行 - 因此仍在分配内存。

这意味着如果我们太晚运行垃圾收集器,我们可能会超出内存目标。(Go 也不能一直运行垃圾收集 - GC 会从主应用程序中夺走资源和性能。)

Go 的垃圾收集器使用 GC Pacer 来估计垃圾收集的最佳时间。这有助于 Go 满足其内存和 CPU 目标,而不会牺牲不必要的应用程序性能。

触发率

正如我们刚刚说的,Go 的并发垃圾收集器依赖于一个 GC Pacer 来确定何时进行垃圾收集。但它是如何做出这个决定的呢?

每次调用垃圾收集器时,GC Pacer 都会更新其内部目标,即下次应该何时运行 GC。这个目标称为触发率。触发比率 0.6 意味着一旦堆 60% 大小增加,系统应该再次运行垃圾收集。CPU、内存和其他因素中的触发比率因素会生成此数字。

让我们看看当我们一次分配大量内存时,垃圾收集器的触发率是如何变化的。我们可以通过跟踪函数来获取触发率 gcSetTriggerRatio。

$ curl '127.0.0.1/allocate-memory-and-run-gc?arrayLength=20000&bytesPerElement=4096'
Generated string array with 81920000 bytes of data
Ran garbage collector

bcdd8f2a1268dc4f2661e7b14cbe4f7e.png

随时间变化的触发率

我们可以看到,最初,触发率相当高。450%运行时已确定在程序使用更多内存之前不需要进行垃圾收集。这是有道理的,因为应用程序没有做太多事情(并且没有使用很多堆)。

然而,一旦我们到达端点来创建~81MB 堆分配,触发率迅速下降到~1. 现在我们需要更多的内存就进行垃圾收集(因为我们的内存消耗增加了)。

标记和清理辅助工作

当我分配内存但不调用垃圾收集器时会发生什么?接下来,当我将点击/allocate-memory 接口/allocate-memory-and-gc 与 runtime.GC().

根据最近的触发率,垃圾收集器应该还没有启动。但是,我们看到标记和清理仍然发生:

ccd5268136637280d64ae3b4d0d8135f.png

标记 gcDrain 随时间执行的工作
2587acd37dab30a536891b221978dd2c.png
随时间扫过的页面

事实证明,垃圾收集器还有另一个技巧可以防止失控的内存增长。如果堆内存开始增长过快,垃圾收集器将对任何分配新内存的人收“税”。请求新堆分配的 Goroutines 将首先必须协助垃圾收集,然后才能获得它们所要求的东西。

这种“辅助”系统增加了分配的延迟,因此有助于系统背压。这非常重要,因为它解决了并发垃圾收集器可能引起的问题。在并发垃圾收集器中,内存分配仍在垃圾收集运行时进行分配。如果程序分配内存的速度快于垃圾收集器释放它的速度,那么内存增长将是无限的。通过减慢(背压)新内存的净分配来帮助解决这个问题。

我们可以跟踪 gcAssistAlloc1 以查看此过程的运行情况。gcAssistAlloc1 接受一个名为 的参数 scanWork,它是请求的辅助工作量。

67cb355e9de4de19aae6fa9835c039d0.png

gcAllocAssist1 在一段时间内执行的辅助工作

我们可以看到,这 gcAssistAlloc1 就是 mark 和 sweep 工作的来源。300,000 它接收完成有关工作单元的请求。在之前的标记阶段图中,我们可以看到它同时 gcDrainN 执行了大约 300,000 个标记工作(只是分散了一点)。

总结

还有很多关于 Go 中的内存分配和垃圾收集的知识!这里有一些其他的资源可以查看:

  • Go 对小对象的特殊清理https://github.com/golang/go/blob/master/src/runtime/mgc.go#L93

  • 如何对代码运行逃逸分析以查看对象是否将分配给堆栈或堆https://medium.com/a-journey-with-go/go-introduction-to-the-escape-analysis-f7610174e890

  • sync.Pool,一种并发数据结构,通过池化共享对象来减少分配https://medium.com/swlh/go-the-idea-behind-sync-pool-32da5089df72

就像我们在这个例子中所做的那样,创建 uprobes 通常最好在更高级别的 BPF 框架中完成。对于这篇文章,我使用了 Pixie 的 Dynamic Go 日志记录功能(仍处于 alpha 阶段)。bpftrace 是另一个创建 uprobes 的好工具。您可以在此处试用此帖子中的整个示例。

检查 Go 垃圾收集器行为的另一个不错的选择是 gc 跟踪器。只需在 GODEBUG=gctrace=1 您启动程序时传入。需要重新启动,但会告诉您有关垃圾收集器正在做什么的各种信息。

e0cb439f1a8a0bf5cf14c029fbde3c9f.png

推荐

我在使用 Go 过程中犯过的低级错误

深入理解 goroutine 泄漏和避免泄漏的最佳实践


随手关注或者”在看“,诚挚感谢!

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值