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

https://github.com/pixie-io/pixie-demos/tree/main/go-garbage-collector

背景

  • 为什么要 uprobe?
  • 垃圾收集的阶段

跟踪垃圾收集器

  • 运行时 GC
  • 标记和扫描阶段
  • STW 事件

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

  • 触发率
  • 标记和清理辅助工作

深入前的几件事

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

为什么要 uprobe?

uprobes 很酷,因为它们让我们无需修改代码即可动态收集新信息。当您不能或不想重新部署您的应用程序时,这很有用 - 可能是因为它正在生产中,或者有的行为难以重现。函数参数、返回值、延迟和时间戳都可以通过 uprobes 收集。在这篇文章中,我将把 uprobes 部署到 Go 垃圾收集器的关键函数上。这将让我看到它在我正在运行的应用程序中的实际表现。



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

垃圾收集的阶段

https://agrim123.github.io/posts/go-garbage-collector.html
  • 标记阶段:识别并标记程序不再需要的对象。
  • 清理阶段:对于标记阶段标记为“无法访问”的每个对象,释放内存以供其他地方使用。



一个简单的演示应用程序永久链接

这是一个简单的接口,我将使用它来触发垃圾收集器。它创建一个可变大小的字符串数组。然后它通过调用垃圾收集器 runtime.GC()。通常,您不需要手动调用垃圾收集器,因为 Go 会为您处理。但是,这保证了它在每次 API 调用后启动。

跟踪垃圾收集器

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

跟踪 runtime.GC()

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



https://github.com/pixie-io/pixie-demos/tree/main/go-garbage-collector

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



从源代码来看这是有道理的——gcWaitOnMark 被调用两次,一次是在开始下一个循环之前对前一个循环进行验证。标记阶段触发清理阶段。接下来,我在使用各种输入到达端点 runtime.GC 后对延迟进行了一些测量。/allocate-memory-and-run-gc



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

虽然这是一个很好的高级视图,但我们可以发现更多细节。接下来,我探索了一些用于内存分配、标记和扫描的辅助函数,以获取下一级信息。这些辅助函数有参数或返回值,可以帮助我们更好地可视化正在发生的事情(例如分配的内存页)。

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



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



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

  • Go 分配了几千页,这是有道理的,因为我们直接向堆中添加了大约 80MB 的字符串。
  • 标记工作拉开了序幕(注意它的单位不是页,而是标记工作单位)
  • 标记的页面被清理过。(这应该是所有页面,因为在调用完成后我们不会重用字符串数组)。

追踪 STW

“Stopping the world”是指垃圾收集器暂时停止除自身之外的一切,以安全地修改状态。我们通常更喜欢最小化 STW 阶段,因为它们会减慢我们的程序速度(通常是在最不方便的时候……)。一些垃圾收集器会在垃圾收集运行的整个过程中STW。这些是“非并发”垃圾收集器。虽然 Go 的垃圾收集器在很大程度上是并发的,但我们可以从代码中看到,它在技术上确实在两个地方STW。让我们跟踪以下函数:

  • stopTheWorldWithSema
  • startTheWorldWithSema

并再次触发垃圾回收:

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



我们可以从 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。



我们可以看到,最初,触发率相当高。450%运行时已确定在程序使用更多内存之前不需要进行垃圾收集。这是有道理的,因为应用程序没有做太多事情(并且没有使用很多堆)。然而,一旦我们到达端点来创建~81MB 堆分配,触发率迅速下降到~1. 现在我们需要更多的内存就进行垃圾收集(因为我们的内存消耗增加了)。

标记和清理辅助工作

当我分配内存但不调用垃圾收集器时会发生什么?接下来,当我将点击/allocate-memory 接口/allocate-memory-and-gc 与 runtime.GC().根据最近的触发率,垃圾收集器应该还没有启动。但是,我们看到标记和清理仍然发生:



事实证明,垃圾收集器还有另一个技巧可以防止失控的内存增长。如果堆内存开始增长过快,垃圾收集器将对任何分配新内存的人收“税”。请求新堆分配的 Goroutines 将首先必须协助垃圾收集,然后才能获得它们所要求的东西。这种“辅助”系统增加了分配的延迟,因此有助于系统背压。这非常重要,因为它解决了并发垃圾收集器可能引起的问题。在并发垃圾收集器中,内存分配仍在垃圾收集运行时进行分配。如果程序分配内存的速度快于垃圾收集器释放它的速度,那么内存增长将是无限的。通过减慢(背压)新内存的净分配来帮助解决这个问题。我们可以跟踪 gcAssistAlloc1 以查看此过程的运行情况。gcAssistAlloc1 接受一个名为 的参数 scanWork,它是请求的辅助工作量。



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

总结

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

https://github.com/golang/go/blob/master/src/runtime/mgc.go#L93https://medium.com/a-journey-with-go/go-introduction-to-the-escape-analysis-f7610174e890https://medium.com/swlh/go-the-idea-behind-sync-pool-32da5089df72

就像我们在这个例子中所做的那样,创建 uprobes 通常最好在更高级别的 BPF 框架中完成。对于这篇文章,我使用了 Pixie 的 Dynamic Go 日志记录功能(仍处于 alpha 阶段)。bpftrace 是另一个创建 uprobes 的好工具。您可以在此处试用此帖子中的整个示例。检查 Go 垃圾收集器行为的另一个不错的选择是 gc 跟踪器。只需在 GODEBUG=gctrace=1 您启动程序时传入。需要重新启动,但会告诉您有关垃圾收集器正在做什么的各种信息。


译 Serrino 云原生技术爱好者社区