A Guide to the Go Garbage Collector 翻译(Go 语言垃圾回收指南)

原文地址

序言

本指南文档通过向熟练的 Go 用户提供关于 Go GC 一些深入信息,来帮助他们更好的对自己程序的运行代价的理解。同时也给 Go 用户提供一份如何深入洞察优化程序资源利用率的指南。本指南并不假设你熟悉 GC,但是对 Go 语言的熟知是必须的。

Go 语言负责管理所有的语言的值(values),绝大多数情况下,Go 开发者并不需要关注这些值是如何被存储。当然,这些数据实际上是存储在有限的物理内存中的。 因为内存的限制,因此它必须被谨慎的管理,和被回收重用,避免被一个运行的程序耗尽。根据需求分配和回收内存,是 Go 语言负责的任务。

自动回收利用内存被称为垃圾回收(garbage collection)。垃圾回收(简称 GC) 是一个回收被判断为程序不会再使用的内存的系统。Go 语言提供一个运行时库(runtime library),它伴随每个 Go 程序一同执行,提供了 GC 功能。

请注意,在 Go 语言规范中并没有确保存在 GC,规范只提到了底层存储的值是由语言自己管理的。(语言规范中的)这项标准是故意遗漏的,它允许实际的(Go 语言实现)使用完全不同的内存管理技术。

尽管如此,本指南描述的只是 Go 语言的一个特定实现,可能不适用于其他的实现。再具体一点,本指南只针对 Go 语言官方标准工具链 的实现。在 Gccgo 和 Gollvm 这两个实现中 GC 和(标准工具链)非常相似,有大量相同的概念,但是实现细节可能不一样。

另外,本文档更新以适用与最新的 Go 版本。目前,本文档描述的是 Go 1.19 的 GC。

Go 的值(Values) 存在于哪里

在我们深入讨论 GC 之前,先讨论一下那些不需要被 GC 管理的内存。
比如,被存储在 local 变量的非指针值很有可能完全不需要被 Go GC 管理,而是被分配在它具有相同的词法作用域的内存中。通常这样会比使用 GC 更加高效,因为 Go 编译器能够预先计算出这块内存什么时候被回收,并且发出清理的指令。 我们称这一类分配为栈分配,因为他的空间正是存在于在执行代码的 goroutine 堆栈上。

不能被 Go 编译器确定生存期的值不能够在栈分配,他们被叫做逃逸到堆内存(的值)。可以被认为是一个动态分配出来,用于存放 Go 的值的内存的统称。在堆上分配内存的操作 通常被称为动态内存分配,编译器和运行时都很难猜测这块内存如何被使用,以及何时它应当被清理。此时 GC 就被引入了:这是一个专门用于标记和清理动态分配内存的系统。

有许多原因会导致 Go 的值被逃逸到堆上。其中可能的一个原因是,它的大小是动态决定的。比方你有一个数组 slice,他的初始大小是一个变量而不是一个常量决定的。请注意,逃逸是具有传递性的:一个对值引用如果被保存到了另外一个已逃逸了的值内,则它也一定是逃逸的。

一个值是否逃逸取决于使用它的函数的上下文和 Go 编译器的逃逸分析算法。当值逃逸的时候,要精准的分析出来是不靠谱(fragile)且困难的:这个算法本身也是非常的精妙和复杂,并且在不同版本中的实现不同。如果想要了解如何定位一个值是否逃逸,可以参考消除堆分配(eliminating heap allocations)

跟踪垃圾收集

GC 一般指各种用于自动回收内存的过程,比如引用计数。在本文,GC 特指跟踪垃圾收集(译注: Tracing Garbage Collection,是一个术语,和它对应的是引用计数),它 通过跟踪指针的传递来识别使用中的,被称为活动对象,

我们先严格的定义一些术语:

  • 对象 —— 一个动态分配出来,包含一个或多个 Go 值的内存块。
  • 指针 —— 一个对于任何在对象中的值的地址的引用。 自然的,形如 *T 的值肯定是指针,同时一部分的 Go 内置的值类,包括 string/slice/channel/map/interface 也都包含了 GC 必须要追踪的内存地址。

对象和指向其他对象的指针构成了 对象图 (object graph)。为了识别活动的内存,GC 从程序的根处(被确定为正在使用的对象的指针)遍历对象图。本地变量和全局变量被视为两个典型的(root)。遍历对象图这个过程被称为 扫描 (scanning)。

所有的 跟踪垃圾收集 都使用这种通用的算法。不同 跟踪垃圾收集 的异同在于他们发现一旦发现内存活动(live)之后的做法。Go 的 GC 使用一种叫做 标记-清除 (mark-sweep) 的技术,它为了保持跟踪其进度,会标记遇到的值为活动。一旦跟踪操作完成,GC 会再遍历堆内存,将所有未被标记的内存设置为可分配状态。这个过程被称为 清除 (sweeping)。

