Go 垃圾回收指南

引言

本指南旨在帮助高级 Go 用户通过深入了解 Go 垃圾回收器来更好地理解其应用程序的成本。它还提供了关于 Go 用户如何利用这些见解来提高应用程序资源利用率的指导。本指南不假设您了解垃圾回收的任何知识,但假设您熟悉 Go 编程语言。

Go 语言负责安排 Go 值的存储;在大多数情况下,Go 开发者无需关心这些值存储在哪里,或者是否存储,以及为什么。然而,实际上,这些值通常需要存储在计算机的物理内存中,而物理内存是有限的资源。由于它是有限的,因此必须仔细管理和回收内存,以避免在执行 Go 程序时内存耗尽。Go 实现的任务是根据需要分配和回收内存。

自动回收内存的另一个术语是垃圾回收。从高层次来看,垃圾回收器(简称 GC)是一个系统,它通过识别内存中不再需要的部分来代表应用程序回收内存。Go 标准工具链提供了一个随每个应用程序一起发布的运行时库,这个运行时库包含了垃圾回收器。

请注意,本文档所述的垃圾回收器的存在并非由Go 规范保证,Go 规范只保证 Go 值的基础存储由语言本身管理。这种省略是故意的,并支持使用截然不同的内存管理技术。

因此,本指南是关于 Go 编程语言的特定实现,并且可能不适用于其他实现。具体来说,本指南适用于标准工具链(gc Go 编译器和工具)。Gccgo 和 Gollvm 都使用了非常相似的 GC 实现,因此许多相同的概念适用,但细节可能有所不同。

此外,这是一份动态文档,会随着时间推移而变化,以最佳地反映 Go 的最新版本。本文档目前描述的是 Go 1.19 版本中的垃圾回收器。

Go 值存储在哪里

在深入探讨 GC 之前,让我们先讨论一下不需要由 GC 管理的内存。

例如,存储在局部变量中的非指针 Go 值很可能完全不会由 Go GC 管理,Go 会安排分配与创建该值的词法作用域相关的内存。一般来说,这比依赖 GC 更高效,因为 Go 编译器能够预先确定何时可以释放该内存并发出清理的机器指令。通常,我们将以这种方式为 Go 值分配内存称为“栈分配”,因为空间存储在 goroutine 栈上。

由于 Go 编译器无法确定其生命周期而无法以这种方式分配内存的 Go 值,被称为逃逸到堆。可以将“堆”视为内存分配的包罗万象之地,用于存储那些需要被放置在某个地方的 Go 值。在堆上分配内存的行为通常被称为“动态内存分配”,因为编译器和运行时对于这块内存如何使用以及何时可以清理几乎不做任何假设。这就是 GC 的作用所在:它是一个专门识别和清理动态内存分配的系统。

Go 值需要逃逸到堆的原因有很多。其中一个原因是其大小是动态确定的。例如,考虑一个 slice 的底层数组,其初始大小由变量而不是常量决定。请注意,逃逸到堆也必须是传递的:如果对一个 Go 值的引用被写入到另一个已被确定会逃逸的 Go 值中,那么该值也必须逃逸。

Go 值是否逃逸取决于它使用的上下文以及 Go 编译器的逃逸分析算法。试图精确列举值何时逃逸将是脆弱且困难的:该算法本身相当复杂,并且在 Go 版本之间会有变化。有关如何识别哪些值逃逸以及哪些值不逃逸的更多详细信息,请参阅关于消除堆分配的部分。

追踪式垃圾回收

垃圾回收可能指许多不同的自动回收内存的方法;例如,引用计数。在本文档的上下文中,垃圾回收指的是追踪式垃圾回收,它通过传递地跟踪指针来识别正在使用(即所谓的活跃)的对象。

让我们更严格地定义这些术语。

对象和指向其他对象的指针共同构成了对象图。为了识别活跃内存,GC 从程序的开始遍历对象图,这些根是指向程序中肯定正在使用的对象的指针。根的两个例子是局部变量和全局变量。遍历对象图的过程称为扫描。您可能在 Go 文档中看到的另一个短语是对象是否可达,这仅仅意味着该对象可以通过扫描过程被发现。另请注意,除了一个例外,一旦内存变得不可达,它就保持不可达状态。

这个基本算法是所有追踪式 GC 的共同之处。追踪式 GC 的区别在于它们发现内存是活跃的之后做什么。Go 的 GC 使用标记-清除技术,这意味着为了跟踪其进度,GC 还会将遇到的值标记为活跃。追踪完成后,GC 会遍历堆中的所有内存,并将标记的所有内存设置为可供分配。这个过程称为清除

