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 值的存储位置
在我们深入了解 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 从程序的根开始遍历对象图,根是指向程序明确正在使用的对象的指针。根的两个示例是局部变量和全局变量。遍历对象图的过程称为扫描。
此基本算法适用于所有跟踪 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 成本模型。
-
GC 仅涉及两种资源:CPU 时间和物理内存。
-
GC 的内存成本包括活动堆内存、标记阶段之前分配的新堆内存以及元数据空间,即使与之前的成本成比例,但相比较小。
注意:活动堆内存是由上一个 GC 周期确定的活动内存,而新堆内存是在当前周期中分配的任何内存,它在结束时可能处于活动状态,也可能不处于活动状态。
-
GC 的 CPU 成本被建模为每个周期的固定成本和与活动堆大小成比例的边际成本。
注意:渐近而言,清除的扩展性比标记和扫描差,因为它必须执行与整个堆大小成比例的工作,包括确定为非活动(即“死亡”)的内存。但是,在当前实现中,清除比标记和扫描快得多,因此在本次讨论中可以忽略其相关成本。
此模型简单但有效:它准确地对 GC 的主要成本进行了分类。但是,此模型并未说明这些成本的大小,也没有说明它们如何交互。要对其进行建模,请考虑以下情况,从这里开始称为稳态。
-
应用程序分配新内存的速度(以每秒字节为单位)是恒定的。
注意:了解此分配速率与此新内存是否活动完全无关非常重要。它可能都不活动,它可能都活动,或者它的一部分可能活动。(最重要的是,一些旧的堆内存也可能死亡,因此如果该内存活动,活动堆大小不一定增长。)
为了更具体地说,考虑一个 Web 服务,它为其处理的每个请求分配 2 MiB 的总堆内存。在请求期间,该 2 MiB 中最多 512 KiB 在请求进行中时保持活动状态,并且当服务完成处理请求时,所有内存都死亡。现在,为了简单起见,假设每个请求大约需要 1 秒才能端到端处理。然后,稳定的请求流(例如每秒 100 个请求)将产生 200 MiB/s 的分配速率和 50 MiB 的峰值活动堆。
-
应用程序的对象图每次看起来都大致相同(对象大小相似,指针数量大致恒定,图的最大深度大致恒定)。
考虑这一点的另一种方法是 GC 的边际成本是恒定的。
注意:稳态可能看起来很牵强,但它代表了应用程序在某些恒定工作负载下的行为。当然,即使在应用程序执行期间,工作负载也会发生变化,但通常应用程序行为看起来像一堆稳态串在一起,其间有一些瞬态行为。
注意:稳态不对活动堆做出任何假设。它可能随着每个后续的 GC 周期而增长,它可能缩小,或者它可能保持不变。但是,尝试在以下解释中涵盖所有这些情况既乏味又没有说明性,因此本指南将重点关注活动堆保持恒定的示例。GOGC 部分更详细地探讨了非恒定活动堆场景。
在活动堆大小恒定的稳态中,只要 GC 在经过相同时间后执行,每个 GC 周期在成本模型中看起来都是相同的。这是因为在固定的时间内,在应用程序以固定的分配速率下,将分配固定数量的新堆内存。因此,随着活动堆大小恒定,并且新堆内存恒定,内存使用量将始终相同。并且由于活动堆大小相同,因此边际 GC CPU 成本将相同,并且固定成本将在某个规则间隔内发生。
现在考虑一下,如果 GC 将其运行时间点向后推移会怎样。那么,将分配更多内存,但每个 GC 周期仍会产生相同的 CPU 成本。然而,在某些其他固定时间窗口内,完成的 GC 周期会更少,从而导致整体 CPU 成本降低。如果 GC 决定提前开始,则相反的情况也会成立:分配的内存更少,而 CPU 成本会更频繁地产生。
这种情况代表了 GC 可以做出的 CPU 时间和内存之间的基本权衡,由 GC 实际执行的频率控制。换句话说,权衡完全由GC 频率定义。
还有一个细节有待定义,那就是 GC 应该决定何时开始。请注意,这直接设置了任何特定稳态中的 GC 频率,从而定义了权衡。在 Go 中,决定 GC 何时应该开始是用户可以控制的主要参数。
GOGC
在高层面上,GOGC 决定了 GC CPU 和内存之间的权衡。
它的工作原理是确定每个 GC 周期后的目标堆大小,即下一周期中总堆大小的目标值。GC 的目标是在总堆大小超过目标堆大小之前完成一个收集周期。总堆大小被定义为上一个周期末尾的活动堆大小,加上自上一个周期以来应用程序分配的任何新堆内存。同时,目标堆内存被定义为
目标堆内存 = 活动堆 + (活动堆 + GC 根) * GOGC / 100
例如,考虑一个活动堆大小为 8 MiB、协程堆栈为 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 仅包含根集。以前,它只计算活动堆。通常,协程堆栈中的内存量非常小,而活动堆大小占主导地位,但对于程序有数十万个协程的情况,GC 做出了错误的判断。
堆目标控制 GC 频率:目标越大,GC 等待开始另一个标记阶段的时间就越长,反之亦然。虽然精确的公式对于进行估计很有用,但最好从其基本目的来考虑 GOGC:一个在 GC CPU 和内存权衡中选择一个点的参数。关键要点是将 GOGC 加倍将使堆内存开销加倍,并将 GC CPU 成本大致减半,反之亦然。(要了解为什么的完整解释,请参阅附录。)
注意:目标堆大小只是一个目标,GC 周期可能无法完全达到该目标的原因有很多。一方面,足够大的堆分配可能会超出目标。但是,GC 实现中会出现其他原因,这些原因超出了本指南迄今为止一直使用的 GC 模型。有关更多详细信息,请参阅 延迟部分,但可以在 其他资源 中找到完整详细信息。
GOGC 可以通过 GOGC
环境变量(所有 Go 程序都识别)或 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 时间会增加整体持续时间。
请注意,GC 始终会产生一些 CPU 和峰值内存开销。随着 GOGC 的增加,CPU 开销会减少,但峰值内存会与活动堆大小成比例地增加。随着 GOGC 的减少,峰值内存需求会以增加 CPU 开销为代价而减少。
注意:该图表显示的是 CPU 时间,而不是完成程序的挂钟时间。如果程序在 1 个 CPU 上运行并充分利用其资源,那么它们是等效的。实际程序很可能在多核系统上运行,并且不会始终 100% 利用 CPU。在这些情况下,GC 的挂钟时间影响会更低。
注意:Go GC 的最小总堆大小为 4 MiB,因此如果 GOGC 设置的目标低于该值,则会向上取整。可视化反映了此详细信息。
这里还有另一个更具动态性和真实性的示例。同样,该应用程序在没有 GC 的情况下需要 10 个 CPU 秒才能完成,但稳态分配速率在中途急剧增加,并且活动堆大小在第一阶段略有变化。此示例演示了当活动堆大小实际变化时稳态可能如何变化,以及较高的分配速率如何导致更频繁的 GC 周期。
内存限制
在 Go 1.19 之前,GOGC 是可以用来修改 GC 行为的唯一参数。虽然它作为设置权衡的一种方式非常有效,但它没有考虑到可用内存是有限的。考虑一下当活动堆大小出现瞬时峰值时会发生什么:因为 GC 会选择与该活动堆大小成比例的总堆大小,所以必须将 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 为 100,为 42 MiB)以下时,GC 会更频繁地运行,以将峰值内存保持在限制范围内。
回到我们之前关于瞬态堆峰值的示例,通过设置内存限制并提高 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 无法占用其 CPU 时间的 50% 以上。
注意:此页面上的可视化不会模拟 GC CPU 限制。
建议用途
虽然内存限制是一个强大的工具,并且 Go 运行时采取措施减轻滥用的最坏行为,但仍然重要的是要深思熟虑地使用它。下面是一些关于内存限制最有用和最适用的地方以及它可能弊大于利的地方的建议。
-
请在 Go 程序的执行环境完全受您控制且 Go 程序是唯一可以访问某些资源(即某种内存保留,如容器内存限制)的程序时利用内存限制。
一个很好的例子是将 Web 服务部署到具有固定可用内存量的容器中。
在这种情况下,一个好的经验法则是留出额外的 5-10% 的裕量来考虑 Go 运行时不知道的内存源。
-
请随时调整内存限制以适应不断变化的条件。
一个很好的例子是 cgo 程序,其中 C 库暂时需要使用更多内存。
-
不要在 Go 程序可能与其有限的内存与其他程序共享,并且这些程序通常与 Go 程序分离的情况下,将 GOGC 设置为 off 并设置内存限制。相反,保留内存限制,因为它可能有助于抑制不希望的瞬态行为,但将 GOGC 设置为较小、合理的平均值。
虽然尝试为共租程序“保留”内存可能很诱人,但除非程序完全同步(例如,Go 程序调用一些子进程并在其调用方执行时阻塞),否则结果将不可靠,因为这两个程序不可避免地都需要更多内存。让 Go 程序在不需要时使用更少的内存将总体上产生更可靠的结果。此建议也适用于超量提交的情况,其中在一台机器上运行的容器的内存限制总和可能超过机器可用的实际物理内存。
-
不要在部署到您无法控制的执行环境时使用内存限制,尤其是在程序的内存使用量与其输入成正比时。
一个很好的例子是 CLI 工具或桌面应用程序。当不清楚程序可能输入什么样的输入或系统上可能有多少可用内存时,将内存限制烘焙到程序中可能会导致令人困惑的崩溃和性能不佳。此外,高级最终用户可以随时根据需要设置内存限制。
-
不要在程序已经接近其环境的内存限制时设置内存限制以避免内存不足的情况。
这实际上用严重的应用程序减速风险取代了内存不足的风险,即使 Go 为减轻抖动所做的努力,这通常也不是有利的交换。在这种情况下,更有效的方法是增加环境的内存限制(然后可能设置内存限制)或减少 GOGC(与抖动缓解相比,这提供了更干净的权衡)。
延迟
本文中的可视化将应用程序建模为在 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 频率之间的联系不太直接。对于那些倾向于深入研究的人,下面列出了延迟的可能来源。
- 当 GC 在标记和清除阶段之间转换时,短暂停止世界暂停,
- 调度延迟,因为 GC 在标记阶段占用 25% 的 CPU 资源,
- 用户 goroutine 响应高分配率来辅助 GC,
- 在 GC 处于标记阶段时,指针写入需要额外的工作,并且
- 必须暂停正在运行的 goroutine 才能扫描它们的根。
这些延迟源在执行跟踪中可见,但指针写入需要额外的工作除外。
其他资源
虽然上面提供的信息准确无误,但它缺乏了解 Go GC 设计中的成本和权衡所需的详细信息。有关更多信息,请参阅以下其他资源。
- GC 手册——关于垃圾收集器设计的优秀通用资源和参考。
- TCMalloc——C/C++ 内存分配器 TCMalloc 的设计文档,Go 内存分配器基于此设计。
- Go 1.5 GC 公告——宣布 Go 1.5 并发 GC 的博文,其中更详细地描述了该算法。
- Go 的发展——关于 Go GC 设计演变的深入介绍,截止到 2018 年。
- Go 1.5 并发 GC 配速——确定何时启动并发标记阶段的设计文档。
- 更智能的清除——修改 Go 运行时将内存返回给操作系统的设计文档。
- 可扩展页面分配器——修改 Go 运行时管理从操作系统获取的内存的设计文档。
- GC 配速器重新设计(Go 1.18)——修改算法以确定何时启动并发标记阶段的设计文档。
- 软内存限制(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 配置文件提供了 CPU 时间使用情况的概述,尽管对于未经训练的眼睛来说,可能很难识别 GC 在特定应用程序中所扮演的角色的大小。幸运的是,了解 GC 如何适应主要归结为了解 `runtime` 包中不同函数的含义。以下是用于解释 CPU 配置文件的这些函数的有用子集。
注意:下面列出的函数不是叶函数,因此它们可能不会出现在
pprof
工具使用top
命令提供的默认值中。相反,使用top -cum
命令或直接对这些函数使用list
命令,并重点关注累积百分比列。 -
runtime.gcBgMarkWorker
:后台标记工作程序例程的入口点。在此处花费的时间会随着 GC 频率以及对象图的复杂性和大小而增加。它表示应用程序用于标记和扫描的时间基准。注意:在这些例程中,你会发现对
runtime.gcDrainMarkWorkerDedicated
、runtime.gcDrainMarkWorkerFractional
和runtime.gcDrainMarkWorkerIdle
的调用,它们表示工作程序类型。在很大程度上处于空闲状态的 Go 应用程序中,Go GC 将使用额外的(空闲)CPU 资源来更快地完成其工作,这由runtime.gcDrainMarkWorkerIdle
符号表示。因此,此处的时间可能代表 Go GC 认为是空闲的 CPU 样本的很大一部分。如果应用程序变得更加活跃,空闲工作程序中的 CPU 时间将减少。发生这种情况的一个常见原因是,如果应用程序完全在一个例程中运行,但GOMAXPROCS
大于 1。 -
runtime.mallocgc
:堆内存内存分配器的入口点。在此处花费的大量累积时间(>15%)通常表示分配了大量内存。 -
runtime.gcAssistAlloc
:函数协程进入以让出一些时间来帮助 GC 扫描和标记。在此处花费大量累积时间(>5%)表明应用程序在分配速度方面可能超过了 GC。它表明 GC 影响特别大,还表示应用程序花费在标记和扫描上的时间。请注意,这包含在runtime.mallocgc
调用树中,因此也会增加该调用树。 -
执行跟踪
虽然 CPU 分析非常适合识别总共花费时间的位置,但对于指示更微妙、罕见或具体与延迟相关的性能成本,它们的作用较小。另一方面,执行跟踪可以深入详细地了解 Go 程序执行的短时间窗口。它们包含与 Go GC 相关的各种事件,并且可以对具体的执行路径进行直接观察,以及应用程序如何与 Go GC 交互。跟踪查看器中会方便地将所有跟踪的 GC 事件标记为 GC 事件。
请参阅
runtime/trace
包的文档,了解如何开始使用执行跟踪。 -
GC 跟踪
当所有其他方法都失败时,Go GC 会提供一些不同的特定跟踪,这些跟踪可以更深入地了解 GC 行为。这些跟踪始终直接打印到 STDERR,每个 GC 周期一行,并且通过所有 Go 程序都识别的
GODEBUG
环境变量进行配置。它们主要用于调试 Go GC 本身,因为它们需要对 GC 实现的具体内容有一定的了解,但有时仍可用于更好地了解 GC 行为。通过设置
GODEBUG=gctrace=1
启用核心 GC 跟踪。此跟踪产生的输出在runtime
包文档的环境变量部分 中进行了说明。名为“pacer 跟踪”的补充 GC 跟踪提供了更深入的见解,并且通过设置
GODEBUG=gcpacertrace=1
启用。解释此输出需要了解 GC 的“pacer”(请参阅 其他资源),这超出了本指南的范围。
消除堆分配
降低 GC 成本的一种方法是让 GC 从一开始就管理更少的值。下面描述的技术可以产生一些最大的性能提升,因为正如 GOGC 部分 所展示的,Go 程序的分配速率是 GC 频率的一个主要因素,这是本指南使用的关键成本指标。
堆分析
在确定 GC 是成本的重要来源之后,消除堆分配的下一步是找出它们大多数来自哪里。为此,内存分析(实际上是堆内存分析)非常有用。查看文档,了解如何开始使用它们。
内存分析描述了程序堆分配的来源,通过分配它们时的堆栈跟踪来识别它们。每个内存分析都可以通过四种方式分解内存。
inuse_objects
—分解活动对象的数目。inuse_space
—按活动对象使用的内存字节数分解活动对象。alloc_objects
—分解自 Go 程序开始执行以来已分配的对象数目。alloc_space
—分解自 Go 程序开始执行以来已分配的总内存量。
在堆内存的这些不同视图之间切换可以使用 pprof
工具的 -sample_index
标志完成,或者在交互式使用该工具时通过 sample_index
选项完成。
注意:默认情况下,内存分析只对堆对象的一个子集进行采样,因此它们不会包含有关每个堆分配的信息。但是,这足以找到热点。要更改采样率,请参见runtime.MemProfileRate
。
为了降低 GC 成本,alloc_space
通常是最有用的视图,因为它直接对应于分配率。此视图将指示分配热点,这些热点将提供最大的好处。
逃逸分析
一旦借助堆分析确定了候选堆分配站点,如何消除它们?关键是利用 Go 编译器的逃逸分析,让 Go 编译器为该内存找到替代的、更高效的存储,例如在 goroutine 堆栈中。幸运的是,Go 编译器能够描述它决定将 Go 值逃逸到堆的原因。有了该知识,它就变成了重新组织源代码以更改分析结果的问题(这通常是最困难的部分,但超出了本指南的范围)。
至于如何访问 Go 编译器逃逸分析中的信息,最简单的方法是通过 Go 编译器支持的调试标志,该标志以文本格式描述了它对某个包应用或未应用的所有优化。这包括值是否逃逸。尝试以下命令,其中 [package]
是某个 Go 包路径。
$ go build -gcflags=-m=3 [package]
此信息也可以在 VS Code 中作为叠加层进行可视化。此叠加层在 VS Code Go 插件设置中进行配置和启用。
-
将
ui.codelenses
设置为包括gc_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 设置都很有帮助,并且始终建议进行试验。
可以通过修改 /sys/kernel/mm/transparent_hugepage/enabled
在 Linux 环境中启用或禁用透明大页面。有关更多详细信息,请参阅官方 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 或更高版本时遇到内存使用量增加,请尝试应用此设置;它可能会解决您的问题。作为其他解决方法,您可以使用PR_SET_THP_DISABLE
调用Prctl
函数 以在进程级别禁用大页面,或者您可以设置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 成本。总成本可以分解为每个周期的成本,乘以一段时间 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 需要扫描的内存的更近似值,但当可扫描活动堆非常少而活动堆很大时,这会导致退化行为。