另外一种你可能熟悉的技术,它会把对象移动到另外的内存区域,留下一个指针,用于接下来更新整个应用的指针。我们称此类会移动对象的 GC 为 移动式 GC(a moving GC),Go 则是 非移动式 GC。(译注:移动和非移动式 GC 的主要区别在于标记所有活动内存之后,是否把他们拷贝到一块新区域里)

GC 周期

Go GC 是一种 标记-清除 类型的 GC,它大致上被分为两个阶段:标记阶段,和清除阶段。这个描述看起来有些冗余,但它蕴含了一个重要信息:在所有的内存被跟踪之前,内存是无法被释放的,因为可能存在一些未被扫描的活动对象引用的内存。因此,清除行为必须彻底地和标记行为分开。进一步得到,在无 GC 相关工作执行时,GC 可能是处于彻底的非激活状态。GC 周期 不停的在三个阶段直接循环:清除、停止和标记。 对于本文档,我们认为 GC 周期从清除开始,然后停止,再标记。

接下来的几节内容,我们会专注给用户建立一种对于 GC 开销的直觉来帮助大家通过调试 GC 参数获益。

理解开销

GC 本身是一个复杂的软件系统,且构建在更加复杂的系统上。在尝试理解原理和调试行为的时候很容易陷入到各种细节中。这一节我们尝试提供一个框架来理解推测 Go GC 的开销,并且尝试调优参数。

首先,我们这个 GC 模型基于三个前提。

  1. GC 只涉及到两个计算资源:CPU 消耗时间和物理内存。
  2. GC 的内存开销包括活动的堆内存,和未标记的新分配的堆内存,对于 metadata 占用的空间,由于和前面的内存比起来太小(而忽略)。

注意:活动内存是指已经在前一个 GC 周期内被确认的内存,未标记的新内存则是在本周期内分配的,在周期结束的时候,它可能是活动的也有可能不是。
  1. GC 的 CPU 开销分为两部分:固定开销和随着活动堆内存增长而成比例增长的边际开销(marginal cost)。
注意:(随着内存的增长)渐进地说,清除阶段会比标记和扫描操作性能恶化的更快,因为它会随着包括被确定为非活动的整个堆的规模增长而增长。但是,在目前的实现中,清除操作本身要比标记和扫描的开销要快得多,因此在本文讨论中它常常被忽略掉。

此模型简单但是有效:它精准的把主要的 GC 开销分类处理了。不过这个模型没有对开销的规模处理,以及它们会如何相互影响。为了更好的建模,我们定义了一种被叫做 稳态 的情形。

  • 程序分配新内存的速度(bytes 每秒)是固定的。

注: 要明白,这个内存分配速度和新分配的内存有多少会活动是无关的。它可能部分活动,全部活动或者都不活动。(在此之外,一些之前分配的堆内存可能会变为非活动,因此不管新分配的内存多少活动,整个堆大小并不会增加。)

更具体的说明,设想我们有一个 web 服务,每一个请求会带来 2M 的堆内存分配。在处理请求过程中,在 2M 内存中最多有 512K 内存是活动的,当整个请求结束的时候,所有分配的内存都会变为非活动。接下来,我们简单的认为每一个请求会花费1秒时间来处理。然后,我们有稳定的每秒100次的请求,在这种情况下,分配速度是 200M/s,同时会有 50M (额外的)峰值活动堆内存。

  • 程序的对象图在每一个时刻看起来都是差不多一致的(大小,包含的指针数量,和图的深度都是近似常量)。
    再有,我们认为 GC 的边际开销也是固定的。

注:这个「稳态」看起来有点不符合实际,但是他确实代表了在某种固定负载情况下的程序的行为状态。 确实,在程序执行的时候负载也会是变化的,但是一个典型的程序,看起来像是一大堆稳定绑在一起的形成的瞬时状态。

注:稳态没有对活动的堆内存做任何假设。堆可能会随着一系列 GC 周期而增长,可能会缩减,也可能保持不变。要把这些状态全部涵盖的解释可能是冗长乏味且难以描述的,因此本指南专注在堆内存保持不变的场景。GOGC 那一节探索了非固定堆内存场景的一些细节。

在这样的一个活动堆大小固定的场景下,只要是在固定的时间间隔后执行,每一个 GC 周期在模型中执行的花销上都是一样的。那是因为固定的时间,固定的分配速度下,固定的堆内存会被分配。所以,因为活动堆内存和新增堆内存都是固定的,于是内存的使用也会永远一样。由于活动堆内存保持一致,边际带来的 GC CPU 开销也是固定的,于是在每一次常规(固定)间隔下执行的开销是固定的。