您可能熟悉另一种技术,即实际将对象移动到内存的新位置,并在原位置留下一个转发指针,稍后用于更新应用程序的所有指针。我们将以这种方式移动对象的 GC 称为移动式 GC;Go 是一种非移动式 GC。

GC 周期

由于 Go GC 是标记-清除 GC,它大体上分为两个阶段:标记阶段和清除阶段。尽管这个说法可能看起来是同义反复,但它包含一个重要的见解:在所有内存都被追踪之前,不可能释放内存以供重新分配,因为可能仍然存在一个未扫描的指针使某个对象保持活跃。因此,清除操作必须完全与标记操作分开。此外,当没有 GC 相关工作可做时,GC 可能根本不活跃。GC 会不断地在清除、不活跃和标记这三个阶段中循环,这被称为GC 周期。为了本文档的目的,我们将 GC 周期视为从清除开始,然后是不活跃,再是标记。

接下来的几节将重点介绍如何建立对 GC 成本的直观理解,以帮助用户根据自己的需求调整 GC 参数。

理解成本

GC 本质上是一个复杂的软件,构建在更复杂的系统之上。在试图理解 GC 并调整其行为时,很容易陷入细节之中。本节旨在提供一个框架,用于推理 Go GC 的成本及其调优参数。

首先,考虑基于三个简单公理的 GC 成本模型。

  1. GC 只涉及两种资源:物理内存和 CPU 时间。

  2. GC 的内存成本包括活跃堆内存、标记阶段之前分配的新堆内存以及元数据空间,即使元数据空间与前述成本成比例,但相对较小。

    GC 周期 N 的内存成本 = 周期 N-1 的活跃堆 + 新堆

    活跃堆内存是上一个 GC 周期确定为活跃的内存,而新堆内存是当前周期分配的任何内存,这些内存在周期结束时可能活跃也可能不活跃。程序在任何给定时间点有多少活跃内存是程序的属性,而不是 GC 可以直接控制的。

  3. GC 的 CPU 成本被建模为每个周期的固定成本,以及与活跃堆大小成比例的边际成本。

    GC 周期 N 的 CPU 时间 = 每个周期的固定 CPU 时间成本 + 每字节的平均 CPU 时间成本 * 周期 N 中发现的活跃堆内存

    每个周期的固定 CPU 时间成本包括每个周期固定次数发生的事情,例如初始化下一个 GC 周期的内部结构。这个成本通常很小,仅为完整性而包含。

    GC 的大部分 CPU 成本是标记和扫描,这由边际成本体现。标记和扫描的平均成本取决于 GC 实现,但也取决于程序的行为。例如,更多指针意味着更多的 GC 工作,因为 GC 至少需要访问程序中的所有指针。链表和树等结构也更难让 GC 并行遍历,从而增加了每字节的平均成本。

    这个模型忽略了清除成本,清除成本与总堆内存成比例,包括已死亡的内存(必须使其可供分配)。对于 Go 当前的 GC 实现,清除比标记和扫描快得多,因此成本相比较而言可以忽略不计。

这个模型简单但有效:它准确地对 GC 的主要成本进行了分类。它还告诉我们,垃圾回收器的总 CPU 成本取决于给定时间范围内的 GC 周期总数。最后,这个模型中蕴含着 GC 基本的时间/空间权衡。

要了解原因,让我们探讨一个受限但有用的场景:稳态。从 GC 的角度来看,应用程序的稳态由以下属性定义:

让我们来看一个例子。假设某个应用程序分配速度为 10 MiB/s,GC 的扫描速度为 100 MiB/cpu-秒(这是虚构的),并且固定 GC 成本为零。稳态对活跃堆的大小没有假设,但为了简单起见,假设这个应用程序的活跃堆始终是 10 MiB。(注意:恒定的活跃堆并不意味着所有新分配的内存都是死亡的。这意味着,在 GC 运行后,旧堆和新堆内存的某种组合是活跃的。)如果每个 GC 周期恰好每 1 cpu-秒发生一次,那么我们的示例应用程序在稳态下,每个 GC 周期将有 20 MiB 的总堆大小。并且在每个 GC 周期中,GC 将需要 0.1 cpu-秒来完成其工作,导致 10% 的开销。

现在假设每个 GC 周期发生频率较低,每 2 cpu-秒一次。那么,我们的示例应用程序在稳态下,每个 GC 周期将有 30 MiB 的总堆大小。但每次 GC 周期,GC 仍然只需要 0.1 cpu-秒来完成其工作。所以这意味着我们的 GC 开销刚刚从 10% 下降到 5%,代价是内存使用量增加了 50%。

