Go 向低延迟 GC 进军

 我们在 Twitch 的许多最繁忙的系统中使用 Go 。它的简单性、安全性、性能和可读性使其成为解决我们在为数百万用户提供实时视频和聊天时遇到的问题的好工具。

但这不是另一篇关于 Go 对我们来说有多棒的文章——它是关于我们对 Go 的使用如何在某些方面突破当前运行时实现的限制,以及我们如何应对达到这些限制。

这是一个关于 Go 1.4 和 Go 1.6 之间对 Go 运行时的改进如何使垃圾收集 (GC) 暂停时间提高 20 倍的故事,我们如何将 Go 1.6 的暂停时间再提高 10 倍,以及如何共享我们在 Go 运行时团队的经验帮助他们在 Go 1.7 中为我们提供了 10 倍的额外加速,同时淘汰了我们的手动调优。

GC 延迟传奇开始

我们基于 IRC 的聊天系统于 2013 年底首次用 Go 编写,取代了之前的 Python 实现。使用 Go 1.2 的预发布版本,它能够为来自每个物理主机的超过 500,000 个并发用户提供服务,而无需进行特殊调整。使用一组三个 goroutine(Go 的轻量级执行线程)为每个连接提供服务,该程序与每个进程 1,500,000 个 goroutine 一起运行。即使有这么大的 goroutine 数量,我们在 Go 大约 1.2 中遇到的唯一主要性能问题是 GC 暂停时间,这会使我们的应用程序在运行时冻结数十秒——这对于我们的交互式聊天服务来说是无法接受的。

不仅每次 GC 暂停非常昂贵,而且收集每分钟运行几次。我们努力减少内存分配的数量和大小,以降低 GC 的运行频率,一旦堆每两分钟仅增长 50% 左右就宣告胜利。尽管停顿的次数更少,但每一次停顿都同样具有毁灭性。

Go 1.2 发布后,GC 暂停时间“仅”几秒钟。我们将流量分散到更多的进程中,从而将停顿降低到更舒适的范围内。

即使 Go 实现的发展,减少分配的工作继续使我们的聊天服务器受益,但是将聊天流量拆分到大量进程中的更改是特定范围的 Go 版本的解决方法。像这样的解决方法经不起时间的考验,但对于为我们今天的用户提供良好的服务很重要。分享我们对变通方法的经验可以帮助对 Go 运行时进行持久改进,这不仅使单个程序受益。

从 2015 年 8 月的 Go 1.5 开始,Go 的垃圾收集器主要是并发和增量的,这意味着它不再需要在完成大部分工作时完全停止应用程序。除了相对较短的设置和终止阶段之外,我们的程序可以在垃圾收集进行时继续运行。升级到 Go 1.5 立即使我们聊天系统中的 GC 暂停时间缩短了 10 倍,负载重的测试实例上的暂停时间从 2 秒缩短到 200 毫秒左右。

Go 1.5,GC的新时代

虽然我们在 Go 1.5 中获得的延迟减少值得庆祝,但新 GC 最好的部分是它为进一步的增量改进奠定了基础。

Go 1.5 的垃圾收集器仍然有两个相同的主要阶段——标记阶段,GC 确定哪些内存分配仍在使用中,以及清理阶段,未使用的内存准备重用——但它们都被分成了两个子阶段。首先,应用程序在前一个扫描阶段终止时暂停。然后并发标记阶段在用户代码运行时查找正在使用的内存。最后,应用程序第二次暂停,以便标记阶段终止。之后,随着应用程序的运行,未使用的内存逐渐被清扫。

运行时的 gctrace 功能打印总结每个 GC 周期的行,包括每个阶段的持续时间。对于我们的聊天服务器,它表明大部分剩余的暂停时间都处于标记终止阶段,因此分析将集中在此处。而且由于运行时团队已经要求提供描述仍然看到长时间 GC 暂停的应用程序的错误报告,我们将疏忽将我们的长时间暂停时间保密!

当然,我们需要更多关于在这些暂停期间 GC 到底在做什么的详细信息。Go 核心包包括一个很棒的 用户级 CPU 分析器,但为此我们将使用 Linux 的 perf 工具。使用 perf 将允许更高的采样频率和对内核所用时间的可见性。在内核中花费的监控周期可以帮助我们调试缓慢的系统调用并透明地完成虚拟内存管理的工作。

下图是我们使用 go1.5.1 运行的聊天服务器配置文件的一部分。这是一个  用 Brendan Gregg 的工具制作的火焰图,修剪后只包含堆栈上具有 runtime.gcMark 函数的样本,这对于 Go 1.5 来说近似于标记终止阶段所花费的时间。

火焰图将堆栈深度显示为向上增长,并将 CPU 时间表示为每个部分的宽度。(颜色没有意义,x 轴上的顺序也无关紧要——它只是按字母顺序。)在图表的最左边,我们可以看到 runtime.gcMark 在几乎所有采样堆栈中都在调用 runtime.parfordo。向上移动,我们看到大部分时间花在 runtime.markroot 调用 runtime.sang、runtime.scanobject 和 runtime.shrinkstack。