现在考虑如果把 GC 执行点延后会怎样。那么更多的内存会被分配,但是每一个 GC 周期仍然是使用相同的 CPU 开销(译注:这里执行 GC 时候的边际 CPU 时间应该会增大,可能并不明显)。那么随着的时间窗口内,GC 周期将会更少,带来总的更少的 CPU 消耗。反之如果把 GC 执行点提前,会带来更少的内存被分配,和更多的 CPU 开销。

这个情况,通过控制 GC 执行的频度,正是最基本的在 CPU 时间和内存分配之间的权衡。换句话说,这种权衡完全取决于 GC 执行频率

还剩余一点需要确定,那就是什么时候 GC 决定执行。我们注意到,在某种特定的稳态下,这个决定权衡的选择,会直接生效在 GC 频率上。在 Go 语言中,用户主要控制的参数,就是决定执行 GC 的时机。

GOGC

参数 GOGC 在一个高维度上定义了 GC 时 CPU 和内存的权衡取舍。

它是通过在执行完一次 GC 周期之后,设置一个下次执行 GC 时的 目标堆大小 来完成工作的。GC 的目标是在总的堆大小超过目标堆大小之前执行一个收集周期。总的堆大小是使用上一个周期结束时候的活动堆内存大小来定义的,这个大小加上所有的自该周期结束后由程序动态分配的大小。目标堆大小定义如下:

目标堆大小 = 活动堆 + (活动堆 + GC roots) * GOGC / 100

举一个例子,我们有一个 Go 程序,它的活动堆大小为 8M,另外还有 1M 的 goroutine 栈,1M 的全局指针数据。那么,在 GOGC 被设置成 100 的时候,下一个 GC 周期开始之前,新分配的内存大小将会是10MB,即 之前 (8M + 1M + 1M) 的 100%。 总的堆大小足迹为 18M。如果 GOGC 设置为 50,那么(新分配的内存)就是 5M;如果 GOGC 是200,那么这个值将会是 20M。

注:GOGC 从 Go 1.18版本开始包括那些根内存大小。在此之前,它只会计算活动堆内存大小。一般来说,栈内存占比很小,堆内存占据了所有 GC 需要处理的内存的绝大部分,但是在某些有用成千上万个 goroutine 的情况下,GC 的计算值会不那么准确。

目标大小控制了 GC 频率,它被设置的越大,那么 GC 等待下一个周期的时间就更长,反之亦然。精确的公式能够用于估算状况,不过 GOGC 更合适的是作为一个用来权衡 GC CPU 消耗和内存使用的参数。GOGC 值翻倍意味着双倍的堆内存开销,也意味着大致减半的 GC CPU 开销,反之也是一样。(想要更全面的解释清参考本文附件)。

注: 目标堆大小仅仅是一个目标,有一些原因会导致 GC 周期并不是正好在此目标处完成。比如,一个足够大的堆内存分配就能够轻松超过此目标。同时,还有一些在具体的 GC 实现中出现的相关原因,它们超出了本指南使用的模型。想要了解更多的细节,可以参考 延迟(latency) 这一节,不过完整的细节需要查阅这里的资料可以获得。

GOGC可以通过配置环境变量设置(适用于所有的Go程序),也可以通过 runtime/debug 模块的 SetGCPercent 方法设置。

请留意 GOGC 这个参数是可以用来把整个 GC 关闭的(如果 memory limit 没有设置的话),可以通过 GOGC=off 或者 SetGCPercent(-1) 来操作。这相当于把 GOGC 设置为一个无限大的值,一个无法到达的触发 GC 的新分配内存值。

为了让大家更好的理解,可以在下面尝试下面一个交互式可视展示,它是基于之前讨论的 GC 代价模型实现的。此展示程序描绘了一个程序,它在无 GC 时会花掉10秒(CPU 时间)执行。第一秒它会做一些初始化操作(活动内存增加),接下来进入稳态。整个程序会分配 200M 活动,其中 20M 是某一时刻下活动堆内存。这个程序假设所有的 GC 工作都只针对于活动堆内存,并且(不切实际的)假设程序不使用额外的内存。

译注:请大家务必去体验一下原文链接的带交互的展示程序,能够直观的感受到参数调节对于 GC 和 CPU 开销的意义。

使用滑块调整 GOGC 的大小,观察程序在总的时间消耗和 GC 开销方面的反馈。每一个 GC 周期结束时新增堆下降为0。新增堆下降为0的开销包括第N个周期的标记阶段开销和第N+1个周期的清除阶段开销。注意这个可视化交互程序(包括本指南的其他可视化程序)假设在 GC 执行的时候整个程序是暂停的,所以 GC 的 CPU 开销完全等同于它将新增堆大小减为0的时间。这个只是为了简化可视化;但是对于直觉感受来说是适用的(译注:?)。X轴的增长反映了总的 GC 带来的额外的 CPU 时间的增加。

