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 垃圾收集器的关键函数上。这将让我看到它在我正在运行的应用程序中的实际表现。
uprobes 可以跟踪延迟、时间戳、参数和函数的返回值
注意:这篇文章使用 Go 1.16。我将在 Go 运行时中跟踪私有函数。但这些功能在 Go 的后续版本中可能会发生变化。
垃圾收集的阶段
Go 使用并发标记和清除垃圾收集机制。对于那些不熟悉这些术语的人,这里有一个快速摘要,以便您可以理解帖子的其余部分。您可以在此处https://agrim123.github.io/posts/go-garbage-collector.html
找到更多详细信息。
Go 的垃圾收集器被称为并发,因为它可以安全地与主程序并行运行。换句话说,它不需要来停止你的程序的执行来完成它的工作。
垃圾收集有两个主要阶段:
- 标记阶段:识别并标记程序不再需要的对象。
- 清理阶段:对于标记阶段标记为“无法访问”的每个对象,释放内存以供其他地方使用。
一种节点着色算法。黑色物体仍在使用中。白色物体已准备好清理。灰色物体仍然需要分类为黑色或白色。
一个简单的演示应用程序永久链接
这是一个简单的接口,我将使用它来触发垃圾收集器。它创建一个可变大小的字符串数组。然后它通过调用垃圾收集器 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。
如果你有兴趣了解 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 观察到以下事件:
在运行垃圾收集器后为 GC、gcWaitOnMark 和 gcSweep 收集事件
从源代码来看这是有道理的——gcWaitOnMark 被调用两次,一次是在开始下一个循环之前对前一个循环进行验证。标记阶段触发清理阶段。
接下来,我在使用各种输入到达端点 runtime.GC 后对延迟进行了一些测量。/allocate-memory-and-run-gc
跟踪标记和扫描阶段永久链接
虽然这是一个很好的高级视图,但我们可以发现更多细节。接下来,我探索了一些用于内存分配、标记和扫描的辅助函数,以获取下一级信息。
这些辅助函数有参数或返回值,可以帮助我们更好地可视化正在发生的事情(例如分配的内存页)。
$ 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
在产生更多的垃圾收集器之后,以下是原始结果:
调用垃圾收集器后为 allocSpan、gcDrainN 和 sweepone 收集的事件示例
绘制为时间序列时,它们更容易解释:
allocSpan 随时间分配的页面
标记 gcDrain 随时间执行的工作
随时间清理的页面
现在我们可以看到发生了什么:
- 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
新探测器产生了以下事件:
生成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
随时间变化的触发率
我们可以看到,最初,触发率相当高。450%运行时已确定在程序使用更多内存之前不需要进行垃圾收集。这是有道理的,因为应用程序没有做太多事情(并且没有使用很多堆)。
然而,一旦我们到达端点来创建~81MB 堆分配,触发率迅速下降到~1. 现在我们需要更多的内存就进行垃圾收集(因为我们的内存消耗增加了)。
标记和清理辅助工作
当我分配内存但不调用垃圾收集器时会发生什么?接下来,当我将点击/allocate-memory 接口/allocate-memory-and-gc 与 runtime.GC().
根据最近的触发率,垃圾收集器应该还没有启动。但是,我们看到标记和清理仍然发生:
标记 gcDrain 随时间执行的工作
随时间扫过的页面
事实证明,垃圾收集器还有另一个技巧可以防止失控的内存增长。如果堆内存开始增长过快,垃圾收集器将对任何分配新内存的人收“税”。请求新堆分配的 Goroutines 将首先必须协助垃圾收集,然后才能获得它们所要求的东西。
这种“辅助”系统增加了分配的延迟,因此有助于系统背压。这非常重要,因为它解决了并发垃圾收集器可能引起的问题。在并发垃圾收集器中,内存分配仍在垃圾收集运行时进行分配。如果程序分配内存的速度快于垃圾收集器释放它的速度,那么内存增长将是无限的。通过减慢(背压)新内存的净分配来帮助解决这个问题。
我们可以跟踪 gcAssistAlloc1 以查看此过程的运行情况。gcAssistAlloc1 接受一个名为 的参数 scanWork,它是请求的辅助工作量。
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 您启动程序时传入。需要重新启动,但会告诉您有关垃圾收集器正在做什么的各种信息。