这种开销的变化就是前面提到的基本时间/空间权衡。而GC 频率是这种权衡的核心:如果我们更频繁地执行 GC,那么使用的内存就会更少,反之亦然。但是 GC 实际执行的频率是多久呢?在 Go 中,决定 GC 何时开始是用户可以控制的主要参数。

GOGC

从高层次来看,GOGC 决定了 GC CPU 和内存之间的权衡。

它的工作原理是确定每个 GC 周期后的目标堆大小,即下一个周期中总堆大小的目标值。GC 的目标是在总堆大小超过目标堆大小之前完成一次收集周期。总堆大小定义为前一个周期结束时的活跃堆大小,加上自前一个周期以来应用程序分配的任何新堆内存。同时,目标堆内存定义为:

目标堆内存 = 活跃堆 + (活跃堆 + GC 根) * GOGC / 100

举个例子,考虑一个 Go 程序,其活跃堆大小为 8 MiB,goroutine 栈占用 1 MiB,全局变量中的指针占用 1 MiB。那么,当 GOGC 值为 100 时,在下一次 GC 运行之前将分配的新内存量将是 10 MiB,或者说 10 MiB 工作量的 100%,总堆占用为 18 MiB。当 GOGC 值为 50 时,将分配 50%,即 5 MiB。当 GOGC 值为 200 时,将分配 200%,即 20 MiB。

注意:GOGC 从 Go 1.18 开始才将根集包含在内。在此之前,它只计算活跃堆。通常,goroutine 栈中的内存量很小,并且活跃堆大小主导所有其他 GC 工作来源,但在程序有数十万个 goroutine 的情况下,GC 的判断会变差。

堆目标控制着 GC 频率:目标越大,GC 可以等待更长时间才开始另一个标记阶段,反之亦然。虽然精确的公式对于进行估算很有用,但最好从其基本目的来考虑 GOGC:一个在 GC CPU 和内存权衡中选择一个点的参数。关键点是,GOGC 加倍会使堆内存开销加倍,并将 GC CPU 成本大致减半,反之亦然。(要了解完整解释,请参阅附录。)

注意:目标堆大小只是一个目标,GC 周期可能不会正好在该目标处结束,原因有很多。一方面,足够大的堆分配可以直接超过目标。然而,GC 实现中还存在其他超出本指南目前使用的GC 模型的原因。有关更多详细信息,请参阅延迟部分,完整细节可在附加资源中找到。

GOGC 可以通过 GOGC 环境变量(所有 Go 程序都识别)或通过 runtime/debug 包中的 SetGCPercent API 进行配置。

注意,还可以通过设置 GOGC=off 或调用 SetGCPercent(-1) 来完全关闭 GC(前提是内存限制不适用)。概念上,此设置等同于将 GOGC 设置为无穷大,因为在触发 GC 之前的新内存量是无限制的。

为了更好地理解我们到目前为止讨论的所有内容,请尝试下面的交互式可视化,它基于前面讨论的GC 成本模型构建。该可视化描绘了某个程序的执行过程,其非 GC 工作需要 10 秒的 CPU 时间来完成。在第一秒,它执行一些初始化步骤(增加其活跃堆),然后进入稳态。应用程序总共分配了 200 MiB,同时有 20 MiB 是活跃的。它假设唯一需要完成的相关 GC 工作来自活跃堆,并且(不切实际地)应用程序没有使用额外的内存。

使用滑块调整 GOGC 的值,以查看应用程序在总持续时间和 GC 开销方面的响应。每个 GC 周期结束时,新堆都会降至零。新堆降至零所需的时间是周期 N 的标记阶段和周期 N+1 的清除阶段的总时间。请注意,此可视化(以及本指南中的所有可视化)假设应用程序在 GC 执行时暂停,因此 GC CPU 成本完全由新堆内存降至零所需的时间表示。这只是为了简化可视化;相同的直观理解仍然适用。X 轴移动以始终显示程序的完整 CPU 时间持续时间。请注意,GC 使用的额外 CPU 时间会增加总持续时间。

GOGC

请注意,GC 总是会产生一定的 CPU 和峰值内存开销。随着 GOGC 增加,CPU 开销降低,但峰值内存与活跃堆大小成比例增加。随着 GOGC 降低,峰值内存需求减少,但会增加额外的 CPU 开销。

注意:图表中显示的是 CPU 时间,而不是完成程序的实际时钟时间。如果程序运行在 1 个 CPU 上并充分利用其资源,那么两者是等效的。真实的程序可能运行在多核系统上,并且不会始终 100% 利用 CPU。在这种情况下,GC 对实际时钟时间的影响会更小。

注意:Go GC 的最小总堆大小为 4 MiB,因此如果 GOGC 设置的目标低于此值,它会被向上取整。可视化反映了这一细节。