A Guide to the Go Garbage Collector 翻译(Go 语言垃圾回收指南)_第1张图片

注意 GC 总是会带来一些 CPU 和峰值内存开销(的变化)。当 GOGC 增加的时候,CPU 开销下降,但是峰值内存相对于活动堆成比例的增加。当 GOGC 减小时,峰值内存消耗减少但是带来了一些额外的 CPU 开销。

注:(可视化程序)图表展示的是 CPU 时间而非真实世界时间。如果该程序跑在只有1个 CPU 的计算机并且使用全部的资源的情况下,二者是等价的。一个真实世界的程序往往是多核系统,并且 Go 程序无法在任何时候都利用 100% 的 CPU 资源。在这种情况下,GC 带来的真实时间开销往往要比 CPU 时间更低。

注:Go 语言 GC 有一个 4M 的最小堆内存大小,如果 GOGC 设置的目标值小于它的话,将会使用此值。我们的可视化展示程序也遵循此细节执行。

接下来是一个更加动态和接近真实的例子。和上面一样,在无 GC 情况,程序需要10秒 CPU 时间执行,活动内存在开始阶段开始逐步增长,并且稳态过程中急剧增加。该例子说明了在稳态情况下活动堆是如何变化的,并且可以看到更高的增长率导致更加频繁的 GC 周期。

A Guide to the Go Garbage Collector 翻译(Go 语言垃圾回收指南)_第2张图片

限制内存使用

在 Go 1.19 之前,GOGC 是唯一的可以调节 GC 行为的参数。虽然它在权衡(CPU 和内存)上工作良好,但是它没有考虑到内存并不是无限大的因素。考虑一下如果我们有一个活动内存的瞬间峰值的情况:因为 GC 是按照总活动内存大小的成比例选择的,GOGC 只能依据峰值活动内存确定,即便通常情况下更高的 GOGC 也提供了更好的权衡。

下面的例子说明了瞬时堆分配峰值带来的变化。

A Guide to the Go Garbage Collector 翻译(Go 语言垃圾回收指南)_第3张图片

如果上面的示例程序工作在一个可用内存大概在 60M 多一点的容器中,那么 GOGC 就不能被调到比 100 更多(译注:会在峰值活动内存增长后耗尽内存),尽管在后续的 GC 周期中有足够多的内存可以使用。而且,对于很多程序来说,瞬时峰值可能很少出现且难以预计,但它会造成偶然的,无法避免的可能会造成巨大代价的 耗尽内存(out-of-memory) 状况。

这就是为何在 1.19 版本中,Go 增加了一个设置运行时使用内存的上限的方式。内存上限可以通过配置 GOMEMLIMIT 环境变量生效,或者调用 runtime/debug 库的 SetMemoryLimit 方法设置。

此参数设置了 Go 运行时能够使用的最大内存数量。具体设置的值,可以通过 runtime.MemStats 包里面的 mstats.Sys - mstats.HeapReleased 计算得到。

或者通过 runtime/metrics 包的 /memory/classes/total:bytes - /memory/classes/heap/released:bytes 方式获取。

因为 Go GC 系统对于多少堆内存使用有清晰的掌控,它会基于设置的上限和 runtime 使用的内存量来设置总的堆内存大小。

下面的可视化展示程序和 GOGC 章节同样的稳态,但是增加了 10M 的额外 runtime 开销和可以一个可以调节的内存上限设置。尝试拉动 GOGC 和内存上限设置开关,看看会发生什么。

A Guide to the Go Garbage Collector 翻译(Go 语言垃圾回收指南)_第4张图片

可以看到当内存上限被设置为低于峰值内存需求的时候(GOGC=100的情况下峰值内存是 42M),为了保持内存在限制范围内,GC 会以非常高频的状态执行。

回到之前的有一个瞬时峰值堆内存的例子,通过设置内存上限和调整 GOGC,我们可以得到两全其美的状态:没有内存使用溢出(breach)和更好的资源使用率(译注:如下图,即在 GOGC = 100的情况下,控制最大使用内存在 60M 以内)。尝试下方的可视化展示程序。

A Guide to the Go Garbage Collector 翻译(Go 语言垃圾回收指南)_第5张图片

留意到在在某些 GOGC 和内存上限设置的情况下,峰值内存被限制控制住了,但是后续的程序依旧遵循 GOGC 的设置的规则执行。

