go

Go 系列 Go内存压舱物:利用大块内存降低Go GC的运行频率

Posted by lichao modified on November 16, 2021

本文转载自 Twitch 工程师 Ross Engers 的文章《Go memory ballast: How I learned to stop worrying and love the heap》,这篇文章所用的优化思路很有意思但又分析得非常到位,因此与大家分享。

以下是作者原文。

我非常喜欢能引起巨大影响的少量代码改动。这似乎是显而易见的事情,但请让我解释一下:

  1. 这种改动通常需要深入探究并理解一个自己并不熟悉的事物
  2. 即使是非常完善的代码,你添加的每一个优化都有它的维护成本,而且它通常(虽然不总是)随着你最终添加/修改的代码总量呈线性关系。

我们最近推出了一个改动,它把我们的API前端服务器的CPU利用率减少了大约30%,也把高负载时的整体的99分位API延时降低了45%。

这篇博文阐述了相关改动、发现问题的过程以及解释它是如何工作的。

故事背景

我们在Twitch有一个服务叫做Visage,它的功能相当于我们的API前端。Visage是所有外部来源的API流量的中心网关。它负责一堆事情,从授权到请求路由,再到服务器侧的GraphQL(最近的)。因此,它需要扩容以处理某些我们无法控制的用户流量模式。

例如,我们常见的一个流量模式是“刷新风暴”。这会在一个热门主播由于网络波动而掉线的时候发生;此时,主播重新开始推流,这通常会导致观众反复地刷新页面,然后突然间我们就有大量的API流量需要处理。

Visage是一个Go应用程序,在改动的时候以Go 1.11构建,运行在Amazon EC2之上并部署在一个负载均衡器之后。由于在EC2上,它大多数时候都可以很好地水平扩容。

然而,即使有了EC2和Auto Scaling组的魔法,我们仍然在处理非常大的流量高峰的时候遇到问题。在刷新风暴期间,我们经常会在几秒间有剧增上百万个请求,这是我们平时负载的20倍。在此之上,我们会看到当我们的前端服务器在重负载之下,API延迟有显著的增加。

解决这个问题的一个方法就是永远保持你的集群有足够的规模,但这样不仅浪费,而且昂贵。为了减少这个不断增加的成本,我们决定花一些时间去探索一些触手可得的方法,希望当主机处于负载之中时,它不仅可以改进单机的吞吐量,也可以提供更可靠的单请求处理。

查找问题

幸运的是,我们在我们的生产环境运行了pprof,所以获取真实生产流量的性能分析是非常平常的事情。如果你没有在运行pprof,我非常鼓励你去做。大多数情况下,这个性能分析器只有非常小的CPU开销。执行追踪器(Execution Tracer)会有小的开销,但仍然足够小,使得我们可以高兴地在生产环境中每小时就运行几秒。

所以,在查看了我们Go应用程序的性能分析之后,我们做出以下的观察结论:

在稳定状态,我们的应用程序每秒钟触发大约8-10次垃圾收集(GC)(每分钟400-600次)

  • 大于30%的CPU周期被花费在了与GC相关的函数调用中
  • 在流量高峰时,GC的周期数会增加
  • 我们的平均堆大小很小(小于450MiB)

压舱物-GC频率 压舱物-堆使用 你可能还没有猜到,我们在应用程序中已经做了的与性能有关的改进是垃圾收集。在我提到这些改进之前,下面是关于什么是GC以及它做什么的快速入门与概述。如果你对这些概念熟悉,可以随时跳过。

压舱物

压舱物(Ballast)——暂时或永久携带在船上以提供需要的吃水深度以及稳定性的重物。 ——来源:directory.com

在应用程序中,压舱物是分配得很大的一块内存,它为堆提供稳定性。

通过在应用程序启动时分配一块非常大的 byte 数组去实现它:

1
2
3
4
5
6
7
8
func main() {

 // 分配一块 10GiB 的巨大的堆内存
 ballast := make([]byte, 10<<30)

 // 程序继续执行
 // ...
}

看完上面的代码,可能马上就想问两个问题:

  1. 你到底为什么要这样做?
  2. 这样会不会消耗我 10GiB 的宝贵内存?