这是另一个更具动态性和真实性的示例。同样,应用程序在没有 GC 的情况下需要 10 个 CPU 秒来完成,但在中途,稳态分配速率显著增加,并且活跃堆大小在第一阶段略有变化。这个示例演示了当活跃堆大小实际变化时稳态可能是什么样子,以及更高的分配速率如何导致更频繁的 GC 周期。

GOGC

内存限制

在 Go 1.19 之前,GOGC 是唯一可以用来修改 GC 行为的参数。虽然它作为设置权衡的方法效果很好,但它没有考虑到可用内存是有限的。考虑当活跃堆大小出现瞬时峰值时会发生什么:由于 GC 将选择与该活跃堆大小成比例的总堆大小,因此必须针对峰值活跃堆大小配置 GOGC,即使在通常情况下更高的 GOGC 值可以提供更好的权衡。

下面的可视化演示了这种瞬时堆峰值情况。

GOGC

如果示例工作负载在一个可用内存略高于 60 MiB 的容器中运行,那么 GOGC 就不能增加到超过 100,即使在其他 GC 周期中,有可用的内存来利用额外的内存。此外,在某些应用程序中,这些瞬时峰值可能很少见且难以预测,从而导致偶尔、不可避免且可能代价高昂的内存不足情况。

这就是为什么在 1.19 版本中,Go 添加了对设置运行时内存限制的支持。内存限制可以通过所有 Go 程序都识别的 GOMEMLIMIT 环境变量或通过 runtime/debug 包中可用的 SetMemoryLimit 函数进行配置。

此内存限制设置了 Go 运行时可以使用的总内存量的上限。包含的特定内存集根据 runtime.MemStats 定义为表达式:

Sys - HeapReleased

或者等效地用 runtime/metrics 包的术语表示:

/memory/classes/total:bytes - /memory/classes/heap/released:bytes

由于 Go GC 对其使用的堆内存量有明确的控制权,因此它根据此内存限制以及 Go 运行时使用的其他内存量来设置总堆大小。

下面的可视化图表展示了与 GOGC 部分相同的单阶段稳态工作负载,但这次增加了来自 Go 运行时额外的 10 MiB 开销,并且具有可调节的内存限制。尝试同时调整 GOGC 和内存限制,看看会发生什么。

GOGC
内存限制

请注意,当内存限制降低到低于 GOGC 决定的峰值内存(GOGC 为 100 时为 42 MiB)时,GC 会更频繁地运行以将峰值内存保持在限制范围内。

回到我们之前瞬时堆峰值的例子,通过设置内存限制并提高 GOGC,我们可以获得两全其美的结果:不会突破内存限制,并且资源利用更经济。请尝试下面的交互式可视化。

GOGC
内存限制

请注意,对于某些 GOGC 和内存限制的值,峰值内存使用量会停留在内存限制设定的值,但程序其余部分的执行仍然遵守 GOGC 设置的总堆大小规则。

这一观察引出了另一个有趣的细节:即使将 GOGC 设置为关闭,内存限制仍然受到遵守!实际上,这种特定配置代表了资源利用的最大化,因为它设置了维持某个内存限制所需的最低 GC 频率。在这种情况下,程序执行的所有过程中,堆大小都会增加以达到内存限制。

现在,虽然内存限制显然是一个强大的工具,但使用内存限制并非没有代价,并且肯定不会使 GOGC 的用处失效。

考虑一下当活跃堆增长到足以使总内存使用量接近内存限制时会发生什么。在上面的稳态可视化中,尝试关闭 GOGC,然后慢慢地进一步降低内存限制,看看会发生什么。请注意,随着 GC 不断执行以维持一个不可能的内存限制,应用程序的总时间将开始无限增长。

这种由于持续的 GC 周期导致程序无法取得合理进展的情况称为颠簸。它尤其危险,因为它会有效地导致程序停滞。更糟糕的是,它可能恰好发生在我们试图用 GOGC 避免的相同情况下:足够大的瞬时堆峰值可能导致程序无限期停滞!在瞬时堆峰值可视化中尝试降低内存限制(大约 30 MiB 或更低),注意最坏的行为是如何从堆峰值开始的。

在许多情况下,无限期停滞比内存不足情况更糟糕,内存不足往往会导致更快的失败。

因此,内存限制被定义为软限制。Go 运行时不保证在所有情况下都能维持此内存限制;它只承诺付出一些合理的努力。放宽内存限制对于避免颠簸行为至关重要,因为它为 GC 提供了一种退出方式:允许内存使用量超过限制,以避免在 GC 中花费过多时间。