你可能观测到一个有意思的细节:就算 GOGC 关闭,内存上限仍然是生效的。实际上这个配置代表着最大化资源利用率,因为它在内存上限的情况下设置了最低的 GC 执行频率。在示例中,每次程序的堆大小都涨到了设置的内存上限值处。

不过,即便内存上限是一个明确的有效工具,对于它的使用也不是没有代价的,它也无法取代 GOGC。

想一下在活动内存增长到接近内存上限下的情况。在上面的可视化程序中,关闭 GOGC,并且缓慢的减少内存上限看看会发生什么。你会发现总的程序执行时间会在某一时刻猛增,原因是为了遵循内存上限,GC 需要不停的执行。

在这种状态下,因为无限制的 GC 程序已经无法正常执行,这被叫做抖动(Thrashing)。它非常的危险,因为此时程序被严重的拖慢。更坏的情况下,它会发生在我们尝试使用 GOGC 来避免的最坏情况:一个很大的瞬时对内存峰值导致程序陷入无限卡顿状态。把内存上限设置为 30M 或更低,看看瞬间峰值导致的状况有多糟。

很多情况下,无限的卡顿会比内存耗尽更糟糕,因为后者会导致更快的出错。

因此,内存上限被设计成软限制。Go runtime 并不保证它一定在所有情况下都维持内存上限;只是承诺一定范围的效果。放宽内存上限的限制对于防止抖动状况至关重要,因为它给了 GC 一个机会逃出去:让内存使用超过上限,防止在 GC 上划掉太多的时间。

这在内部是通过为 GC 设置一个能使用更高的 CPU 时间限制来实现的(会带来瞬间的 CPU 峰值使用率)。这个限制目前是大约被设置为50%,大约有 2 x GOMAXPROCS 秒 的 CPU 时间窗口(译注:没看明白)。限制 GC 使用 CPU 时间的原因是 GC 本身是延后执行的,同时程序可能仍然在持续的分配新内存,甚至超过内存上限。

50%的 GC CPU 使用是基于在内存足够的情况下一个程序受到的最坏冲击。在此情况下,一个被错误的配的过低的内存上限,会导致程序最多被拖慢2倍,因为 GC 最多只能够占用 50% 的 CPU 时间。

注:本文档的可视化程序并不模拟 GC 的 CPU 使用限制。

推荐用法

尽管内存上限是一个有强大的设置,而且 Go runtime 也在错误使用的最坏情况做了缓和操作,在使用时候你仍需要深思熟虑。下面是一些关于内存上限在哪些地方最有用、最适用,以及在哪些地方它可能弊大于利的建议。

  • 在 Go 程序运行的环境完全受你掌控时,并且在此 Go 程序是唯一的能够利用各种资源时(比如像一个预留了内存且限制了内存的容器),可以充分利用内存上限。

比如一个部署在固定内存大小的容器中 web 服务就是一个好例子。

在这种情况下,一种较好的经验法则是预留 5-10% 的内存给 Go runtime 无法感知的地方。

  • 在实际运行的时候动态调整内存上限用于适配各种状况。

比如一个会调用 C 库分配大量内存的 cgo 程序。

  • 不要在和其他程序共享有限内存的场景下关闭 GOGC,否则其他程序可能会被你的程序影响。相反,设置内存上限用来缓解峰值,并且设置更小,更合理的的 GOGC 用来处理常规状况。

对于一个和其他程序合租内存空间的 Go 程序来说,尝试「预留」内存看起来挺有吸引力的,除非这些程序是完全同步的(比如 Go 程序启动一些子进程,且在它们执行的时候阻塞),不然在所有程序都需要更多内存的时候会变得不可控。在 Go 程序不需要太多内存的时候尽量少实用一些会让整体更加可靠。这项建议在过度使用,即容器的内存上限之和大于实际物理内存的情况(译注:比如云主机超卖实例)也是适用的。

  • 不要在你无法控制的情况下,特别是你的程序使用内存和输入相关的情况下使用内存上限。

比如一个 CLI 工具或者一个桌面应用。在你不知道有哪些输入的情况下,或者你不知道系统有多少内存的情况下,内置一个内存上限在程序内会引起令人困惑的程序崩溃和性能问题。再说了,一个有经验的用户自己会按照需求设置合适的内存上限。

  • 当一个程序已经快用完环境的内存的时候,不要通过设置内存上限来避免内存耗尽。

这样确实可以有效的避免了内存耗尽,但是带来了程序变慢的风险,通常情况下这样并不划算,那怕 Go 自己会尝试缓解抖动。这种情况下,更好的做法是增加环境内存(再设置一个可能的内存限制)或者调小 GOGC(明确的 CPU 内存权衡胜过抖动缓解)。

延迟(响应速度)

