The website uses cookies. By using this site, you agree to our use of cookies as described in the Privacy Policy.
I Agree

【转载】Go内存压舱物:利用大块内存降低Go GC的运行频率

【转载】Go内存压舱物:利用大块内存降低Go GC的运行频率
甘钊宇服务端
2019-12-12 14:09
465
28
21

本文转载自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应用程序的性能分析之后,我们做出以下的观察结论:

  1. 在稳定状态,我们的应用程序每秒钟触发大约8-10次垃圾收集(GC)(每分钟400-600次)
  2. 大于30%的CPU周期被花费在了与GC相关的函数调用中
  3. 在流量高峰时,GC的周期数会增加
  4. 我们的平均堆大小很小(小于450MiB)

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

什么是垃圾收集(GC)?

在现代的应用程序中,分配内存有两种常用的方法:栈和堆。绝大多数程序员是从第一次写出导致栈溢出的递归程序时熟悉栈的。另一方面,堆是一个可以用作动态分配的内存池。

栈分配很好,因为它只在它们所属的函数的生命周期内有效;然而,堆分配不会在它们离开定义域之后被自动回收。为了防止堆无限制地增长,我们必须显式地取消分配,或者在一些有内存管理的编程语言中(例如Go)依赖垃圾收集器去寻找并删除那些不再被引用的对象。

一般来说,在有GC的语言中,在栈中存储对象越多就越好,因为这些分配从来都不会被GC看到。编译器使用一种叫做逃逸分析(Escape Analysis)的技术去决定一个对象是否可以在栈中分配,或者必须要放在堆中。

在实践中,编写强制编译器只在栈中分配空间的程序是非常受限的,因此在Go中,我们利用它的绝妙的GC去保持我们堆整洁干净。

Go的GC

GC是一个软件中复杂的部分,所以我将尽力保证这部分的相关性。

自从1.5开始,Go并入了一个并发的“标记-清除GC”(Mark-and-Sweep GC)。这种GC正如它的名字一样,分为两个阶段:标记和清除。“并发”只意味着它在整个GC周期里不会“停止这个世界”(Stop-the-World,STW),而是尽可能地与我们应用程序的代码并发运行。在标记阶段,GC运行时会遍历所有的应用程序在堆中引用的对象并标记它们仍然在使用。这个对象的集合称为实时内存(Live Memory)。在这个阶段之后,在堆中没有被标记的其它对象都被认为是垃圾,然后在接下来的清理阶段,他们会被清理器回收。

总结以下术语:

  • 堆大小——包括所有在堆中的内存分配;一些是有用的,一些是垃圾
  • 实时内存——指当前被正在运行的应用程序引用的所有内存分配;不是垃圾

事实证明,在现代的操作系统中,清理(释放内存)是一个非常快的操作,所以Go的标记-清除GC的GC时间很大程度上由标记组件主导,而不是清理组件。

标记包括遍历应用程序当前指向的所有对象,所以时间与系统中的实时内存总数成比例,而不是堆的总大小。换句话说,堆中有额外的垃圾不会增加标记的时间,因此也不会显著地增加一个GC周期的计算时间。

综上所述,更低频率的GC意味着更少的标记过程,也意味着更少的CPU花费,这看起来说合理的,但代价是什么呢?内存。运行时等待GC越久,系统内存中就会积累越多的垃圾。

正如我们在前面提到的,运行在拥有64GiB物理内存的VM上的Visage程序运行GC非常频繁,使得它大约只使用400MiB的物理内存。为了了解其中的原因,我们需要深入研究Go是如何解决GC的运行频率和内存的权衡问题,并讨论调度器(pacer)。

调度器(Pacer)

Go GC使用一个调度器去决定下一个GC周期何时触发。调度被建模成一个类似于控制问题的模型,它试图找到合适的时间去触发GC周期,以使它可以达到预期的堆大小目标。Go的默认调度器会在堆的大小是原来的两倍时尝试触发GC周期。 它是通过在当前的GC周期的标记(Mark)阶段的终止阶段设置下一次触发的堆大小实现的。因此在标记完所有的实时内存之后,它就能做出当下一次的堆大小达到当前实时集的2倍时就运行GC的决定。2倍这个数字来自于一个GOGC的环境变量,运行时用它来设置触发比例。

在我们的案例中,调度器在保持我们堆中垃圾最小化过程中做了非常出色的工作,但也带来了很多由于不必要工作带来的代价,毕竟我们只使用了大约0.6%的系统内存。

引入压舱物

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

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

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

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周期会在堆大小(现在刚好大于10GiB)再次翻倍的时候发生。

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

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

改动的效果符合我们的预期——我们看到GC周期数减少了99%:

所以,它看起来很棒,那CPU利用率又如何呢?

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

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

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

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

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

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

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

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

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

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数组写入一半内容:

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观察:

%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会帮助后台工人,特别是在标记工作中有所帮助。

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

someObject := make([]int, 5)

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

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

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

// 分配一个字节大小的对象
// 小的对象会在每个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周期的情况。

这个跟踪展示了每个goroutine分别运行在哪个处理器上。拥有app-code标签的一切goroutine都在运行我们应用程序的有用代码(例如,API请求的服务逻辑)。注意,除了运行GC代码的4个独立线程以外,其它的goroutine都被推迟了,并强制去做MARK ASSIST(即runtime.gcAssistAlloc)的工作。

把之前的结果与这个正在运行相同应用程序但未处在GC周期的性能分析对比,这里,我们的goroutine正与预想中的一样,正在花费它们大部分的时间去运行我们的应用程序代码。

所以,通过简单地降低GC频率,我们看到标记协助的工作减少了接近99%,它最终转化成在高峰流量中99分位API延迟的一个接近45%的改进。

你可能会感到很奇怪为什么Go会为它的GC选择一个如此奇怪的策略(使用协助),但是这确实有用。GC的主函数用于确保堆在一个合理的大小中,并保证垃圾不会无限增长。这在“停止这个世界”(STW)的GC中非常容易做到,但在并发的GC中,我们需要有一个机制去确保在GC周期中发生的内存分配不会无限增长。在我看来,在GC周期中,让每个goroutine为它所要申请的内存支付呈线性比例的分配税是一个非常优雅的设计。

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

简单总结

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

一些最后的想法

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 并发垃圾收集调度器

Measure
Measure
Summary | 2 Annotations
压舱物增加了堆的基准大小,从而使GC延迟触发
2020/10/15 06:37
当下一次的堆大小达到当前实时集的2倍时就运行GC的决定
2020/10/15 06:40