其内部工作原理是,GC 在某个时间窗口内设置了它可以使用的 CPU 时间上限(对于非常短暂的 CPU 使用瞬时峰值有一定的滞后)。此限制当前设置为大致 50%,时间窗口为 2 * GOMAXPROCS CPU 秒。限制 GC CPU 时间的后果是 GC 的工作被延迟,同时 Go 程序可能会继续分配新的堆内存,甚至超过内存限制。

50% GC CPU 限制背后的直觉是基于在可用内存充足的情况下对程序的最坏影响。如果内存限制配置错误,被错误地设置得太低,程序最多只会减慢 2 倍,因为 GC 不能占用超过 50% 的 CPU 时间。

注意:本页面上的可视化不模拟 GC CPU 限制。

建议使用场景

虽然内存限制是一个强大的工具,并且 Go 运行时采取措施减轻因误用而产生的最坏行为,但仍需审慎使用。以下是一些关于内存限制何时最有用、何时适用以及何时可能弊大于利的建议。

延迟

本文档中的可视化模拟了应用程序在 GC 执行期间暂停的情况。确实存在这种行为的 GC 实现,它们被称为“全暂停”(stop-the-world)GC。

然而,Go GC 并非完全全暂停(stop-the-world),它的大部分工作是与应用程序并发进行的。这主要是为了降低应用程序的延迟。具体来说,就是单个计算单元(例如网页请求)的端到端持续时间。到目前为止,本文档主要考虑了应用程序的吞吐量(例如每秒处理的网页请求)。请注意,GC 周期部分中的每个示例都侧重于程序执行的总 CPU 持续时间。然而,对于像 Web 服务这样的应用,这样的持续时间意义要小得多。虽然吞吐量对于 Web 服务仍然重要(即每秒查询次数),但通常每个单独请求的延迟更加重要。

在延迟方面,全暂停(stop-the-world)GC 可能需要相当长的时间来执行其标记和清扫阶段,在此期间,应用程序,在 Web 服务的上下文中,任何正在处理的请求都无法取得进一步进展。Go GC 则避免使任何全局应用程序暂停的时间长度与堆大小成比例,并且其核心追踪算法是在应用程序活跃执行时进行的。(算法上,暂停时间与 GOMAXPROCS 更强的比例关系,但最常见的是由停止正在运行的 goroutine 所需的时间决定。)并发回收并非没有成本:实践中,它通常会导致吞吐量低于等效的全暂停垃圾回收器。然而,重要的是要注意,较低的延迟并不固有地意味着较低的吞吐量,并且 Go 垃圾回收器的性能随着时间的推移在延迟和吞吐量方面都稳步提高。

Go 当前 GC 的并发特性并没有使本文档迄今为止讨论的任何内容失效:没有任何陈述依赖于这种设计选择。GC 频率仍然是 GC 在 CPU 时间和内存之间为吞吐量进行权衡的主要方式,实际上,它在延迟方面也扮演着这个角色。这是因为 GC 的大部分成本发生在标记阶段活跃时。

那么,关键的启示是,降低 GC 频率也可能带来延迟的改善。这不仅适用于通过修改调优参数(如增加 GOGC 和/或内存限制)来降低 GC 频率,也适用于优化指南中描述的优化。

然而,延迟通常比吞吐量更难理解,因为它是程序瞬时执行的产物,而不仅仅是成本的汇总。因此,延迟与 GC 频率之间的联系不那么直接。下面列出了对于那些倾向于深入研究的人来说,可能导致延迟的来源。

  1. GC 在标记和清扫阶段之间转换时短暂的全暂停(stop-the-world),
  2. 调度延迟,因为 GC 在标记阶段占用 25% 的 CPU 资源,
  3. 用户 goroutine 响应高分配率而协助 GC,
  4. GC 在标记阶段时,指针写入需要额外的工作,以及
  5. 正在运行的 goroutine 必须暂停以便扫描它们的根。

这些延迟来源在执行跟踪中可见,除了指针写入需要额外工作。

终结器(Finalizers)、清理器(Cleanups)和弱指针(Weak Pointers)

垃圾回收提供了一种只用有限内存实现无限内存的假象。内存被分配但从未显式释放,这使得与原始的手动内存管理相比,API 更简单,并发算法更容易。(一些具有手动管理内存的语言使用“智能指针”和编译时所有权跟踪等替代方法来确保对象被释放,但这些特性深入嵌入到这些语言的 API 设计约定中。)

只有存活的对象——那些可以从全局变量或某个 goroutine 中的计算访问到的对象——才能影响程序的行为。对象变得不可达(“死亡”)后的任何时间,它都可以被 GC 安全地回收。这允许各种 GC 设计,例如 Go 当前使用的追踪(tracing)设计。对象死亡在语言层面不是一个可观察到的事件。