本文档提供的可视化程序模型在 GC 执行的之后暂停了程序的执行。实际的 GC 实现中确实存在这样的行为,他们被称作 "stop-the-world"(以下简称 stw)GC 操作。

实际上 Go GC 并不是完全的 stw,大部分时候它都是和用户程序并发的执行的。(并发执行)主要是为了降低程序的延迟。具体的说,延迟指端到端的时间间隔(比如一个 web 请求)。到目前为止,本文档主要考虑的是程序的吞吐量(比如在单位时间内 web 请求的数量)。可以注意到在 GC 周期那一节的每个示例都专注于总的 CPU 执行时长。虽然这个时长对于一个 web 服务来说远没有那么重要。尽管吞吐量对于一个 web 服务来说仍然是很重要的(比如每秒查询次数),通常每一个独立请求的延迟更加重要。

在延迟方面,GC 的 标记和清理阶段都会产生较大的 stw 时间,对于一个正在执行请求的 web 服务来说,此时请求会无法执行。 Go GC 极力避免全局的 stw 时间随着堆内存的增长而增长,并且避免在用户程序执行的时候执行核心的内存跟踪算法(这个 stw 在算发烧和 GOMAXPROCS 更相关,但是更决定性的是它停止执行的 goroutine 的时间)。并发的收集并非是没有代价的,实际上它通常要比一个 stw 的垃圾收集器吞吐量更低。但是重要的是更低延迟并不一定意味着更低的吞吐量,并且 Go 的垃圾回收器不断的在延迟和吞吐两方面改进。

Go 并发执行的 GC 并不会是的我们在前文的讨论变得无意义:上面的那些情形并不依赖具体设计的选择。GC 的执行频率仍然是 CPU 时间和内存使用权衡的主要因子,并且它还在延迟方面其主要因素。因为大部分 GC 的开销都在标记阶段被激活的时候发生。

关键的要点在于,降低 GC 频率可能导致 在延迟响应方面的改善。这就意味着不仅可以通过调试参数,通过增加 GOGC 值,并且(或者)增加内存上限降低 GC 频率,而且可以通过这里的优化指南来优化。

不过延迟通常要比吞吐量更加复杂更加难懂,因为他是一个时刻发生的,不像吞吐量一下只是把开销加总。结果就是响应延迟和 GC 执行频率并不是非常的直接。下面列出了一些可能的产生延迟的原因供深入思考研究。

  1. 主要的stw暂停都是发生在标记和清除阶段的切换期内。
  2. 执行 GC 标记阶段最多会带来 25% 的 CPU 资源的使用。
  3. 在内存分配率很高的时候执行用户代码的 goroutine 会辅助执行 GC。
  4. 指针会带来 GC 标记阶段额外的开销。
  5. 扫描执行中的 goroutine 的 root 内存信息时候会挂起该 goroutine。

运行追踪器能够观测到除了指针带来的开销之外以上其他源的状态。

相关资料

尽管上文的信息都是精确的,但是仍然缺乏一个全面的对于 Go GC 方案带来的开销和权衡的了解。如果想了解更多的信息,请参考下面的资源:

关于虚拟内存的说明

本指南着重描述 GC 相关的物理内存的使用,一个随之而来的问题是,它意味着什么,虚拟内存相比又是怎样的(虚拟内存在 top 类的程序中显示为 VSS)。

物理内存是指在实际的 RAM 芯片中的内存。虚拟内存则是操作系统为每个不同的程序提供的关于物理内存的抽象定义。通常情况下是容许程序预留实际上并未和任何物理地址映射(map)的虚拟地址。

因为虚拟内存仅仅只是一个由 OS 维护的映射关系,因此通常情况下预留一大片未映射到物理内存的虚拟内存的成本是极低的。

Go runtime 通常依赖这个低成本的虚拟内存,从以下几个方面:

  • Go runtime 不会删除它映射的虚拟内存。对应的,它使用一些 OS 提供的用于将指定虚拟内存相关联的物理内存释放的接口。
    此技术用与管理内存上限和把 Go runtime 不再使用的内存归还给 OS。Go runtime 也会在后台不间断地把不适用的内存释放。关于此技术可以参考这里
  • 32位平台上 Go runtime 会预留 128M 到 512M 地址空间——在对的最前面,用于限制碎片化问题。
  • Go runtime 会预留大量的虚拟地址空间给内部数据结构。在64位平台上,最小的虚拟内存空间是大约 700M。在32位平台上则可以忽略。

结果是,在 top 中看到的关于程序的 VSS 参数信息往往对于了解使用的内存空间意义不大,请把注意力放在 RSS或者类似的信息上,它才能真实的反应物理内存使用情况。

优化指南

定位开销

在开始优化你的 Go 程序 GC 相关部分之前,先要确认 GC 是否是主要的开销。