runtime.scang 函数用于重新扫描内存以帮助终止标记阶段。标记终止阶段背后的整个想法是完成对应用程序内存的扫描,所以这是它在这里要做的正确类型的工作。我们将有更好的运气在其他功能中找到性能改进。

接下来是 runtime.scanobject。该函数做了几件事,但它在 Go 1.5 中聊天服务器的标记终止阶段运行的原因是为了实现终结器。“为什么程序会使用这么多终结器,以至于它们会对 GC 暂停时间做出重大贡献?” 你可能想知道。有问题的应用程序是一个聊天服务器,处理数十万用户。Go 的核心“net”包为每个 TCP 连接附加了一个终结器,以帮助控制文件描述符泄漏——并且由于每个用户都有自己的 TCP 连接,即使对标记终止的贡献很小。

这似乎值得向 Go 运行时团队报告。我们来回发送电子邮件,Go 团队对如何诊断性能问题以及如何将它们提炼成最小的测试用例提供了非常有帮助的建议。对于 Go 1.6,运行时团队将终结器扫描移至并发标记阶段,从而减少了具有大量 TCP 连接的应用程序的暂停时间。连同该版本中的所有其他改进,Go 1.6 聊天服务器的暂停时间是 Go 1.5 的一半,在测试实例上降至 100 毫秒左右。进步!

堆栈收缩

Go 的并发方法包括让拥有大量 goroutines 变得非常便宜。虽然使用 10,000 个 OS 线程的程序可能性能不佳,但 goroutine 的数量并不稀奇。一个区别是 goroutine 从一个非常小的堆栈开始——只有 2kB——它会根据需要增长,这与其他地方常见的大型固定大小的堆栈形成对比。Go 的函数调用前导确保有足够的堆栈空间用于下一次调用,如果没有,则将 goroutine 的堆栈移动到更大的内存区域——根据需要重写指针——然后允许调用继续。

因此,当程序运行时,其 goroutine 的堆栈将增长以支持它们进行的最深调用。垃圾收集器的职责之一是回收不再需要的堆栈内存。将 goroutine 堆栈移动到更合适大小的内存范围的任务由 runtime.shrinkstack 完成,在 Go 1.5 和 1.6 中,它是在应用程序暂停时标记终止期间完成的。

上面的火焰图——从 2015 年 10 月开始使用 Go 的 1.6 之前的版本记录——在大约 3/4 的样本中显示了 runtime.shrinkstack。如果这项工作可以在应用程序运行时完成,它可以大大加快我们的聊天服务器和其他类似的程序。

Go 运行时的包文档解释了如何禁用堆栈收缩。对于我们的聊天服务器来说,浪费一些内存是为了减少 GC 暂停而付出的小代价——这是我们在运行 Go 1.6 时做出的决定。在禁用堆栈收缩的情况下,聊天服务器的暂停时间再次减半至 30 到 70 毫秒之间,具体取决于风向。

在保持聊天服务的结构和操作相对稳定的同时,我们忍受了 Go 1.2 到 1.4 的多秒 GC 暂停。Go 1.5 将它们降低到 200 毫秒左右,而 Go 1.6 将其进一步缩短到 100 毫秒左右。现在,暂停时间通常小于 70 毫秒,我们可以声称改进了 30 倍以上。

可能还有改进的空间;是时候换一个个人资料了!

页面错误?!

到目前为止,GC 暂停时间相当一致,但现在它们分布在很宽的持续时间范围内(从 30 到 70 毫秒),与任何其他 gctrace 输出无关。这是在一些较长的标记终止暂停期间花费的周期的火焰图:

当 Go GC 调用 runtime.gcRemoveStackBarriers 时,系统会生成页面错误,从而导致调用内核的 page_fault 函数,从而导致图表中的宽塔位于中心右侧。页面错误是内核将一页虚拟内存(通常为 4kB)映射到一块物理 RAM 的方式。进程通常被允许分配大量的虚拟内存,只有在程序访问时才会通过页面错误将其转换为常驻内存。

runtime.gcRemoveStackBarriers 函数修改堆栈内存,程序最近应该访问过堆栈内存。事实上,它的目的是消除几秒钟前在 GC 周期开始时添加的堆栈屏障。系统有大量可用内存——它没有将物理 RAM 分配给其他更活跃的进程——那么为什么这种内存访问会导致页面错误呢?

有关我们计算硬件的一些背景知识可能会有所帮助。我们用来运行聊天系统的服务器是现代双路机器。每个 CPU 插槽都有几个直接连接的内存库。此配置导致 NUMA,非统一内存访问。当线程在套接字 0 中的内核上运行时,它将更快地访问连接到该套接字的物理内存,而对其他内存的访问速度则较慢。Linux 内核试图通过在它们使用的内存附近运行线程以及通过将物理内存页面移动到靠近相关线程运行的位置来减少这种延迟。

考虑到这一点,我们可以仔细看看内核的 page_fault 函数的行为。深入调用堆栈(在火焰图上向上移动)我们可以看到内核调用 do_numa_page 和 migrate_misplaced_pa​​ge,表明内核正在物理内存库之间移动程序的内存。

