Go 垃圾收集器指南

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 成本模型。

  1. GC 只涉及两种资源:物理内存和 CPU 时间。
  2. GC 的内存成本包括存活堆内存、标记阶段开始前新分配的堆内存,以及元数据占用的空间。即使元数据空间与前述成本成正比,相比之下也很小。

第 N 个周期的 GC 内存成本 = 第 N−1 个周期的存活堆 + 新堆内存

存活堆内存是上一个 GC 周期判定为存活的内存;新堆内存则是当前周期分配的全部内存,在周期结束时可能存活,也可能不存活。任意时刻有多少内存存活,是程序本身的属性,GC 无法直接控制。

  1. 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 频率的关系没有那么直接。下面列出可能的延迟来源,供希望深入了解的读者参考。

  1. GC 在标记与清扫阶段之间切换时发生的短暂全局暂停。
  2. 标记阶段 GC 占用 25% CPU 资源造成的调度延迟。
  3. 面对高分配速率,用户 goroutine 协助 GC。
  4. 标记阶段的指针写入需要额外工作。
  5. 为扫描 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 设计的成本和权衡。更多信息见以下资源。

关于虚拟内存的说明

本指南主要关注 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 的影响和行为。

  1. 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 调用树内,因此也会抬高后者的耗时。
  1. 执行跟踪

CPU 剖析很适合识别累计时间花在哪里,但对于细微、罕见或特别与延迟有关的性能成本,帮助较小。执行跟踪则深入、丰富地展示 Go 程序短时间窗口内的执行情况。它包含各种 Go GC 相关事件,能够直接观察具体执行路径,以及应用如何与 Go GC 交互。

跟踪查看器会清楚地标注所有追踪到的 GC 事件。

入门方法见 runtime/trace 包文档。

  1. 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”。使用以下配置设置控制显示哪些注释:

  1. 在 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。原文中的“我们”均指原作者,示例数值、结果和性能比例均来自原文,并非译者运行实验所得。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容