然而,Go 的运行时库提供了三个打破这种假象的特性:清理器(cleanups)弱指针(weak pointers)终结器(finalizers)。这些特性都提供了某种方式来观察和响应对象死亡,对于终结器,甚至可以逆转它。这当然会使 Go 程序复杂化,并给 GC 实现增加额外的负担。尽管如此,这些特性之所以存在是因为它们在各种情况下都很有用,并且 Go 程序一直在使用它们并从中受益。

有关每个特性的详细信息,请参阅其包文档(runtime.AddCleanupweak.Pointerruntime.SetFinalizer)。下面是使用这些特性的一些一般性建议,每个特性可能遇到的常见问题概述,以及测试这些特性的使用方法的建议。

一般建议

常见的清理器问题

常见的弱指针问题

常见的终结器问题

测试对象死亡

使用这些特性时,为使用它们的代码编写测试有时会很棘手。以下是一些关于为使用这些特性的代码编写健壮测试的技巧。

其他资源

虽然上面提供的信息是准确的,但它缺乏足够的细节来完全理解 Go GC 设计中的成本和权衡。欲了解更多信息,请参阅以下其他资源。

关于虚拟内存的说明

本指南主要关注 GC 的物理内存使用,但一个经常出现的问题是这具体意味着什么,以及它与虚拟内存(通常在 top 等程序中显示为“VSS”)相比如何。

物理内存是大多数计算机中实际物理 RAM 芯片中的内存。虚拟内存是操作系统提供的物理内存抽象,用于隔离程序。程序保留不映射到任何物理地址的虚拟地址空间通常也是可以接受的。

由于虚拟内存只是操作系统维护的一种映射,因此进行不映射到物理内存的大型虚拟内存保留通常非常廉价。

Go 运行时通常在几个方面依赖于这种虚拟内存成本的观点

因此,像 top 中的“VSS”这样的虚拟内存指标通常在理解 Go 程序的内存占用方面作用不大。相反,应关注“RSS”及类似的测量指标,它们更直接地反映了物理内存使用情况。

优化指南

识别成本

在尝试优化 Go 应用程序与 GC 的交互方式之前,首先重要的是确定 GC 是否是主要的成本来源。

Go 生态系统提供了许多用于识别成本和优化 Go 应用程序的工具。要快速了解这些工具,请参阅诊断指南。在这里,我们将重点关注这些工具的一个子集以及应用它们的合理顺序,以便理解 GC 的影响和行为。

  1. CPU 性能分析(CPU profiles)

    一个好的起点是CPU 性能分析。CPU 性能分析提供了 CPU 时间花费在哪里的概述,但对于未经训练的人来说,可能很难识别 GC 在特定应用程序中扮演的角色有多大。幸运的是,了解 GC 的作用主要归结为知道 `runtime` 包中不同函数的意思。下面是用于解释 CPU 性能分析报告的一些有用函数。

    请注意,下面列出的函数不是叶子函数,因此它们可能不会出现在 pprof 工具使用 top 命令提供的默认视图中。相反,请使用 top -cum 命令或直接对这些函数使用 list 命令,并关注累积百分比列。

    • runtime.gcBgMarkWorker:后台标记工作 goroutine 的入口点。此处花费的时间与 GC 频率以及对象图的复杂性和大小成比例。它代表了应用程序在标记和扫描上花费的时间基线。

      请注意,在这些 goroutine 内部,你会看到对 runtime.gcDrainMarkWorkerDedicatedruntime.gcDrainMarkWorkerFractionalruntime.gcDrainMarkWorkerIdle 的调用,它们指示了工作 goroutine 的类型。在一个大部分时间处于空闲状态的 Go 应用程序中,Go GC 会利用额外的(空闲)CPU 资源来更快地完成其工作,这由 runtime.gcDrainMarkWorkerIdle 符号指示。因此,此处的时间可能占 CPU 采样的大部分,Go GC 认为这些 CPU 资源是空闲的。如果应用程序变得更活跃,空闲工作 goroutine 的 CPU 时间将下降。发生这种情况的一个常见原因是应用程序完全在一个 goroutine 中运行,但 GOMAXPROCS > 1。

    • runtime.mallocgc:堆内存分配器的入口点。此处花费大量累积时间(>15%)通常表示正在分配大量内存。

    • runtime.gcAssistAlloc:goroutine 进入此函数以让出部分时间来协助 GC 进行扫描和标记。此处花费大量累积时间(>5%)表明应用程序的分配速度可能超过了 GC 的处理速度。这表示 GC 的影响程度特别高,也代表了应用程序花费在标记和扫描上的时间。注意,这包含在 runtime.mallocgc 的调用树中,因此也会增加该函数的累积时间。

  2. 执行跟踪(Execution traces)

    虽然 CPU 性能分析非常适合识别总共花费的时间,但对于更微妙、罕见或与延迟相关的性能成本则不太有用。另一方面,执行跟踪提供了一个 Go 程序在短暂执行窗口内的丰富而深入的视图。它们包含与 Go GC 相关的各种事件,并且可以直接观察特定的执行路径,以及应用程序如何与 Go GC 交互。所有跟踪的 GC 事件在跟踪查看器中都方便地标记了出来。

    有关如何开始使用执行跟踪,请参阅runtime/trace 包的文档

  3. GC 跟踪(GC traces)

    当其他方法都无效时,Go GC 提供了一些不同的特定跟踪,它们提供了对 GC 行为更深入的洞察。这些跟踪总是直接打印到 STDERR,每个 GC 周期一行,并且通过所有 Go 程序都能识别的 GODEBUG 环境变量进行配置。由于它们需要对 GC 实现的细节有一定的熟悉,因此主要用于调试 Go GC 本身,但偶尔也可能有助于更好地理解 GC 行为。

    通过设置 GODEBUG=gctrace=1 可以启用核心 GC 跟踪。该跟踪产生的输出在runtime 包文档中的环境变量部分有说明。

    一个补充的 GC 跟踪称为“步调跟踪”(pacer trace),它提供了更深入的洞察,通过设置 GODEBUG=gcpacertrace=1 启用。解释此输出需要理解 GC 的“步调控制器”(pacer)(参阅其他资源),这超出了本指南的范围。