Linux 内核已经接受了 GC 标记终止阶段几乎毫无意义的内存访问模式,并且正在迁移内存页面——付出巨大的代价——以匹配它。这种行为在 go1.5.1 火焰图中表现得非常轻微,但现在我们的注意力集中在 runtime.gcRemoveStackBarriers 上,这种行为更加明显。

这是使用 perf 进行分析的好处最明显的地方。perf 工具能够显示内核堆栈,而 Go 的用户级分析器会显示 Go 函数异常缓慢。使用 perf 相当复杂,需要 root 访问权限才能查看内核堆栈,对于 Go 1.5 和 1.6 需要非标准构建的 Go 工具链(通过 GOEXPERIMENT=framepointer ./make.bash,在 Go 1.7 中是标准的)。对于像这样的问题,这是完全值得的。

控制迁移

如果使用两个 CPU 插槽和两个内存条过于复杂,让我们尝试只使用一个。可用的最直接的工具是 taskset 命令,它可以限制程序仅在单个套接字的核心上运行。由于程序的线程只从一个套接字访问内存,内核会将其内存移动到该套接字的内存库中。

将程序限制在单个 NUMA 节点后,应用程序的标记终止时间降至 10-15 毫秒,与 Go 1.5 的 200 毫秒或 Go 1.4 的 2 秒暂停相比有了显着改善。(通过 set_mempolicy(2) 或 mbind(2) 将进程的内存策略设置为 MPOL_BIND 可以在不牺牲一半服务器的情况下获得相同的好处。)上述配置文件是从 10 月开始使用 1.6 之前的 Go 版本获取的2015. 它在左侧的 runtime.freeStackSpan 中显示了相当长的时间,此后已移至并发 GC 阶段,因此不再有助于暂停持续时间。标记终止阶段没有太多要删除的东西了!

Go 1.7

在 Go 1.6 之前,我们通过禁用程序的功能来管理堆栈收缩的高成本。这对聊天服务器的内存使用影响很小,但对操作复杂性的影响要大得多。堆栈收缩对于某些程序来说非常重要,因此我们没有在任何地方都进行更改,而是只为一小部分应用程序实现了它。Go 1.7 现在在应用程序运行时同时收缩堆栈。我们得到了两全其美的优势——无需任何特殊调整即可实现低内存使用。

自从在 Go 1.5 中引入并发 GC 以来,运行时一直跟踪 goroutine 自上次扫描其堆栈以来是否已执行。标记终止阶段将检查每个 goroutine 以查看它是否最近运行过,并重新扫描少数运行过的 goroutine。在 Go 1.7 中,运行时维护一个单独的此类 goroutine 的简短列表。这消除了在用户代码暂停时查看整个 goroutine 列表的需要,并大大减少了可以触发内核的 NUMA 相关内存迁移代码的内存访问次数。

最后,amd64 架构的编译器默认会维护帧指针,因此标准调试和 perf 之类的 _perf_ormance 工具可以确定当前的函数调用堆栈。使用 Go 的二进制发行版构建程序的用户将能够根据需要选择更高级的工具,而无需深入研究如何重建他们的 Go 工具链和重建/重新部署他们的程序。这对于 Go 核心包和运行时的未来性能改进来说是个好兆头,因为像你我这样的工程师将能够收集我们需要的高质量错误报告的信息。

从 2016 年 6 月开始使用预发布的 Go 1.7,GC 暂停时间比以往任何时候都好,无需手动调整。我们的聊天服务器的典型暂停时间开箱即用接近 1 毫秒——比调整后的 Go 1.6 配置提高了 10 倍!

与 Go 团队分享我们的经验使他们能够找到解决我们遇到的问题的永久解决方案。分析和调优使我们的应用程序在 Go 1.5 和 1.6 中的暂停时间提高了 10 倍,但在 Go 1.5 和 Go 1.7 之间,运行时团队能够将其转化为像我们这样的所有应用程序的暂停时间提高 100 倍。向他们对 Go 运行时性能的不懈努力致敬。

下一步是什么

所有这些分析都集中在我们的聊天服务器的祸根上——stop-the-world 暂停持续时间——但这只是 GC 性能的一个维度。随着 GC 尴尬的停顿终于得到控制,运行时团队准备好处理吞吐量问题。

他们最近关于面向 事务的收集器的提议 描述了一种透明地提供廉价分配和收集内存的方法,这些内存不在 goroutine 之间共享。这可以延迟完整 GC 运行的需要,并减少程序在垃圾收集上花费的 CPU 周期总数。

当然,Twitch 正在招聘!如果您对这类东西感兴趣,请 给我们留言

谢谢

我要感谢 Chris Carroll 和 John Rizzo 在他们的聊天系统上安全地测试新的 Go 版本的帮助,以及 Spencer Nelson 和 Mike Ossareh 与我一起编辑这篇文章。我还要感谢 Go 运行时团队帮助我提交良好的错误报告以及他们对 Go 垃圾收集器的不断改进。