让我们先从 1.你到底为什么要这样做? 开始。我们之前提到,GC 会在每次堆大小翻倍的时候触发。堆的大小是堆分配空间的总大小。因此,如果一个 10GiB 的压舱物被分配出来,下一次 GC 只会在堆大小上涨至 20GiB 的时候触发。届时,程序中会有大约 10GiB 的压舱物+10GiB 的其它内存分配。

当 GC 运行的时候,这个压舱物不会被当作垃圾被清理掉,因为我们仍然在 main 函数中持有它的一个引用,因此实际上它被考虑成实时内存的一部分。因为我们程序中的大多数内存分配只在一个 API 请求中存活很短的时间,所以分配的这 10GiB 内存的中的大多数会被清理,从而将堆大小重新减少到 10GiB 左右(即,这 10GiB 的压舱物加上正在处理的请求所分配的内存,这些内存会被认为是实时内存)。现在,下一个 GC 周期会在堆大小(现在刚好大于 10 GiB)再次翻倍的时候发生。

因此,总之,压舱物增加了堆的基准大小,从而使GC延迟触发,同时总体的GC周期数也下降了。

如果你好奇为什么我们用一个byte数组作为压舱物,这是为了确保我们在标记阶段只添加了一个额外的对象。因为一个byte数组不包含任何指针(对象本身除外),GC可以在O(1)的时间内标记整个对象。

改动的效果符合我们的预期——我们看到GC周期数减少了 99 %: 压舱物-GC频率 所以,它看起来很棒,那CPU利用率又如何呢? 压舱物-CPU利用率.png

绿色正弦状的 CPU 利用率指标展示了我们每日的流量波动。你可以看到改动之后的下降。

每个容器减少 30% 左右的 CPU 意味着在不考虑未来的情况下,我们可以把我们的集群缩容 30%。然而我们关心的另一件事情是 API 延迟——这个稍后再讲。

正如前面提到的,Go 运行时提供了一个环境变量 GOGC,它允许我们对 GC 调度器进行一个非常粗略的调整。这个值控制了在 GC 触发之前堆可以增长的比率。我们选择不用它,因为它有以下明显的缺点:

  • 这个比率本身对我们并不重要,使用的内存总量才重要
  • 要达到与压舱物同样的效果,我们必须要把这个值设置得非常高,从而使这个值容易受到实时堆大小微小变化的影响
  • 考虑实时内存以及它的变化率并不容易;考虑使用的总内存量很简单

对于那些感兴趣的人,这里有一个将目标堆大小设置添加到 GC 的建议,希望它在不久的将来可以在 Go 运行时实现。

现在我们看 2.这样会不会消耗我10GiB的宝贵内存?。放心,答案是:不会,除非你有意这样做。*nix(甚至Windows)系统的内存都是虚拟地址并通过页表映射。当上述代码运行时,这个压舱物切片指向的数组会在程序的虚拟地址空间中被分配。只有当我们尝试读写这个切片时,缺页错误产生,虚拟地址背后的物理内存才会被分配。

我们可以用下面一段简单的程序去确认它:

1
2
3
4
func main() {
 _ = make([]byte, 100<<20)
 <-time.After(time.Duration(math.MaxInt64))
}

我们运行这个程序,然后用ps查看它:

1
2
3
ps -eo pmem,comm,pid,maj_flt,min_flt,rss,vsz --sort -rss | numfmt --header --to=iec --field 4-5 | numfmt --header --from-unit=1024 --to=iec --field 6-7 | column -t | egrep "[t]est|[P]ID"
%MEM  COMMAND          PID    MAJFL  MINFL  RSS   VSZ
0.2   test_alloc       27826  0      1003   4.8M  108M

这里显示只有超过 100MiB 的虚拟内存被分配给进程,即虚拟大小(Virtual Size, VSZ);而只有大约5MiB的驻留集被分配,即驻留集大小(Resident Set Size, RSS),即物理内存。

现在,让我们修改这个程序,我们在切片背后的 byte 数组写入一半内容:

1
2
3
4
5
6
7
func main() {
 ballast := make([]byte, 100<<20)
 for i := 0; i < len(ballast)/2; i++ {
  ballast[i] = byte('A')
 }
 <-time.After(time.Duration(math.MaxInt64))
}