Go 的技术生态中有不少用于定位开销和优化的工具。可以在诊断分析指南 中大致过一遍。我们在这里主要关注和 GC 行为以及影响相关的一部分工具,并且按照一个更合理(reasonable)的顺序介绍。

1. CPU profiles

CPU profiling 是最好的着手点。它提供了一个对于 CPU 开销的宏观展示,虽然不熟练的话是很难通过它了解到程序中 GC 的占用的资源量级的。幸运的是,可以通过对 runtime 包里面的不同函数的理解来归因 GC 的影响。下面是一组用于解释 CPU profile 很有用函数。

注:由于下面列出来的函数都不是最下层(leaf) 函数,因此他们可能不会在 pprof 工具的里使用的 top 指令就能展示出来。因此要使用 top -cum ,或者直接用 list 函数名 查询这些函数的累计开销。

runtime.gcBgMarkWorker: 专门做 GC 标记工作的 goroutine 的入口函数。它消耗的时间会随着 GC 频率、扫描的对象图的大小和复杂度的提升而成比例提高。它展示了程序中有多少的时间花在 GC 标记和扫描阶段。

注:对于一个大部分时间都处于空闲状态的 Go 程序,Go GC 会使用额外的(闲置)CPU 资源用来加速其执行。结果就是他可能占比很高,但是其实并无特殊意义。发生这种情况的常见原因:所有的逻辑在一个 goroutine 中完成,但是 GOMAXPROCS > 1(译注:Go 会使用不执行业务的其他 CPU 完成 GC,导致看起来 CPU 资源消耗较高)。

runtime.mallocgc: 堆内存分配器入口。如果它的累计消耗时间 > 15%,往往意味着大量的内存分配。

runtime.gcAssistAlloc: 执行用户代码的 goroutine 把它的时间片用来协助 GC 执行扫描和标记的入口。如果累计消耗时间 > 5%, 表示程序在内存分配速度上超过了 GC。这通常意味着 GC 已经带来了很高的冲击,它也会反映在程序花在 GC 标记和扫描的时间上。
需要注意的是该函数在 runtime.mallocgc 中也会被执行,会导致它的占用时间也膨胀起来。

2. Execution traces

尽管 CPU profile 在对分析(函数)累计开销上非常有用,它却在定位更加细微、少见的性能开销以及响应速度相关用处较小。Execution traces 通过对 Go 程序执行时的一个较短窗口期,提供另一方面的,丰富有深度的展示。它可以提供可视化的,关于 Go GC 相关的各种时间,标记执行路径以及程序是如何与 GC 交互的。在 trace viewer 中可以查肯定能到所有被恰当地标记了的 GC 事件。

查看相关文档了解如何着手使用 execution traces。

3. GC traces

当其他方案失效时,Go GC 提供了一个特别的 trace 手段可以提供一些深入的对 GC 行为做观察。这些 trace 手段通过启动的环境变量 GODEBUG 开启,直接在标准错误输出(STDERR)打印,每一次 GC 周期输出一行。读懂他们需要对 GC 的实现有一定了解,这也是主要用来调试 Go GC 本身的方法,不过有时候他们也会对你理解 GC 的行为有所帮助。

核心的 GC trace 可以通过配置环境变量 GODEBUG=gctrace=1 开启。通过这项配置输出的内容可以在运行时的环境变量配置这一节查到。

一个叫做 调步追踪(pacer trace) 的,关于对于 GC trace 的补充 debug 信息可以通过设置环境变量 GODEBUG=gcpacertrace=1 开启。理解相关的输出信息需要对 GC 的调步算法(参考相关资料)有一定了解,这些内容本指南不会涉及。

减少堆内存分配

直接减少 GC 一开始管理的内存是一种降低 GC 开销的办法。下面描述的一些技术手段能够带来最大的性能收益,上面的 GOGC 章节已经证明, Go 程序的堆内存分配率是影响本文认为的最大的开销:GC 频率的最大因子。

堆内存 profiling

定位开销之后,接下来就是减少堆内存最多分配的地方。为了达到此目的,内存 profile (实际上是堆内存 profile)非常的有用。阅读此文档获取上手信息。

内存 profile 描述了在程序中堆内存来自哪里,这是通过跟踪他们在执行栈上分配堆内存地方实现的。每一份关于内存的 profile 都可以把内存分为4类。

  • inuse_objects—活动内存对象数量
  • inuse_space—活动内存对象占据的内存字节大小
  • alloc_objects—Go 程序自启动后分配的内存对象总数量
  • alloc_space—Go 程序自启动后分配的内存对象总共占据的内存字节大小

