此文翻译自:https://blog.twitch.tv/gos-march-to-low-latency-gc-a6fa96f06eb7

我们在Twitch使用Go研发了许多高并发的系统。它的简单性,安全性,性能和可读性使其成为解决我们遇到问题的一个很好的工具,我们向数百万用户提供实况视频和聊天服务。

但这不是另一篇关于Go对于我们有多么大作用的文章, 本文是关于我们在使用Go过程中碰到的限制以及我们如何克服这些限制的文章。

Go 1.4和Go 1.6之间Go runtime的改进使我们的垃圾收集(GC)暂停时间缩短了20倍,我们如何在Go 1.6的停顿之上进一步缩小 10 倍的暂停时间,以及如何向Go团队分享我们的案例,使得1.7中不使用我们手动方案的同时停顿时间又降低了10倍。

开始

我们的基于IRC的聊天系统最早是在2013年年底用Go写的,取代了以前的Python实现。使用Go 1.2的预发布版本,它能够在每个物理主机提供超过500,000并发用户,而无需特殊调整。有一组三个goroutine(Go的轻量级执行线程)为每个连接提供服务,程序在每个进程中拥有1,500,000个goroutine。即使有这么大的goroutine数量,我们在使用Go1.2的过程中碰见的唯一性能问题是GC暂停时间,执行GC将冻结我们的应用程序几十秒。

不仅每个GC暂停非常昂贵,GC每分钟运行几次。我们努力减少内存分配的数量和大小,以便将GC运行的频率降低,如果堆每两分钟只增长50%,就意味着分配数量足够低。虽然暂停时间减少,但每一次GC暂停都是破坏性的。

一旦Go 1.2 正式发布,GC暂停时间下降到“仅”几秒钟。我们将流量分散在更大数量的进程中,从而将停顿降低到更容易接受的范围。

随着Go版本增长,减少分配的工作会继续有益于我们的聊天服务器,但是分解聊天进程是一个特定范围的Go版本的解决方案。这样的解决方案经不起时间的考验,但对于为我们的用户提供良好的服务非常重要。分享我们的经验有助于为Go运行时创建持久的改进,从而使单个程序受益。

从2015年8月的Go 1.5开始,Go的垃圾收集器大多数时候是并发和增量的,这意味着在大部分阶段它不需要将应用程序完全停止。除了相对较短的标记和终止阶段,我们的程序可以继续运行,同时运行垃圾回收。升级到Go 1.5立即导致我们的聊天系统中的GC暂停时间的10倍缩小,在重负荷测试实例上的暂停时间从2秒缩短到约200ms。

Go 1.5 GC新纪元

虽然Go 1.5的延迟减少值得庆祝,但新GC的最大意义是它为进一步的增量改进奠定了基础。

Go 1.5的垃圾收集器仍然具有相同的两个主要阶段 -

标记阶段(GC确定哪些内存分配仍在使用),以及扫描阶段(其中未使用的内存已准备好重用)

但是每个阶段都被分成两个子阶段。首先,应用程序暂停,而前一个扫描阶段终止。然后,并发标记阶段在用户代码运行时查找正在使用的内存。最后,应用程序第二次暂停,标记阶段终止。之后,未使用的内存将被扫描,同时应用程序将执行其业务。

runtime的gctrace功能打印每个GC周期,包括每个阶段的持续时间。对于我们的聊天服务器,它表明大部分剩余的暂停时间在标记终止阶段,因此分析将集中在那里。

当然,我们需要更多关于GC在这些暂停期间究竟做了什么的细节。 Go核心包有CPU profiler,同时组合使用Linux的perf工具。使用perf允许使用更高的采样频率和在内核中花费的时间可视化。在内核中使用的监视器可以帮助我们调试缓慢的系统调用,并透明地完成虚拟内存管理。

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

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

runtime.scang函数用于重新扫描内存以帮助终止标记阶段。标记终止阶段背后的整个想法是完成扫描应用程序的内存。

接下来是runtime.scanobject。这个函数做了几件事情,但在Go 1.5的聊天服务器标记终止阶段运行的原因是实现finalizer。 为什么程序会使用这么多的finalizer,他们为什么会占用这么长GC暂停时间?。有问题的应用程序是一个聊天服务器,同时处理成千上万的用户。

Go的核心“net”软件包附加一个finalizer到每个TCP连接,以帮助控制文件描述符泄漏

并且由于每个用户都有自己的TCP连接,即使每个链接只有一个finalizer,加起来还是很可观。

这个问题似乎值得向Go runtime 团队报告。我们通过电子邮件交流,Go团队对如何诊断性能问题以及如何将它们提炼成最小测试用例的建议非常有帮助。对于Go 1.6,运行时团队将finalizer扫描移动到并发标记阶段,导致具有大量TCP连接的应用程序的暂停时间更短。结合发布中的所有其他改进,我们的聊天服务器在Go 1.6的暂停时间是在Go 1.5的一半左右,在测试实例上降低到大约100ms。

堆栈收缩

Go的并发使启动大量goroutine非常廉价。虽然使用10,000个操作系统线程的程序性能可能很差,但是这个数量的goroutine却很正常。一个区别是goroutine从非常小的堆栈开始 - 只有2kB - 根据需要增长,与其他地方常见的大型固定大小堆栈形成对比。

Go的函数调用前缀确保有足够的堆栈空间用于下一次调用,如果没有,则在允许调用继续之前,将goroutine的堆栈移动到更大的内存区域 - 根据需要重写指针。

因此对一个程序来说,为了支持他们做的最深的调用,其goroutine的堆栈将增长。垃圾收集器的一个职责是回收不再需要的堆栈内存。将goroutine堆栈移动到更适当大小的内存区域的任务由runtime.shrinkstack完成,在Go 1.5和1.6中,在标记终止期间完成。