消除堆分配

减少 GC 成本的一种方法是让 GC 从一开始就管理更少的值。下面描述的技术可以带来一些最大的性能改进,因为正如GOGC 部分所演示的那样,Go 程序的分配率是 GC 频率的主要因素,而 GC 频率是本指南使用的关键成本指标。

堆性能分析(Heap profiling)

确定 GC 是重要的成本来源之后,消除堆分配的下一步是找出它们主要来自哪里。为此,内存性能分析(实际上是堆内存性能分析)非常有用。有关如何开始使用它们,请查阅文档

内存性能分析报告描述了堆分配来自程序中的哪些位置,通过分配时的堆栈跟踪来识别它们。每个内存性能分析报告可以从四个方面细分内存。

可以使用 pprof 工具的 -sample_index 标志或在交互式使用工具时通过 sample_index 选项在这些不同的堆内存视图之间切换。

注意:内存性能分析报告默认只采样堆对象的一个子集,因此它们不包含关于每一个堆分配的信息。然而,这足以找到热点。要更改采样率,请参阅runtime.MemProfileRate

为了降低 GC 成本,alloc_space 通常是最有用的视图,因为它直接对应于分配率。此视图将指出能够带来最大收益的分配热点。

逃逸分析(Escape analysis)

一旦借助堆性能分析确定了候选的堆分配位置,如何才能消除它们呢?关键是利用 Go 编译器的逃逸分析,让 Go 编译器为这部分内存找到替代的、更高效的存储位置,例如在 goroutine 栈中。幸运的是,Go 编译器能够描述为什么它决定将 Go 值逃逸到堆上。有了这些知识,就变成了重组你的源代码来改变分析结果的问题(这通常是最难的部分,但超出了本指南的范围)。

至于如何访问 Go 编译器的逃逸分析信息,最简单的方法是通过 Go 编译器支持的一个调试标志,该标志以文本格式描述了它对某个包应用或未应用的所有优化。这包括值是否逃逸。请尝试以下命令,其中 [package] 是某个 Go 包路径。

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

此信息也可以在支持 LSP 的编辑器中以叠加层(overlay)的形式可视化;它作为代码操作暴露出来。例如,在 VS Code 中,调用“Source Action... > Show compiler optimization details”命令以启用当前包的诊断信息。(你也可以运行“Go: Toggle compiler optimization details”命令。)使用此配置设置来控制显示哪些注释

  1. 通过ui.diagnostic.annotations 设置为包含 escape 来启用逃逸分析的叠加层。

最后,Go 编译器以机器可读的 (JSON) 格式提供此信息,可用于构建额外的自定义工具。有关更多信息,请参阅Go 源代码中的文档

实现特定的优化

Go GC 对存活内存的构成很敏感,因为复杂的对象和指针图既限制了并行性,又为 GC 带来了更多工作。因此,GC 针对特定的常见结构包含了一些优化。对性能优化最直接有用的优化如下所示。

注意:应用下面的优化可能会通过模糊意图来降低代码的可读性,并且可能无法在不同的 Go 版本中持续有效。请优先仅在最重要的地方应用这些优化。可以使用识别成本部分中列出的工具来识别这些位置。

