```markdown

简介

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

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

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

请注意,本指南所描述的垃圾回收器的存在并非由 Go 语言规范所保证,规范只保证 Go 值的底层存储由语言本身管理。 这种省略是有意为之的,它允许使用截然不同的内存管理技术。

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

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

Go 值的存放位置

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

例如,存储在局部变量中的非指针 Go 值很可能根本不会被 Go GC 管理,Go 会转而安排分配一块内存,该内存与创建它的词法作用域绑定。 通常,这比依赖 GC 更高效,因为 Go 编译器能够预先确定该内存何时可以被释放,并生成执行清理工作的机器指令。 通常,我们把以这种方式为 Go 值分配内存称为“栈分配”,因为这些空间存储在协程的栈上。

其内存无法以这种方式分配的 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的内存成本包括活跃堆内存、标记阶段之前分配的新堆内存,以及元数据的空间(即使与前述成本成比例,也相对较小)。

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

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

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

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

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

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

    该模型忽略了清扫成本,该成本与总堆内存(包括已失效的内存,它必须被置为可分配状态)成比例。对于Go当前的GC实现,清扫比标记和扫描快得多,因此相比之下可以忽略不计。

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

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

让我们通过一个例子来说明。 假设某个应用程序处于稳态,分配速率为10 MiB/秒,而GC能够以每CPU秒100 MiB的速度扫描内存(这是假设的)。 稳态不对存活堆的大小做任何假设,但为简单起见,假设此应用程序的存活堆始终为10 MiB。 同样,为简单起见,我们假设GC的固定成本为零。 让我们调整一下GC周期的间隔。

假设每个GC周期恰好在1个CPU秒后发生。 那么,到每个GC周期结束时,我们的示例应用程序将已分配10 MiB的额外内存,导致总堆大小为20 MiB。 并且在每个GC周期中,GC将花费0.1 CPU秒来扫描10 MiB的存活堆,从而产生10%的CPU开销。 请记住,GC只需要遍历存活堆,而不是整个堆。 (注意:存活堆恒定并不意味着所有新分配的内存都是死亡的。 这意味着,GC运行后,新旧堆内存的某种组合会死亡,并且每个周期结束时只有10 MiB被发现存活。)

现在假设每个GC周期发生频率降低,每2个CPU秒一次。 那么,我们的示例应用程序在稳态下,每个GC周期时的总堆大小将为30 MiB,因为它在那段时间内将分配20 MiB。 但GC在每个周期仍然只需要0.1 CPU秒来扫描10 MiB的存活内存。 再次强调,我们假设存活堆大小保持不变,无论分配了多少内存。 因此,这意味着我们的GC开销降低了,从10%降至5%,代价是内存使用量增加了50%。

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

GOGC

在较高层面上,GOGC决定了GC CPU使用率和内存使用之间的权衡。

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

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

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

注意:直到Go 1.18,GOGC才包含根集合。在此之前,它只计算存活堆。 通常,goroutine栈中的内存量相当小,存活堆大小主导了所有其他GC工作来源,但在程序拥有数十万个goroutine的情况下,GC会做出较差的判断。

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

注意:目标堆大小只是一个目标,有几个原因可能导致GC周期没有恰好在该目标处完成。 例如,一次足够大的堆分配可能直接超过目标。 然而,其他原因出现在超越本指南迄今使用的GC模型的GC实现中。 更多细节请参见延迟部分,但完整细节可在附加资源中找到。

GOGC 可通过以下任一方式配置:所有 Go 程序均识别的 GOGC 环境变量,或通过 runtime/debug 包中的 SetGCPercent API。

请注意,若未应用内存限制,GOGC 也可用于完全关闭垃圾回收器(GC),方法是设置 GOGC=off 或调用 SetGCPercent(-1)。 从概念上讲,此设置等同于将 GOGC 设为无穷大,因为触发 GC 前新内存量不受限制。

为更好地理解我们此前讨论的所有内容,请尝试下方基于GC 开销模型构建的交互式可视化图表。该图表描述了某程序的执行情况,其非 GC 工作耗时 10 CPU 秒完成。在最初 1 秒内,程序执行初始化步骤(增长其存活堆)后进入稳态。 该应用总共分配 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 时间,而非程序完成所需的挂钟时间。若程序在单 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的容器中,那么即使在其他GC周期内有可用内存可以利用,GOGC也无法设置为超过100的值。此外,在某些应用中,这些瞬时峰值可能很少发生且难以预测,导致偶尔出现不可避免且代价高昂的内存不足状况。

正因如此,在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设置为off,内存限制仍然会被遵守! 实际上,这种特定配置代表了资源经济性的最大化,因为它设置了维持某个内存限制所需的最小GC频率。 在这种情况下,程序所有执行期间的堆大小都会上升到与内存限制相匹配。

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

当活跃的堆增长到足够大,使得总内存使用接近内存限制时,会发生什么情况呢?在上面的稳态可视化中,尝试关闭GOGC,然后逐步降低内存限制,观察会发生什么。请注意,随着GC持续执行以维持一个无法达到的内存限制,应用程序所花费的总时间将开始无限制地增长。

这种由于GC循环不断进行而导致程序无法取得合理进展的情况,被称为内存抖动。 它尤其危险,因为它实际上会使程序停滞不前。 更糟糕的是,这恰恰可能发生在我们试图通过GOGC避免的相同场景下:一个足够大的瞬时堆峰值可能会导致程序无限期地停滞!尝试降低内存限制(约30 MiB或更低),观察在瞬时堆峰值可视化中,最糟糕的行为是如何专门由堆峰值引发的。

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

因此,内存限制被定义为软限制。 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实现,它们被称为“全局暂停”GC。

然而,Go的GC并非完全全局暂停,其大部分工作与应用程序并发执行。 这主要是为了减少应用程序的延迟。 具体来说,是单个计算单元(例如一个Web请求)的端到端持续时间。 迄今为止,本文档主要考虑了应用程序的吞吐量(例如每秒处理的Web请求数)。 请注意,GC周期部分中的每个示例都关注了执行程序的总CPU持续时间。 然而,对于例如Web服务来说,这样的持续时间意义要小得多。 虽然吞吐量对Web服务仍然很重要(即每秒查询数),但通常每个单独请求的延迟更为重要。

就延迟而言,全局暂停的GC可能需要相当长的时间来执行其标记和清除阶段,在此期间,应用程序(在Web服务的上下文中,即任何正在处理的请求)无法继续前进。 相反,Go的GC避免使任何全局应用程序暂停的长度与堆的大小成比例,并且核心追踪算法是在应用程序活跃执行时完成的。 (从算法上讲,暂停时间与GOMAXPROCS有更强的相关性,但最常见的是受停止运行goroutine所需时间的主导。) 并发执行并非没有代价:在实践中,它通常导致比等效的全局暂停垃圾回收器更低吞吐量的设计。 然而,重要的是要注意,较低的延迟并不固有地意味着较低的吞吐量,并且Go垃圾回收器的性能随着时间的推移在延迟和吞吐量两方面都得到了稳步改善。

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

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

然而,延迟通常比吞吐量更难理解,因为它是程序瞬时执行的产物,而不仅仅是成本的聚合。 因此,延迟和GC频率之间的联系不那么直接。 下面列出了可能的延迟来源,供那些希望深入探究的人参考。

  1. 当GC在标记和清除阶段之间转换时,会经历短暂的全局暂停,
  2. 由于GC在标记阶段占用25%的CPU资源而导致的调度延迟,
  3. 用户goroutine为响应高分配速率而辅助GC,
  4. 当GC处于标记阶段时,指针写入需要额外的工作,以及
  5. 正在运行的goroutine必须被暂停以便扫描其根。

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

终结器、清理函数和弱指针

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

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

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

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

通用建议

常见清理函数问题

常见的弱指针问题

常见的终结器问题

测试对象回收

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

额外资源

虽然上述信息是准确的,但缺乏足够细节来全面理解Go垃圾回收器设计中的成本与权衡。 如需更多信息,请参阅以下额外资源。

关于虚拟内存的说明

本指南主要关注垃圾回收器的物理内存使用,但一个经常被问及的问题是:这具体意味着什么?以及它与虚拟内存(通常在类似 top 的程序中显示为“VSS”)相比如何?

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

由于虚拟内存只是由操作系统维护的一种映射,因此预留大量不映射到物理内存的虚拟内存通常成本极低。

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

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

优化指南

识别成本

在尝试优化你的Go应用程序与垃圾回收器的交互方式之前,首先确认垃圾回收器是否确实是主要成本来源,这一点很重要。

Go生态系统提供了多种工具来识别成本和优化Go应用程序。 关于这些工具的简要概述,请参阅诊断指南。 在此,我们将重点关注这些工具的一个子集,以及为理解垃圾回收影响和行为而应用它们的合理顺序。

  1. CPU 分析

    一个不错的起点是进行 CPU 分析。 CPU分析提供了CPU时间花费在哪里的概览,尽管对于未经训练的人来说,可能难以识别垃圾回收在特定应用中扮演角色的比重。 幸运的是,理解垃圾回收如何融入其中,主要归结为理解 `runtime` 包中不同函数的含义。 以下是解读CPU分析时有用的一些函数子集。

    请注意,下面列出的函数并非叶子函数,因此它们可能不会显示在 pprof 工具通过 top 命令提供的默认视图中。 请改用 top -cum 命令,或直接在这些函数上使用 list 命令,并重点关注累计百分比列。

  • 执行追踪

    虽然CPU分析文件非常适合识别总体时间消耗,但对于更微妙、罕见或与延迟直接相关的性能成本,它们的作用有限。而执行追踪则提供了对Go程序执行的一个短暂窗口内丰富而深入的观察。它们包含了与Go GC相关的各种事件,可以观察到具体的执行路径,以及应用程序可能如何与Go GC交互。所有跟踪的GC事件在追踪查看器中都被方便地标注出来。

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

  • GC追踪

    当其他方法都失败时,Go的垃圾回收器提供了一些特定的追踪,能够更深入地洞察GC行为。这些追踪始终直接打印到标准错误输出(STDERR),每个GC周期一行,并通过所有Go程序都识别的 GODEBUG 环境变量进行配置。它们主要用于调试Go GC本身,因为需要熟悉GC实现的具体细节,但偶尔也能用于更好地理解GC行为。

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

    一个名为“pacer追踪”(节奏控制器追踪)的补充GC追踪能提供更深入的见解,通过设置 GODEBUG=gcpacertrace=1 来启用。理解其输出需要了解GC的“节奏控制器”(参见附加资源),这超出了本指南的范围。

  • 消除堆内存分配

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

    堆内存分析

    确定GC是显著成本来源之后,消除堆内存分配的下一步是找出大部分分配来自何处。为此,内存分析文件(实际上是堆内存分析文件)非常有用。请查阅文档以了解如何开始使用它们。

    内存分析文件描述了程序中堆内存分配的来源,通过分配时的堆栈跟踪来识别它们。每个内存分析文件可以从四个方面分解内存使用情况。

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

    注意:内存分析默认只采样部分堆对象,因此不会包含关于每一次堆分配的信息。但这足以发现热点问题。要更改采样率,请参考 runtime.MemProfileRate

    就降低垃圾回收成本而言,alloc_space 通常是最有用的视图,因为它直接对应分配速率。这个视图会指出能带来最大收益的分配热点。

    逃逸分析

    在借助 堆分析 识别出潜在的堆分配位置后,如何消除这些分配呢?关键在于利用 Go 编译器的逃逸分析,让编译器为这些内存寻找替代且更高效的存储方式,例如存储在 goroutine(协程)的栈上。幸运的是,Go 编译器能够描述它为何决定将某个 Go 值逃逸到堆上。掌握了这一信息,剩下的就是重组你的源代码,以改变分析的结果(这通常是最困难的部分,但超出了本指南的范围)。

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

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

    这些信息也可以在支持 LSP 的编辑器中作为叠加层进行可视化;它通过代码操作暴露出来。 例如,在 VS Code 中,调用 “Source Action... > Show compiler optimization details”(源操作... > 显示编译器优化详情)命令,即可为当前包启用诊断信息。(你也可以运行 "Go: Toggle compiler optimization details"(Go:切换编译器优化详情)命令。) 使用以下配置设置来控制显示哪些注解:

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

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

    特定实现的优化

    Go 的垃圾回收器对存活内存的组成非常敏感,因为复杂的对象和指针图会限制并行性,并为垃圾回收器产生更多的工作。因此,垃圾回收器针对一些常见的特定结构包含了几项优化。以下是与性能优化最直接相关的几项。

    注意:应用以下优化可能会因掩盖意图而降低代码的可读性,并且可能无法在后续的 Go 版本中持续有效。建议仅在最重要的场景下应用这些优化。这些场景可通过使用 识别成本 一节中列出的工具来识别。

    此外,垃圾回收器必须与其看到的几乎每一个指针进行交互,因此,例如使用指向 slice(切片)的索引而非指针,有助于降低垃圾回收成本。

    Linux 透明大页(THP)

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

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

    在生产环境中运行 Go 程序时,在 Linux 上启用透明大页可以通过增加内存使用来提升吞吐量并降低延迟。堆内存较小的应用往往无法从 THP 中获益,反而可能消耗大量额外内存(高达 50%)。然而,堆内存较大的应用(1 GiB 或以上)通常能获得显著收益(吞吐量提升高达 10%),且额外内存开销很小(1-2% 或更低)。了解 THP 的配置情况对两种场景都很有帮助,始终建议进行实际测试验证。

    可以通过修改 /sys/kernel/mm/transparent_hugepage/enabled 来在 Linux 环境中启用或禁用透明大页。详情请参阅 官方 Linux 管理指南。如果你选择在 Linux 生产环境中启用透明大页,我们建议为 Go 程序配置以下附加设置。

    附录

    关于 GOGC 的补充说明

    GOGC 章节 声称将 GOGC 翻倍会使堆内存开销翻倍,同时将 GC 的 CPU 成本减半。为理解其原因,我们来进行数学推导。

    首先,堆目标为总堆大小设定了一个目标。然而,这个目标主要影响新分配的堆内存,因为存活堆是应用程序固有的。

    目标堆内存 = 存活堆 + (存活堆 + 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 成本将减半。(请注意,本指南中的可视化工具确实模拟了固定成本,因此当 GOGC 加倍时,它们报告的 GC CPU 开销不会精确地减半。) 此外,GC CPU 成本主要由分配速率和扫描内存的每字节成本决定。 有关如何具体降低这些成本的更多信息,请参阅优化指南

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