上面的火焰图,在其样本的3/4左右显示runtime.shrinkstack。如果这项工作可以在应用程序运行时完成,它可以大大加快我们的聊天服务器和其他程序。

Go运行时的包docs解释如何禁用堆栈收缩。对于我们的聊天服务器,相对于浪费内存来说更短暂的暂停时间更容易接受。在禁用堆栈收缩的情况下,聊天服务器的暂停时间再次减少到30到70ms之间。

在保持聊天服务的结构和操作相对恒定的同时,我们忍受了Go 1.2到1.4的多秒GC暂停。 Go 1.5将它们降低到大约200ms,并且Go 1.6进一步将其剪切到大约100ms。现在暂停一般小于70毫秒,现在看来其改进带来30倍的暂停时间缩短。

当然还有改进的余地;让我们看看另外一个profile。

Page faults

现在GC在大概从30到70ms这一范围中变化。这里是在一些较长的标记终止停顿期间花费周期的火焰图:

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

runtime.gcRemoveStackBarriers函数修改最近由程序访问的内存。事实上,它的目的是删除在之前的GC周期开始时添加的内存屏障。操作系统有足够的内存可用,但是没有将物理RAM分配给一些其他更活跃的进程。 为什么这个内存访问会导致页面错误?

我们的计算硬件的一些背景可能是有帮助的。我们用来运行聊天系统的服务器是现代双插槽机器。每个CPU插槽有几个直接连接的内存组。此配置导致NUMA(非统一内存访问)。当线程在插槽0中的核心上运行时,它将更快地访问连接到该插槽的物理内存,访问其他内存相对缓慢。Linux内核尝试更近的额内存,并通过将物理内存页移动到距离相关线程运行较近的地方来减少延迟。

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

Linux内核在GC的标记终止阶段选择了几乎无意义的存储器访问模式,并且正在以很大的代价迁移内存页以匹配它。这种行为在go1.5.1火焰图中显得如此之少,但现在我们的注意力集中在runtime.gcRemoveStackBarriers上更加明显。

这是使用perf的profiling的好处最明显的地方。 perf工具能够显示内核堆栈,而Go的用户级分析器会无法跟踪到这里。使用perf相当复杂,需要root访问来查看内核栈,对于Go 1.5和1.6需要一个非标准的Go工具链构建(通过GOEXPERIMENT = framepointer ./make.bash,在Go 1.7中无需如此)。对于像这样的问题,完全值得使用perf工具。

控制迁移

如果使用两个CPU插槽和两个内存条比较麻烦,那让我们只使用一个。 可用于此的最简单的工具是taskset命令,它可以限制程序仅在单个插槽的CPU上运行。 由于程序的线程仅从一个插槽访问内存,因此内核会将进程内存移动到该CPU插槽临近的内存中。

在将程序限制在单个NUMA节点之后,应用程序的标记终止时间下降到10-15ms。(通过将进程的内存策略通过set_mempolicy(2)或mbind(2)设置为MPOL_BIND,可以在不牺牲一半服务器的情况下获得相同的效益)上面的profile是从10月前的1.6版本开始的, 左边的runtime.freeStackSpans被移动到一个并发的GC阶段,并不再引起长时间停顿。 现在标记终止阶段没有多余工作要剔除了。

Go1.7

Go 1.6,我们通过禁用程序的功能来避免堆栈收缩的高成本。这对聊天服务器的内存使用影响较小,但是操作更复杂。堆栈收缩对于一些程序是非常重要的,所以我们实现了一小组应用程序而不是将更改应用到所有程序。 Go 1.7可以在应用程序运行时同时缩减堆栈。

自从在Go 1.5中引入了并发GC,runtime跟踪了goroutine自上次扫描它的堆栈之后是否执行过的信息。标记终止阶段将检查每个goroutine以查看它是否最近运行,并且将重新扫描那些已经运行的几个goroutine。在Go 1.7中,运行时维护一个上次GC后运行过的goroutine的短列表。这消除了在用户代码暂停时查看goroutine列表的时间,并大大减少可以触发内核NUMA的相关内存迁移代码的访问次数。

最后,amd64架构的编译器默认保持帧指针,因此标准调试和性能工具(如perf)可以确定当前的函数调用堆栈。使用Go的二进制发行版构建程序的用户将能够在需要时获取更高级的工具,而无需深入了解如何重新构建Go工具链并重新编译/部署其程序。这对于Go核心包和运行时的未来性能改进很有利,因为工程师将能够收集高质量错误报告信息。

2016年6月发布的Go 1.7版本,GC暂停时间比以往任何时候都更好,无需进行手动调整。我们的聊天服务器的暂停时间接近于开箱即用的1ms ,这比调整后的Go 1.6有10倍的改进!

与Go团队分享我们的经验,使他们能够找问题的永久解决方案。分析和调优使我们的应用程序在Go 1.5和1.6中暂停时间缩短了10倍,但在Go 1.5和Go 1.7之间,runtime团队能够将所有应用程序的暂停时间缩短100倍。

下一步

所有这些分析都集中在我们的聊天服务器的stop-the-world暂停时间,但这只是GC性能的一个维度。 随着GC的尴尬停顿终于受到控制,runtime团队准备解决吞吐量问题。
他们最近关于面向事务收集器的提议描述了一种透明地提供不在goroutine之间共享的内存的廉价分配和收集的方法。 这可能会延迟对完整GC运行的需要,并减少程序花费在垃圾回收上的CPU周期总数。
当然,Twitch正在招聘! 如果这种东西对你感兴趣,请给我们发邮件。

Thank you

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