此外,GC 必须与它看到的几乎每个指针交互,因此使用 slice 的索引(例如)而不是指针,有助于降低 GC 成本。

Linux 透明大页(THP)

当程序访问内存时,CPU 需要将其使用的虚拟内存地址转换为引用它试图访问的数据的物理内存地址。为此,CPU 会查询“页表”,这是一个由操作系统管理的、表示虚拟内存到物理内存映射的数据结构。页表中的每个条目代表一个不可分割的物理内存块,称为页面,因此得名。

透明大页(THP)是 Linux 的一个特性,它透明地将支持连续虚拟内存区域的物理内存页面替换为更大的内存块,称为大页(huge pages)。通过使用更大的块,表示相同内存区域所需的页表条目更少,从而提高了页表查找时间。然而,如果系统只使用了大页的一小部分,更大的块意味着更多的浪费。

在生产环境中运行 Go 程序时,在 Linux 上启用透明大页(THP)可以提高吞吐量和延迟,代价是增加内存使用。堆较小的应用程序往往不会从 THP 中受益,并可能最终使用大量额外内存(高达 50%)。然而,堆较大的应用程序(1 GiB 或更大)倾向于获得相当大的收益(高达 10% 的吞吐量),而额外内存开销不大(1-2% 或更少)。无论哪种情况,了解你的 THP 设置都有帮助,并且始终建议进行实验。

可以通过修改 /sys/kernel/mm/transparent_hugepage/enabled 在 Linux 环境中启用或禁用透明大页。有关更多详细信息,请参阅官方 Linux 管理员指南。如果选择在 Linux 生产环境中启用透明大页,我们建议为 Go 程序设置以下额外选项。

附录

关于 GOGC 的补充说明

GOGC 部分声称,将 GOGC 加倍会使堆内存开销加倍,并将 GC CPU 成本减半。为了理解原因,我们用数学方法来分解一下。

首先,堆目标(heap target)设定了总堆大小的目标。然而,这个目标主要影响新的堆内存,因为存活堆(live heap)是应用程序的基础。

目标堆内存 = 活跃堆 + (活跃堆 + GC 根) * GOGC / 100

总堆内存 = 存活堆 + 新堆内存

新堆内存 = (存活堆 + GC 根) * GOGC / 100

由此我们可以看到,将 GOGC 加倍也会使应用程序在每个周期分配的新堆内存量加倍,这反映了堆内存开销。请注意,存活堆 + GC 根 是 GC 需要扫描的内存量的近似值。

接下来,我们来看看 GC CPU 成本。总成本可以分解为每个周期的成本乘以在某个时间段 T 内的 GC 频率。

总 GC CPU 成本 = (每个周期的 GC CPU 成本) * (GC 频率) * T

每个周期的 GC CPU 成本可以从GC 模型推导出来

每个周期的 GC CPU 成本 = (存活堆 + GC 根) * (每字节成本) + 固定成本

请注意,此处忽略了清扫阶段的成本,因为标记和扫描的成本占主导地位。

稳态由恒定的分配速率和恒定的每字节成本定义,因此在稳态下,我们可以从这个新的堆内存推导出 GC 频率

GC 频率 = (分配速率) / (新堆内存) = (分配速率) / ((存活堆 + GC 根) * GOGC / 100)

将这些组合起来,我们得到总成本的完整方程

总 GC CPU 成本 = (分配速率) / ((存活堆 + GC 根) * GOGC / 100) * ((存活堆 + GC 根) * (每字节成本) + 固定成本) * T

对于足够大的堆(这代表了大多数情况),GC 周期的边际成本占主导地位,高于固定成本。这使得总 GC CPU 成本公式可以得到显著简化。

总 GC CPU 成本 = (分配速率) / (GOGC / 100) * (每字节成本) * T

从这个简化公式可以看出,如果我们将 GOGC 加倍,总 GC CPU 成本将减半。(请注意,本指南中的可视化模拟了固定成本,因此它们报告的 GC CPU 开销在 GOGC 加倍时不会正好减半。)此外,GC CPU 成本主要由分配速率和扫描内存的每字节成本决定。有关如何具体降低这些成本的更多信息,请参阅优化指南

注意:存活堆的大小与 GC 实际需要扫描的内存量之间存在差异:相同大小但结构不同的存活堆将导致不同的 CPU 成本,但内存成本相同,从而产生不同的权衡。这就是为什么堆的结构是稳态定义的一部分。可以说,堆目标应该只包含可扫描的存活堆,作为 GC 需要扫描内存的更接近的近似值,但这会在可扫描存活堆非常少但总存活堆很大的情况下导致退化行为。