要查看不同类别的堆内存信息,可以在使用 pprof 工具的时候用 -sample_index flag 参数,或者在交互式命令行工具下使用 sample_index 选项。

注:默认情况下内存 profile 工具只会采样一小部分堆内存对象,所以它们并不会包含所有的堆分配信息。不过这对于找到热点区域已经足够了。可以尝试通过 runtime.MemProfileRate 调整采样率。

为了减少 GC 开销,查看 alloc_space 通常是最有效的,因为它直接反映了内存分配率。观察这个信息能够获取到内存 分配热点,也代表优化的最大收益。

逃逸分析

当我们通过堆 profile 定位出了那些需要修改的堆内存分配器的之后,该如何消除这些分配呢?可以利用用 Go 编译器的逃逸分析工具来找到更高效的存放这些内存的地方,比如 goroutine 的栈空间。幸运的是,Go编译器可以描述一个 Go 的值为何被泄露到堆上。有了这个信息,就可以修改代码使得这个分析器的结果改变(往往这是最困难的一步,不过本指南并不涉及这部分)。

As for how to access the information from the Go compiler's escape analysis, the simplest way is through a debug flag supported by the Go compiler that describes all optimizations it applied or did not apply to some package in a text format. This includes whether or not values escape. Try the following command, where [package] is some Go package path.

那么如何获得 Go 编译器的逃逸分析结果呢,最简单的方法是通过 Go 编译器支持的调试参数,它会生成关于指定包的所有优化信息到文本文件中,其中包括了值是否会逃逸的信息。尝试下面的命令,[package]指要分析的包地址。

$ go build -gcflags=-m=3 [package]

This information can also be visualized as an overlay in VS Code. This overlay is configured and enabled in the VS Code Go plugin settings.
生成的信息也可以在 VS Code 中通过浮动层的方式展示。相关的配置和启用开关可以在 VS Code 的 Go 插件设置中控制。

  1. 开启 gc_details
  2. 用 overlay 方式显示的逃逸分析结果配置

(译注:附录有我自己尝试的截图)

最后,Go 编译器还支持将这些信息输出到一个方便程序读取的格式(JSON) 中,后续可能会有一些额外的定制工具来处理。关于这部分可以参考源码中的文档

Implementation-specific optimizations

Go GC 对于活动内存的关系图(demographics)特别明干,因为一个复杂的兑现和指针关系图,既会限制并发度也会给 GC 产生更多的工作量。因此,存在一些为特定通用结构优化 GC 的方式。在下面列举了相关的最有效的优化方案。

注:按照下面的方式优化可能会因为其含糊的意图而降低代码的可读性,而且可能会在新版本失效。仅当他们的确能够带来很大优化的时候使用。可以参考定位开销章节定位这些优化点。
  • 不包含指针的值是和其他值隔离处理的。
    于是,在不是必须的地方干掉指针会降低 GC 给程序带来的缓存压力(cache pressure)。结果是依赖下标而不是指针值的数据结构,尽管不合理,确有着更好的性能。只有当你很清楚对象图很复杂且 GC 在标记和扫描时花费巨大的时候才这么做。
  • GC 会在扫描一个值时,会在遇到最后一个指针之后结束。
    于是,把所有的指针成员放在结构体的最前方可能更优。只有当程序确实花费在对它的标记和扫描上很大是才值得去做。(理论上编译器应该自动这样做——指聚合指针在头部——但是还没有这样实现,目前结构体的成员还是按照他们在源码中的顺序扫描的。)

此外,GC 会去和处理每一个它接触到的指针,因此使用一个 slice 的下标来代替指针会帮助减小 GC 开销。

附录

关于 GOGC 的更多信息

(这一节很无聊且没有什么意义,省略了,读者可以自行阅读


读后感

  • 这份文档描述的信息非常全,也许它本身内容深度不太高,但是给出的参考文档都是非常有价值的。
  • 关于调参 GOGC 和 memory limit 的部分,感觉价值待商榷。
  • 整体来看,它设想的 Go 程序场景还是部署在云(或者容器)上的无状态 web 服务。对于我们的场景(长时间运行的游戏服务器),有一些观点我认为是认可的,比如「卡顿不如 OOM」和「不应当 reserve 内存」等。
  • 用逃逸分析来减少堆内存分配的思路,是第一次见到。感觉很新颖,可能对于无状态 web 请求的方式很有用(因为没有常驻逻辑和相关的内存引用,一次请求完之后所有资源回收,理论上可以做到大部分都在栈上分配内存)。
  • 翻译完了之后才发现别人已经译过了,而且不止一个人……

附:
VS 中使用 GC 分析工具:
A Guide to the Go Garbage Collector 翻译(Go 语言垃圾回收指南)_第6张图片

你可能感兴趣的:(gcgo优化)