同样,用 ps 观察:

1
2
%MEM  COMMAND          PID    MAJFL  MINFL  RSS   VSZ
2.7   test_alloc       28331  0      1.5K   57M   108M

正如想象的那样,byte数组的一半已处于 RSS 中,正在占用物理内存。VSZ 没有发生变化,因为两个程序都分配了相同的虚拟内存大小。

对于那些感兴趣的人:MINFL这一列是次要缺页错误数——也就是这个进程引起的需要从存储中加载页面的缺页错误数。如果我们的操作系统管理分配我们的物理内存优良并连续,那么每一次缺页错误都能映射存储中的一个以上的页面,从而减少出现的缺页错误总数。

所以,只要我们不去读写这个压舱物,我们就能保证它只会作为一个虚拟内存分配并留在堆中。

API延迟如何?

正如之前提到的,因为 GC 运行得更少,我们可以看到API延迟的改进(特别是在高负载的时候)。最初,我们以为这是由于GC暂停时间减少导致的——这是GC在一个GC周期中“停止这个世界”的总时间。然而,GC暂停时间在前后没有发生明显的变化。此外,我们的暂停时间大约只有个位数毫秒,而不是在峰值负载下有上百毫秒的改善。

为了搞清楚延时的改进从何而来,我们需要谈论一点有关 Go GC 的特性,它叫做协助(assists)。

GC协助(assists)

GC协助将一个GC周期的内存分配负荷放在goroutine上,这个goroutine负责内存分配。没有这个机制,运行时将无法阻止堆在一个GC周期中无限增长。

因为Go已经有一个后台的GC工人,因此“协助”一词指的是我们的goroutine会帮助后台工人,特别是在标记工作中有所帮助。

为了进一步了解这一点,让我们举个例子:

1
someObject := make([]int, 5)

当这段代码执行之后,经过一系列的符号转换以及类型检查,goroutines生成一个 runtime.makeslice 的调用,它最终会调用 runtime.mallocgc 去申请分配一些内存给我们的切片。

查看 runtime.mallocgc 函数的内部,它展现了这个有趣的代码路径。

注意,我已经移除了这个函数的大部分内容,只在下面展示了相关部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 分配一个字节大小的对象
// 小的对象会在每个P的缓存空闲列表被分配
// 大的对象(> 32 kB)会在堆中直接被分配
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {

 // 一些错误检查和调试代码,在此省略

 // assistG是本次分配中用于消费的G
 // 如果GC当前未启动,则为nil
 var assistG *g
 if gcBlackenEnabled != 0 {
  // 为本次分配向当前用户G收费
  assistG = getg()
  if assistG.m.curg != nil {
   assistG = assistG.m.curg
  }
  // 对G的分配收费
  // 我们会在 mallocgc 的最后结算内部碎片
  assistG.gcAssistBytes -= int64(size)

  if assistG.gcAssistBytes < 0 {
   // 这个G处于欠债状态
   // 在内存分配之前,它需要帮助GC去修正这些欠债
   // 这必须要在禁用抢占之前发生
   gcAssistAlloc(assistG)
  }
 }
 
 // 实际的分配内存代码,下文省略
}

在上面的代码中,if assistG.gcAssistBytes < 0 这一行用于检查我们的 goroutine 是否处于分配欠债状态。“分配欠债”是一个花哨的说法,它表示这个 goroutine 在GC 周期内分配的资源量超过了它的 GC 工作量。

你可以把它想象成在一个 GC 周期中你的 goroutine 必须要为它的分配所交的“税”,除非这个税在内存实际分配之前已经预先支付过。此外,这个税款与这个 goroutine 尝试申请的资源总量成正比。这提供了一定程度的公平性,使得申请很大空间的 goroutine 需要为它申请的那些空间买单。

所以,假设我们的 goroutine 在当前的GC周期中第一次申请空间,它会被强制去做 GC 协助的工作。这里有一行有趣的代码就是调用 gcAssistAlloc

