Go 垃圾收集器指南
引言
本指南面向有经验的 Go 用户,通过介绍 Go 垃圾收集器,帮助你更好地理解应用的成本,并说明如何运用这些认识改善应用的资源利用率。阅读本指南不需要垃圾收集方面的知识,但需要熟悉 Go 编程语言。
Go 语言负责安排 Go 值的存储;大多数情况下,Go 开发者无需关心这些值存放在哪里,甚至无需关心为什么要存放。不过在实践中,这些值往往需要存储在计算机的物理内存中,而物理内存是有限资源。正因如此,必须谨慎管理并回收内存,才能避免 Go 程序执行时耗尽内存。
Go 实现的职责,就是按需分配和回收内存。
自动回收内存也称为垃圾收集。概括来说,垃圾收集器(简称 GC)会识别哪些内存已经不再需要,并代表应用回收它们。Go 标准工具链会为每个应用提供运行时库,其中就包含垃圾收集器。
需要注意,Go 语言规范并不保证存在本指南描述的垃圾收集器;它只保证 Go 值的底层存储由语言自身管理。这种省略是有意为之,以便采用完全不同的内存管理技术。
因此,本指南讨论的是 Go 编程语言的一种特定实现,不一定适用于其他实现。具体而言,下文适用于标准工具链,即 gc Go 编译器及其工具。Gccgo 和 Gollvm 的 GC 实现十分相似,所以许多概念同样适用,但细节可能不同。
此外,这是一份持续更新的文档,会随时间变化,以尽可能准确地反映最新 Go 版本。本文目前描述的是 Go 1.19 时的垃圾收集器。
译注:上一句保留原文的版本声明;当前官方页面后续也包含 Go 1.24 及以后的清理函数建议等更新,不能把整篇文档理解为固定于 Go 1.19。
Go 值存放在哪里
在深入 GC 之前,先讨论无需 GC 管理的内存。
例如,存储在局部变量中的非指针 Go 值,很可能根本不由 Go GC 管理;Go 会安排分配与创建它的词法作用域关联的内存。一般而言,这比依赖 GC 更高效,因为 Go 编译器能够预先确定何时可以释放内存,并生成负责清理的机器指令。
通常,我们把这种为 Go 值分配内存的方式称为“栈分配”,因为空间存放在 goroutine 的栈上。
如果 Go 编译器无法确定某个 Go 值的生命周期,从而不能以这种方式分配内存,就称它“逃逸到堆”。可以把“堆”看作 Go 值需要找地方存放时,容纳各种内存分配的通用区域。在堆上分配内存通常称为“动态内存分配”,因为编译器和运行时几乎无法假定这块内存将如何使用、何时可以清理。
这就是 GC 发挥作用的地方:它专门识别并清理动态分配的内存。
Go 值可能因为许多原因需要逃逸到堆。例如,它的大小由动态条件决定:考虑一个切片,其底层数组的初始大小取决于变量,而不是常量。还要注意,逃逸具有传递性:如果把一个 Go 值的引用写入另一个已确定会逃逸的 Go 值,那么被引用的值也必须逃逸。
Go 值是否逃逸,取决于使用它的上下文以及 Go 编译器的逃逸分析算法。试图精确列举所有逃逸情形既脆弱又困难:算法本身相当复杂,也会随 Go 版本变化。关于如何识别哪些值会逃逸、哪些不会,请参阅“消除堆分配”一节。
追踪式垃圾收集
垃圾收集可以指许多自动回收内存的方法,例如引用计数。在本文中,垃圾收集指追踪式垃圾收集:它沿着指针传递地追踪,识别仍在使用、也就是所谓“存活”的对象。
下面更严格地定义这些术语。
- 对象:动态分配的一块内存,包含一个或多个 Go 值。
- 指针:引用对象内部任意值的内存地址。它自然包括
*T形式的 Go 值,也包括某些内置 Go 值的组成部分。字符串、切片、通道、映射和接口值都包含 GC 必须追踪的内存地址。
对象与指向其他对象的指针共同构成对象图。为了识别存活内存,GC 从程序的根开始遍历对象图;根是指向程序确定仍在使用的对象的指针。局部变量和全局变量就是两类根。遍历对象图的过程称为扫描。
Go 文档还可能说某个对象“可达”,这只是表示扫描过程能够找到该对象。另请注意,有一种例外,除此之外,内存一旦不可达,就会一直不可达。
所有追踪式 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 成本模型。
- GC 只涉及两种资源:物理内存和 CPU 时间。
- GC 的内存成本包括存活堆内存、标记阶段开始前新分配的堆内存,以及元数据占用的空间。即使元数据空间与前述成本成正比,相比之下也很小。
第 N 个周期的 GC 内存成本 = 第 N−1 个周期的存活堆 + 新堆内存
存活堆内存是上一个 GC 周期判定为存活的内存;新堆内存则是当前周期分配的全部内存,在周期结束时可能存活,也可能不存活。任意时刻有多少内存存活,是程序本身的属性,GC 无法直接控制。
- GC 的 CPU 成本建模为每个周期的固定成本,加上与存活堆大小成正比的边际成本。
第 N 个周期的 GC CPU 时间 = 每周期固定 CPU 时间成本 + 每字节平均 CPU 时间成本 × 第 N 个周期发现的存活堆内存
每周期固定 CPU 时间成本包括每个周期发生固定次数的事情,例如为下一个 GC 周期初始化数据结构。这一成本通常很小,列在这里是为了完整性。
GC 的主要 CPU 成本来自标记和扫描,也就是模型中的边际成本。标记与扫描的平均成本不仅取决于 GC 实现,还取决于程序行为。例如,指针越多,GC 的工作越多,因为它至少需要访问程序中的所有指针。链表、树等结构也更难供 GC 并行遍历,因此会提高每字节平均成本。
这个模型忽略清扫成本。清扫成本与总堆内存成正比,包括已经死亡的内存,因为必须让它重新可供分配。对于 Go 当前的 GC 实现,清扫比标记和扫描快得多,因此相较之下成本可以忽略。
这个模型简单而有效,准确划分了 GC 的主要成本。它也告诉我们,垃圾收集器的总 CPU 成本取决于给定时间段内 GC 周期的总次数。最后,这个模型还蕴含了 GC 的一个基本时间与空间权衡。
为了理解其中的原因,考察一种受限但很有用的情形:稳态。从 GC 的角度,应用的稳态具有以下属性。
- 应用分配新内存的速率(每秒字节数)保持不变。
这意味着,从 GC 看来,应用负载随时间大致不变。例如,对于 Web 服务,请求速率恒定、平均而言请求类型相同、每个请求的平均生命周期也大致恒定。
- GC 的边际成本保持不变。
这意味着,对象大小分布、指针数量、数据结构平均深度等对象图统计特征,在各周期之间保持不变。
来看一个例子。假设某个应用处于稳态,以 10 MiB/s 的速度分配内存,而 GC 的扫描速度是每 CPU 秒 100 MiB(这是虚构数值)。稳态并不假定存活堆的大小,但为简单起见,假设该应用的存活堆始终为 10 MiB。同样为简单起见,再假设固定 GC 成本为零。现在改变 GC 周期的间隔。
假设每个 GC 周期恰好相隔 1 CPU 秒。那么,到每个周期结束时,示例应用又分配了 10 MiB 内存,总堆大小达到 20 MiB。每个周期,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
例如,某个 Go 程序有 8 MiB 存活堆、1 MiB goroutine 栈,以及全局变量中的 1 MiB 指针。如果 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 模型的实现细节。“延迟”一节提供进一步说明;完整细节见“其他资源”。
可以通过所有 Go 程序都识别的 GOGC 环境变量,或者 runtime/debug 包中的 SetGCPercent API 配置 GOGC。
还可以设置 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 时间,会增加总时长。
交互图 1:GOGC 与稳态负载。 原图控制项:GOGC。此静态译本保留原图的假设、读图方法和下述关系说明;没有嵌入可拖动控件,也没有复刻曲线或将其作为实测图。可在官方原文操作原图。
可以看到,GC 总会引入一定的 CPU 和峰值内存开销。GOGC 增大时,CPU 开销降低,而峰值内存会按存活堆大小成比例增加;GOGC 减小时,峰值内存需求降低,但额外 CPU 开销增加。
注意:图中显示的是 CPU 时间,而非程序完成所需的墙钟时间。如果程序运行在一个 CPU 上并充分利用其资源,两者相同。现实程序很可能运行在多核系统上,而且并不始终 100% 利用 CPU。此时,GC 对墙钟时间的影响会更小。
注意:Go GC 的最小总堆大小为 4 MiB,因此 GOGC 设置的目标若低于此值,会向上取整。可视化也体现了这一细节。
再看一个更动态、更现实的例子。应用在没有 GC 时依然需要 10 CPU 秒完成,但到一半时,稳态分配速率大幅增加;第一阶段的存活堆大小也有所变化。这个例子展示了存活堆大小实际变化时稳态可能呈现的样子,也展示了更高分配速率如何使 GC 周期更加频繁。
交互图 2:分配速率变化。 原图控制项:GOGC。静态保留的关系是:中途分配速率升高会使 GC 更频繁,存活堆在第一阶段也会变化。动态曲线和滑块未嵌入;操作见官方原文。
内存限制
在 Go 1.19 之前,GOGC 是唯一可以改变 GC 行为的参数。它很适合设定权衡,却没有考虑可用内存是有限的。如果存活堆短暂出现峰值,由于 GC 会选择与存活堆成比例的总堆大小,GOGC 就必须按存活堆峰值配置,即便通常更大的 GOGC 能取得更好的权衡。
原文下图展示这种短暂堆峰值的情况。
交互图 3:短暂堆峰值。 原图控制项:GOGC。静态关系说明:存活堆峰值会把按 GOGC 计算的总堆目标一并推高。动态曲线和滑块未嵌入;操作见官方原文。
如果示例负载运行在可用内存略高于 60 MiB 的容器中,GOGC 就不能超过 100,尽管其余 GC 周期都还有余量,可以利用更多内存。并且,某些应用的短暂峰值可能很少出现、难以预测,从而造成偶发、不可避免且可能代价高昂的内存耗尽。
因此,Go 在 1.19 版本加入了运行时内存限制。可以通过所有 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 和内存限制,观察结果。
交互图 4:稳态负载加内存限制。 原图控制项:GOGC、Memory Limit(内存限制)。静态译本未嵌入交互曲线;下段保留关键观察,操作见官方原文。
可以看到,内存限制降到 GOGC 所决定的峰值内存以下时(GOGC 为 100 时为 42 MiB),GC 会更频繁运行,使峰值内存保持在限制以内。
回到之前的短暂堆峰值示例,设置内存限制并提高 GOGC,可以兼得两者优点:既不突破内存限制,又能提高资源利用的经济性。可以尝试原文中的下一幅交互可视化。
交互图 5:短暂堆峰值加内存限制。 原图控制项:GOGC、Memory Limit(内存限制)。静态译本保留下面关于峰值、GOGC 关闭与抖动的全部说明;动态曲线和控件未嵌入,操作见官方原文。
可以看到,在某些 GOGC 与内存限制组合下,峰值内存会停在内存限制处,但程序其余执行过程仍遵守 GOGC 设定的总堆大小规则。
由此还能发现一个有趣细节:即使把 GOGC 设为 off,内存限制仍会受到遵守!实际上,这种特定配置最大程度地节约资源,因为它设定了维持某个内存限制所需的最低 GC 频率。在这种情况下,整个程序执行过程中,堆大小都会上升到内存限制。
内存限制显然很强大,但它也有成本,绝不意味着 GOGC 失去作用。
考虑存活堆增长到使总内存使用接近内存限制时会怎样。在前面的稳态可视化中,先关闭 GOGC,再逐渐降低内存限制。你会发现,应用总耗时开始无界增长,因为 GC 为了维持一个不可能满足的限制而不断运行。
这种持续 GC 导致程序无法取得合理进展的情况,称为抖动(thrashing)。它尤其危险,因为实际上会使程序停滞。
更糟的是,我们原本想通过 GOGC 避免的情况也会导致它:足够大的短暂堆峰值,可以使程序无限期停滞!在短暂堆峰值可视化中,把内存限制降到大约 30 MiB 或更低,可以看到最糟糕的行为恰好从堆峰值开始。
在许多情况下,无限期停滞比内存耗尽更糟;后者往往会更快失败。
因此,这个内存限制被定义为“软”限制。Go 运行时不保证在所有情况下都维持它,只承诺作出合理努力。放宽内存限制对于避免抖动至关重要,因为这给了 GC 一条出路:允许内存使用超过限制,避免把过多时间花在 GC 上。
内部机制是,GC 为某个时间窗口内可使用的 CPU 时间设置上限,并针对很短暂的 CPU 使用峰值保留一定滞回。当前上限约为 50%,窗口为 2 * GOMAXPROCS CPU 秒。限制 GC CPU 时间会推迟 GC 工作;与此同时,Go 程序可以继续分配新堆内存,甚至超过内存限制。
50% GC CPU 上限背后的直觉,来自可用内存充足时对程序的最坏影响。如果误把内存限制设得过低,程序最多慢到原来的两倍耗时,因为 GC 无法拿走超过 50% 的 CPU 时间。
注意:本页可视化没有模拟 GC CPU 上限。
使用建议
内存限制是一个强大的工具,Go 运行时也采取措施减轻误用导致的最坏行为,但仍需谨慎使用。以下建议说明它在哪些情况下最有用、最适用,以及什么时候可能弊大于利。
- 当 Go 程序的执行环境完全受你控制,而且只有该程序能使用某一组资源时,应该利用内存限制,例如容器内存限制这样的内存预留。
一个好例子,是把 Web 服务部署到可用内存固定的容器中。
这时,一条实用经验是额外预留 5%–10% 的余量,以容纳 Go 运行时不知道的内存来源。
- 可以实时调整内存限制,适应变化的条件。
例如,在 cgo 程序中,C 库可能暂时需要使用多得多的内存。
- 如果 Go 程序可能与其他程序共享有限内存,而且彼此通常互不关联,不要在设定内存限制的同时关闭 GOGC。 应保留内存限制来抑制不希望出现的短暂行为,同时为通常情况把 GOGC 设为较小且合理的值。
为同机程序“预留”内存可能很诱人,但除非这些程序完全同步,例如 Go 程序调用子进程并阻塞等待它执行,否则结果会更不可靠,因为两个程序都难免需要更多内存。让 Go 程序在不需要时少用内存,整体上会得到更可靠的结果。
这条建议同样适用于超量承诺:同一台机器上各容器的内存限制之和,可能超过机器实际可用的物理内存。
- 部署到不受你控制的执行环境时,不要使用内存限制,尤其是程序内存使用与输入成正比时。
典型例子是命令行工具或桌面应用。如果不知道可能收到什么输入,也不清楚系统有多少可用内存,就把内存限制写死在程序中,可能导致令人困惑的崩溃和性能不佳。而且,高级用户如果愿意,始终可以自行设置内存限制。
- 程序已接近环境的内存上限时,不要试图靠设置内存限制避免内存耗尽。
这实际上把内存耗尽风险换成了应用严重变慢的风险;即使 Go 已努力减轻抖动,通常也不是划算的交换。这时,更有效的做法是提高环境的内存上限(之后再考虑设置内存限制),或者降低 GOGC;后者提供的权衡比抖动缓解机制明确得多。
延迟
本文的可视化把应用建模为在 GC 执行时暂停。确实存在这样工作的 GC 实现,称为“停止世界”(stop-the-world)GC。
但 Go GC 并非完全停止世界;它的大部分工作与应用并发进行,主要目的是降低应用延迟,具体说就是单个计算单元(例如 Web 请求)从头到尾的耗时。到目前为止,本文主要讨论应用吞吐量,例如每秒处理的 Web 请求数。注意,“GC 周期”一节的每个示例,都关注执行程序所花费的总 CPU 时间。
但对 Web 服务而言,这样的时长远没有那么有意义。吞吐量(例如每秒查询数)依然重要,但每个请求的延迟往往更加重要。
从延迟看,停止世界 GC 可能需要很长时间完成标记和清扫;期间应用无法继续推进,在 Web 服务中就是所有正在处理的请求都无法继续。Go GC 则避免让应用全局暂停的长度与堆大小成正比,并在应用继续执行时运行核心追踪算法。(从算法上看,暂停时间与 GOMAXPROCS 的相关性更强,但最常见的主要因素,是停止正在运行的 goroutine 所需的时间。)并发回收并非没有代价:实践中,它往往使吞吐量低于等价的停止世界垃圾收集器。
不过,较低延迟并不天然意味着较低吞吐量;Go 垃圾收集器的延迟与吞吐量性能一直在持续改进。
Go 当前 GC 的并发特性,不会推翻本文此前的任何讨论:那些结论都不依赖这一设计选择。就吞吐量而言,GC 频率仍是 CPU 时间与内存权衡的主要手段;实际上,在延迟方面它也发挥同样作用,因为 GC 的大部分成本发生在标记阶段活动期间。
因此,关键结论是:降低 GC 频率,也可能改善延迟。这不仅适用于提高 GOGC、提高内存限制等参数调整,也适用于“优化指南”中的优化方法。
不过,延迟往往比吞吐量更难理解,因为它来自程序每一时刻的实际执行,而不只是成本汇总。因此,延迟与 GC 频率的关系没有那么直接。下面列出可能的延迟来源,供希望深入了解的读者参考。
- GC 在标记与清扫阶段之间切换时发生的短暂全局暂停。
- 标记阶段 GC 占用 25% CPU 资源造成的调度延迟。
- 面对高分配速率,用户 goroutine 协助 GC。
- 标记阶段的指针写入需要额外工作。
- 为扫描 goroutine 的根,必须暂停正在运行的 goroutine。
除了指针写入的额外工作,这些延迟来源都能在执行跟踪中看到。
终结器、清理函数和弱指针
垃圾收集用有限内存营造出无限内存的假象。内存被分配,却从不显式释放;与最基础的手动内存管理相比,这让 API 和并发算法更简单。(一些手动管理内存的语言采用“智能指针”、编译期所有权追踪等替代方法,确保对象释放,但这些特性深深嵌入了这些语言的 API 设计惯例。)
只有存活对象,也就是能从全局变量或某个 goroutine 的计算中到达的对象,才会影响程序行为。对象一旦不可达,也就是“死亡”,之后任何时候 GC 都可以安全地回收它。这允许采用多种 GC 设计,包括 Go 如今使用的追踪式设计。对象死亡并不是语言层面可观察的事件。
不过,Go 运行时库提供了三种打破这一假象的特性:清理函数、弱指针和终结器。它们都能以某种方式观察并响应对象死亡,终结器甚至能逆转死亡。这当然会使 Go 程序更复杂,并给 GC 实现增加负担。但这些特性在多种场景下确实有用,Go 程序也一直在使用并从中受益。
各特性的细节见其包文档:runtime.AddCleanup、weak.Pointer 和 runtime.SetFinalizer。下面给出一般建议、常见问题,以及测试这些特性的方法。
一般建议
- 编写单元测试。
清理函数、弱指针和终结器的确切时机很难预测,即使连续执行很多次,也容易让自己相信一切正常。但细微错误同样很容易发生。为它们编写测试可能很棘手,可正因为使用方式微妙,测试比平时更加重要。
- 避免在普通 Go 代码中直接使用这些特性。
这些底层特性具有微妙的限制和行为。例如,无法保证程序退出时会运行清理函数或终结器,甚至无法保证它们会运行。API 文档中的长篇注释应被视为警示。绝大多数 Go 代码只能从间接使用中获益,直接使用并无益处。
- 把这些机制封装在包内部。
尽可能不要让这些机制泄露到包的公开 API;提供难以或无法误用的接口。例如,不要要求用户为 C 分配的内存设置清理函数来释放它,而应该编写包装包,把细节隐藏在内部。
- 把带有终结器、清理函数和弱指针的对象的访问,限制在创建对象并应用这些机制的包内。
这一点与上一条有关,但值得单独指出,因为这是降低误用风险的强大模式。例如,unique 包底层使用弱指针,却完整封装了弱指针指向的对象。应用其余部分永远不能修改这些值,只能通过 Value 方法复制它们,从而为包的使用者保留无限内存的假象。
- 尽可能确定性地清理非内存资源,把终结器和清理函数当作兜底。
清理函数和终结器适合处理内存资源,例如 C 分配的外部内存,或者对 mmap 映射的引用。由 C 的 malloc 分配的内存,最终必须由 C 的 free 释放。把调用 free 的终结器附加到 C 内存的包装对象上,是确保 C 内存最终随垃圾收集回收的合理方式。
但文件描述符等非内存资源,往往受 Go 运行时通常不知道的系统限制约束。另外,包作者一般无法控制给定 Go 程序中垃圾收集的时机;例如,GC 的运行频率由 GOGC 控制,而运维人员可以把它设为各种不同值。
这两个因素共同决定,清理函数和终结器不适合作为释放非内存资源的唯一机制。
如果你编写的包公开了包装某种非内存资源的 API,应考虑提供显式、确定性释放资源的 API,例如 Close 方法或类似机制,而不是通过清理函数或终结器依赖垃圾收集器。
更合适的做法是,把它们作为尽力处理程序员错误的兜底:要么像 os.File 那样仍然清理资源,要么向用户报告未能确定性清理资源的问题。
- 优先使用清理函数,而不是终结器。
历史上,加入终结器是为了简化 Go 与 C 代码之间的接口,并清理非内存资源。预期用法是把它附加到拥有 C 内存或其他非内存资源的包装对象上,等 Go 代码用完对象之后释放资源。这些初衷至少部分解释了为什么终结器适用范围狭窄、每个对象只能有一个终结器,以及终结器只能附加到对象的第一个字节。
这种限制已经妨碍了一些用例。例如,一个包如果想在内部缓存关于传入对象的信息,就无法在对象消失后清理这些信息。
更糟的是,终结器既低效又容易出错,因为它会复活所附加的对象,以便把对象传入终结器函数,甚至允许它在终结器之后继续存活。这一事实意味着:如果对象处于引用环中,它就永远无法释放;而对象占用的内存,至少要到下一次垃圾收集周期才能复用。
不过,正因为终结器会复活对象,它的执行顺序比清理函数更明确。因此,对于销毁顺序要求复杂的结构,终结器仍可能有用,只是这种情况很少。
对于 Go 1.24 及以后版本中的所有其他用途,原文作者建议使用清理函数,因为它更灵活、更不容易出错,也更高效。
清理函数的常见问题
- 附加了清理函数的对象,不能从清理函数中到达,例如通过捕获的局部变量到达。否则,对象无法回收,清理函数也永远不会运行。
f := new(myFile)
f.fd = syscall.Open(...)
runtime.AddCleanup(f, func(fd int) {
syscall.Close(f.fd) // Mistake: We reference f, so this cleanup won't run!
}, f.fd)
代码注释的意思是:错误在于引用了 f,所以这个清理函数不会运行。
- 附加了清理函数的对象,不能从传给清理函数的参数到达。否则,对象无法回收,清理函数也永远不会运行。
f := new(myFile)
f.fd = syscall.Open(...)
runtime.AddCleanup(f, func(f *myFile) {
syscall.Close(f.fd)
}, f) // Mistake: We reference f, so this cleanup wouldn't ever run. This specific case also panics.
代码注释的意思是:错误在于引用了 f,所以清理函数永远不会运行;这个特定情形还会 panic。
- 终结器有明确的执行顺序,清理函数没有。清理函数之间还可能并发执行。
- 运行时间长的清理函数应创建 goroutine,避免阻塞其他清理函数的执行。
runtime.GC不会等待不可达对象的清理函数执行完成,只会等到它们全部入队。
弱指针的常见问题
- 弱指针的
Value方法可能在意想不到的时候开始返回nil。务必检查Value调用结果是否为nil,并准备备用方案。 - 弱指针作为 map 键时,不影响 map 值的可达性。因此,如果弱指针键指向一个也能从 map 值到达的对象,该对象仍会被视为可达。
终结器的常见问题
- 附加了终结器的对象,不能经由任何路径从自身到达自身,也就是说,它不能处于引用环中。否则,对象无法回收,终结器也永远不会运行。
f := new(myCycle)
f.self = f // Mistake: f is reachable from f, so this finalizer would never run.
runtime.SetFinalizer(f, func(f *myCycle) {
...
})
代码注释的意思是:错误在于从 f 可以到达 f,因此这个终结器永远不会运行。
- 附加了终结器的对象,不能从终结器函数到达,例如通过捕获的局部变量到达。否则,对象无法回收,终结器也永远不会运行。
f := new(myFile)
f.fd = syscall.Open(...)
runtime.SetFinalizer(f, func(_ *myFile) {
syscall.Close(f.fd) // Mistake: We reference the outer f, so this cleanup won't run!
})
代码注释的意思是:错误在于引用了外层的 f,因此这项清理不会运行。
- 带终结器对象组成的引用链,例如链表,至少需要与链中对象数量相同的 GC 周期,才能全部清理。让带终结器的结构保持浅层!
// Mistake: reclaiming this linked list will take at least 10 GC cycles.
node := new(linkedListNode)
for range 10 {
tmp := new(linkedListNode)
tmp.next = node
node = tmp
runtime.SetFinalizer(node, func(node *linkedListNode) {
...
})
}
代码注释的意思是:回收这个链表至少需要 10 个 GC 周期。
- 避免为跨包边界返回的对象设置终结器。否则,包的使用者就能调用
runtime.SetFinalizer修改返回对象上的终结器;这种意外行为可能最终成为用户依赖的行为。 - 运行时间长的终结器应创建新的 goroutine,避免阻塞其他终结器执行。
runtime.GC不会等待不可达对象的终结器执行完成,只会等到它们全部入队。
测试对象死亡
使用这些特性的代码,有时很难编写测试。下面是一些编写健壮测试的建议。
- 避免让这类测试与其他测试并行运行。尽可能提高确定性,并掌握每一时刻的整体状态,会很有帮助。
- 进入测试时,使用
runtime.GC建立基线。使用runtime.GC使弱指针变为nil,并把清理函数和终结器加入执行队列。 runtime.GC不等待清理函数和终结器运行,只负责把它们排入队列。
为了写出尽可能健壮的测试,可以注入一种机制,让测试等待清理函数或终结器完成。例如,从测试中向清理函数或终结器传入可选通道,执行完毕后写入通道。如果这样做太困难或不可能,另一种方法是循环检查某个清理后的状态,直到条件成立。例如,os 的测试会在循环中调用 runtime.Gosched,检查不可达的文件是否已经关闭。
- 如果测试终结器,而且存在使用终结器的对象链,为确保所有终结器都运行,调用
runtime.GC的次数至少应等于测试能创建的最深链长度。 - 在竞态检测模式下测试,以发现并发清理函数之间,以及清理函数、终结器代码与代码库其他部分之间的竞态。
其他资源
上面的信息准确,但还不够详细,不足以让你充分理解 Go GC 设计的成本和权衡。更多信息见以下资源。
- The GC Handbook:关于垃圾收集器设计的优秀通用资料和参考书。
- TCMalloc:C/C++ 内存分配器 TCMalloc 的设计文档;Go 内存分配器以它为基础。
- Go 1.5 GC 公告:宣布 Go 1.5 并发 GC 的博客文章,详细描述了算法。
- Getting to Go:深入介绍截至 2018 年 Go GC 设计演进的演讲。
- Go 1.5 concurrent GC pacing:关于确定何时开始并发标记阶段的设计文档。
- Smarter scavenging:关于改进 Go 运行时向操作系统归还内存方式的设计文档。
- Scalable page allocator:关于改进 Go 运行时管理从操作系统获取的内存方式的设计文档。
- GC pacer redesign(Go 1.18):关于改进并发标记阶段启动时机算法的设计文档。
- Soft memory limit(Go 1.19):软内存限制的设计文档。
关于虚拟内存的说明
本指南主要关注 GC 的物理内存使用,但一个经常出现的问题是:这到底指什么,它与虚拟内存(通常在 top 等程序中显示为 VSS)有什么区别?
物理内存是大多数计算机中实际 RAM 芯片里的内存。虚拟内存则是操作系统在物理内存之上提供的一层抽象,用于隔离不同程序。通常,程序也可以预留完全不映射到任何物理地址的虚拟地址空间。
由于虚拟内存只是操作系统维护的一种映射,预留大量不映射到物理内存的虚拟内存通常成本很低。
Go 运行时通常在以下几个方面依赖这一虚拟内存成本特征。
- Go 运行时从不删除它映射的虚拟内存,而是利用大多数操作系统提供的特殊操作,显式释放某段虚拟内存对应的物理内存资源。
这项技术明确用于管理内存限制,以及向操作系统归还 Go 运行时不再需要的内存。Go 运行时还会在后台持续释放不再需要的内存。更多信息见“其他资源”。
- 在 32 位平台上,Go 运行时预先为堆预留 128 MiB 到 512 MiB 的地址空间,以限制碎片问题。
- Go 运行时在若干内部数据结构的实现中,会预留大量虚拟地址空间。在 64 位平台上,它们通常至少占用约 700 MiB 虚拟内存;在 32 位平台上,这部分占用可以忽略。
因此,top 中的 VSS 等虚拟内存指标,通常不太适合用来理解 Go 程序的内存占用。应关注 RSS 和类似指标,它们更直接地反映物理内存使用。
优化指南
识别成本
在优化 Go 应用与 GC 的交互之前,首先要确认 GC 确实是一项主要成本。
Go 生态提供许多工具来识别成本、优化 Go 应用。简要概述见诊断指南。这里集中介绍其中一部分,以及合理的使用顺序,以理解 GC 的影响和行为。
- CPU 性能剖析
CPU 性能剖析是一个不错的起点。它概览 CPU 时间花在何处,但缺乏经验时,可能难以判断 GC 在特定应用中影响有多大。好在,要理解 GC 的作用,主要就是弄清 runtime 包中不同函数的含义。下面这些函数对于解释 CPU 剖析结果很有用。
注意,下面列出的函数不是叶函数,因此可能不会出现在 pprof 工具 top 命令默认显示的结果中。应使用 top -cum,或者直接对这些函数执行 list,关注累计百分比列。
runtime.gcBgMarkWorker:后台标记工作 goroutine 的入口。这里的耗时随 GC 频率以及对象图的复杂度、大小变化,代表应用在标记和扫描上耗时的基线。
在这些 goroutine 内部,你会看到对 runtime.gcDrainMarkWorkerDedicated、runtime.gcDrainMarkWorkerFractional 和 runtime.gcDrainMarkWorkerIdle 的调用,它们表示工作协程的类型。在大部分时间空闲的 Go 应用中,Go GC 会利用额外的空闲 CPU 资源,更快地完成工作;runtime.gcDrainMarkWorkerIdle 符号就表示这种情况。因此,这里的时间可能在 CPU 样本中占很大比例,但 Go GC 认为这些资源本来是空闲的。
应用变得更忙时,空闲工作协程占用的 CPU 时间就会下降。一种常见情形是:应用完全运行在一个 goroutine 中,但 GOMAXPROCS 大于 1。
runtime.mallocgc:堆内存分配器的入口。这里累计耗时很高(超过 15%),通常意味着大量内存分配。runtime.gcAssistAlloc:goroutine 进入这个函数后,会让出一部分时间协助 GC 扫描和标记。这里累计耗时很高(超过 5%),表明应用分配内存的速度很可能超过了 GC 的处理速度。这意味着 GC 的影响特别大,也代表应用用于标记和扫描的时间。注意,它包含在runtime.mallocgc调用树内,因此也会抬高后者的耗时。
- 执行跟踪
CPU 剖析很适合识别累计时间花在哪里,但对于细微、罕见或特别与延迟有关的性能成本,帮助较小。执行跟踪则深入、丰富地展示 Go 程序短时间窗口内的执行情况。它包含各种 Go GC 相关事件,能够直接观察具体执行路径,以及应用如何与 Go GC 交互。
跟踪查看器会清楚地标注所有追踪到的 GC 事件。
入门方法见 runtime/trace 包文档。
- GC 跟踪
其他方法都无效时,Go GC 还提供几种专门跟踪,帮助深入理解 GC 行为。它们始终直接输出到 STDERR,每个 GC 周期一行,通过所有 Go 程序都识别的 GODEBUG 环境变量配置。
这些跟踪主要用于调试 Go GC 本身,因为需要熟悉 GC 的具体实现;不过,有时也能帮助更好地理解 GC 行为。
设置 GODEBUG=gctrace=1 可启用核心 GC 跟踪。输出格式见 runtime 包文档的环境变量部分。
另有称为“pacer trace”的补充 GC 跟踪,可提供更深入的信息,使用 GODEBUG=gcpacertrace=1 启用。解释其输出需要理解 GC 的 pacer(节奏控制器,参见“其他资源”),超出了本指南范围。
消除堆分配
降低 GC 成本的一种方式,是一开始就让 GC 管理更少的值。以下技术可能带来最大的性能改善之一,因为如 GOGC 一节所示,Go 程序的分配速率是决定 GC 频率的重要因素,而 GC 频率正是本指南采用的关键成本指标。
堆性能剖析
确认 GC 是显著成本来源后,消除堆分配的下一步,就是找出大多数分配来自哪里。为此,内存剖析(准确说是堆内存剖析)非常有用。入门方法见相关文档。
内存剖析使用分配发生处的栈跟踪,描述程序中的堆分配来自哪里。每份内存剖析都能从四个角度拆解内存。
inuse_objects:按存活对象的数量拆解。inuse_space:按存活对象使用的字节数拆解。alloc_objects:按 Go 程序开始执行以来分配的对象数量拆解。alloc_space:按 Go 程序开始执行以来分配的总内存量拆解。
可以通过 pprof 工具的 -sample_index 标志,或交互模式下的 sample_index 选项,在这些视图之间切换。
注意:内存剖析默认只采样一部分堆对象,因此不包含每次堆分配的信息。不过,这已足以找到热点。调整采样率的方法见 runtime.MemProfileRate。
对于降低 GC 成本,alloc_space 通常是最有用的视图,因为它直接对应分配速率。该视图能指出优化收益最大的分配热点。
逃逸分析
借助堆剖析识别出候选堆分配位置后,如何消除它们?关键是利用 Go 编译器的逃逸分析,让编译器为这块内存找到其他更高效的存储位置,例如 goroutine 栈。好在 Go 编译器能够说明它为什么决定让某个 Go 值逃逸到堆。
知道原因之后,就需要重新组织源代码,改变分析结果;这往往是最困难的一步,但超出了本指南范围。
获取逃逸分析信息最简单的方法,是使用编译器支持的调试标志,以文本形式说明它对某个包应用或未应用的全部优化,其中包括值是否逃逸。可以尝试以下命令,[package] 表示某个 Go 包路径。
$ go build -gcflags=-m=3 [package]
这些信息还可以在支持 LSP 的编辑器中以叠加标注显示,通过代码操作提供。例如,在 VS Code 中执行“Source Action… > Show compiler optimization details”,即可为当前包启用诊断;也可以执行“Go: Toggle compiler optimization details”。使用以下配置设置控制显示哪些注释:
- 在
ui.diagnostic.annotations设置中加入escape,启用逃逸分析叠加标注。
最后,Go 编译器还以机器可读的 JSON 格式提供这些信息,便于构建其他自定义工具。更多说明见Go 源码文档。
特定于实现的优化
Go GC 对存活内存的结构特征很敏感,因为复杂的对象与指针图既限制并行性,也增加 GC 工作。因此,GC 为一些常见结构提供了优化。下面列出对于性能优化最直接有用的几项。
注意:应用以下优化可能掩盖代码意图、降低可读性,而且效果可能无法跨 Go 版本保持。优先只在最重要的地方使用;可以通过“识别成本”一节的工具确定这些位置。
- 不含指针的值,与其他值分开存放。
因此,从并不严格需要指针的数据结构中去掉指针可能有利,因为这会降低 GC 给程序带来的缓存压力。使用索引代替指针的数据结构,虽然类型约束较弱,但可能表现更好。只有明确知道对象图复杂、GC 在标记和扫描上花费大量时间时,才值得这么做。
- GC 扫描到值中的最后一个指针时,就停止扫描这个值。
因此,把结构体中的指针字段集中放在开头可能有利。只有明确知道应用在标记和扫描上花费大量时间时,才值得这么做。(理论上编译器可以自动完成,但目前尚未实现;结构体字段按源代码中的顺序排列。)
此外,GC 几乎必须处理遇到的每一个指针,因此例如用切片索引代替指针,也有助于降低 GC 成本。
Linux 透明大页(THP)
程序访问内存时,CPU 需要把程序使用的虚拟地址转换为指向所需数据的物理地址。为此,CPU 查询“页表”;它是操作系统管理的一种数据结构,表示虚拟内存到物理内存的映射。页表中的每个条目代表一个不可再分的物理内存块,称为页,因此得名。
透明大页(THP)是 Linux 的一项功能,会透明地把连续虚拟内存区域对应的物理内存页,替换成更大的内存块,即大页。使用更大的块,意味着表示同一内存区域所需的页表条目更少,从而改善页表查询时间。但如果系统只使用大页的一小部分,更大的块也意味着更多浪费。
生产环境中的 Go 程序,在 Linux 上启用透明大页可能以额外内存使用为代价,改善吞吐量和延迟。堆较小的应用通常无法从 THP 获益,反而可能使用大量额外内存,增幅高达 50%。但堆较大的应用(1 GiB 或以上)通常能明显获益,吞吐量最高改善 10%,而额外内存开销不大,约 1%–2% 或更低。
无论哪种情况,了解自己的 THP 设置都有帮助,并且始终建议通过实验验证。
在 Linux 环境中,可以修改 /sys/kernel/mm/transparent_hugepage/enabled 来启用或禁用透明大页。更多细节见Linux 官方管理员指南。如果选择在 Linux 生产环境启用透明大页,原文作者建议为 Go 程序采用以下额外设置。
- 把
/sys/kernel/mm/transparent_hugepage/defrag设置为defer或defer+madvise。
这一设置控制 Linux 内核把普通页合并成大页的积极程度。defer 告诉内核延迟处理,并在后台合并。更积极的设置可能使内存受限系统停顿,往往损害应用延迟。defer+madvise 类似 defer,但对系统中明确请求大页、且依赖大页获得性能的其他应用更友好。
- 把
/sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_none设置为0。
这一设置控制 Linux 内核守护进程尝试分配大页时,最多可以分配多少额外页。默认设置最为积极,往往会抵消 Go 运行时向操作系统归还内存所做的工作。在 Go 1.21 之前,Go 运行时试图减轻默认设置的负面影响,但会产生 CPU 成本。从 Go 1.21+ 与 Linux 6.2+ 的组合开始,Go 运行时不再修改大页状态。
如果升级到 Go 1.21.1 或以后版本时内存使用增加,可以尝试应用这一设置;它很可能解决问题。作为额外的临时办法,也可以调用 Prctl 函数并传入 PR_SET_THP_DISABLE,在进程层面禁用大页;或者设置 GODEBUG=disablethp=1,对堆内存禁用大页(原文标注此设置将加入 Go 1.21.6 和 Go 1.22)。注意,这个 GODEBUG 设置可能在未来版本中移除。
附录
关于 GOGC 的补充说明
GOGC 一节指出,GOGC 加倍会使堆内存开销加倍,并使 GC CPU 成本减半。下面从数学上分解,说明原因。
首先,堆目标为总堆大小设定目标。不过,这个目标主要影响新堆内存,因为存活堆由应用本身决定。
目标堆内存 = 存活堆 +(存活堆 + GC 根)× GOGC / 100
总堆内存 = 存活堆 + 新堆内存
由此可得:
新堆内存 =(存活堆 + GC 根)× GOGC / 100
由此可见,GOGC 加倍,也会使应用在每个周期分配的新堆内存量加倍,这就是堆内存开销。注意,“存活堆 + GC 根”近似表示 GC 需要扫描的内存量。
接下来考虑 GC CPU 成本。总成本可以分解为每周期成本,乘以 GC 频率,再乘以时间段 T。
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 需要扫描的内存量;但当可扫描的存活堆非常小、整体存活堆却很大时,这会导致退化行为。
原文:A Guide to the Go Garbage Collector,Go 项目 / The Go Authors。本文为中文翻译,保留原文代码及其英文注释,另补中文注释说明;原文交互图以静态文字说明呈现,未提供动态操作。内容依据 Go 网站版权声明,采用 Creative Commons Attribution 4.0 许可(声明另有说明者除外)。代码采用 BSD 许可,Copyright 2009 The Go Authors;完整代码许可、条件与免责声明见随附 https://go.dev/LICENSE。原文中的“我们”均指原作者,示例数值、结果和性能比例均来自原文,并非译者运行实验所得。











暂无评论内容