这个函数负责一些内部管理的工作,然后最终会调用到gcAssistAlloc去完成真正的 GC 协助的工作。我不会详细介绍gcAssistAlloc函数,但从本质上说,它做了以下事情:

  1. 确认这个goroutine没有在做一些不可抢占的工作(即系统goroutine)
  2. 进行 GC 标记的工作
  3. 检查这个 goroutine 是否仍有分配欠债,如果没有则返回
  4. 跳转至2

现在应该已经很清楚了,任何 goroutine 在一个 GC 分配周期中执行包含内存分配的工作将会导致 GCAssist 的罚时。由于这项工作必须要在分配之前完成,因此这会导致goroutine 在真正想要做的工作上呈现出延迟或者缓慢的现象。

在我们的 API 前端中,这意味着在GC周期中API的响应延迟会上升。正如之前提到的,每个服务器的负载上升,内存分配率也会上升,这又会反过来增加GC的速率(通常是每秒十几或二十几个周期)。我们现在知道,更多的GC周期意味着服务于API的goroutine会有更多的GC协助工作,然后,产生更高的API延迟。

你可以从我们应用程序的一个执行跟踪中很清楚地看到这一结果。以下是Visage的两个执行片段的跟踪,一个是正在运行GC周期的情况,另一个是没有运行GC周期的情况。

压舱物-GC.png 这个跟踪展示了每个 goroutine 分别运行在哪个处理器上。拥有 app-code 标签的一切 goroutine 都在运行我们应用程序的有用代码(例如,API请求的服务逻辑)。注意,除了运行GC代码的4个独立线程以外,其它的goroutine都被推迟了,并强制去做MARK ASSIST(即runtime.gcAssistAlloc)的工作。 压舱物-GC.png 把之前的结果与这个正在运行相同应用程序但未处在 GC 周期的性能分析对比,这里,我们的 goroutine 正与预想中的一样,正在花费它们大部分的时间去运行我们的应用程序代码。

所以,通过简单地降低 GC 频率,我们看到标记协助的工作减少了接近 99 %,它最终转化成在高峰流量中 99 分位 API 延迟的一个接近 45% 的改进。 压舱物-GC.png 你可能会感到很奇怪为什么Go会为它的GC选择一个如此奇怪的策略(使用协助),但是这确实有用。GC的主函数用于确保堆在一个合理的大小中,并保证垃圾不会无限增长。这在“停止这个世界”(STW)的GC中非常容易做到,但在并发的GC中,我们需要有一个机制去确保在GC周期中发生的内存分配不会无限增长。在我看来,在GC周期中,让每个goroutine为它所要申请的内存支付呈线性比例的分配税是一个非常优雅的设计。

对于该设计和选择过程的详细记录,可以查看这个Google文档

简单总结

我们注意到我们的应用程序正在做大量的GC工作:

  • 我们部署了一个内存压舱物
  • 因为允许堆增长得更大,所以GC周期减少了
  • 因为Go GC协助更少地推迟我们的工作,API延时也降低了
  • 压舱物的内存分配基本上是无需关心的,因为它只会驻留在虚拟内存中
  • 设置压舱物比设置GOGC值更加合理
  • 实践中,先从一个小的压舱物开始,然后在测试中慢慢增加它的大小

一些最后的想法

Go在为程序员抽离许多运行时细节的方面做得很棒,这对于大部分的程序员和应用程序来说可能已经足够有用。

有时当你开始突破应用程序环境的边界时(无论是运算、内存、IO),你可能会发现没有可以打开引擎盖去看一眼的工具,也不能找出为什么引擎没能高效地运行。当你需要做这些事情的时候,如果能像Go语言一样有一组可以让你快速发现瓶颈的工具,这肯定会对你有所帮助。

致谢 我要感谢 Rhys Hiltner 在调查和挖掘许多Go运行时以及GC复杂细节方面提供的宝贵帮助。同样也要感谢 Jaco Le Roux、Daniel Bauman、Spencer Nelson,并再次感谢Rhys,感谢他们在编辑和校对这篇文章时提供的帮助。

参考文献

Richard L. Hudson - Go 的垃圾收集之旅

Mark Pusher - Golang 实时 GC 的理论与实践

Austin Clements - Go 1.5 并发垃圾收集调度器