[译]Go 垃圾回收指南
in Go with 0 comment

[译]Go 垃圾回收指南

in Go with 0 comment

简介

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

Go语言负责Go中 “值”的存储。在大多数情况下,Go语言开发人员根本不需要关心这些值存储在哪里,或者为什么要存储。 然而,在实践中,这些值通常需要存储在计算机物理内存中,而物理内存是有限的资源。 因为内存是有限的,所以必须小心地管理和回收内存,以避免在执行Go语言程序时耗尽内存。 根据需要分配和回收内存是 Go 负责实现的。

自动回收内存的另一个说法是垃圾回收(garbage collection) 。 从较高的层次上讲,垃圾回收器(简称GC)是通过识别不再需要使用的内存来对应用程序进行内存回收的系统。Go 标准工具链提供了一个随每个应用程序一起提供的运行时库,并且这个运行时库包含一个垃圾收集器。

请注意,Go语言规范并不能保证本指南所描述的垃圾回收器的存在,只不过Go语言值的底层存储由Go语言本身负责管理。 这一省略是有意的,它允许使用完全不同的内存管理技术。

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

此外,这是一个一直在修正的文档,随着时间的推移而变化,以最好地反映Go语言的最新版本。 本文档目前描述的是Go语言1.19中的垃圾回收器。

Go中的值存活在哪里

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

例如,存储在局部变量中的非指针值可能根本不会被Go语言的GC管理,Go语言会安排内存的分配,并将其绑定到创建它的词法作用域中。 一般来说,这比依赖GC更有效率,因为Go语言编译器能够预先确定何时释放内存,并发出清理内存的机器指令。 通常,我们把这种为Go语言的值分配内存的方式称为“栈分配”,因为空间存储在goroutine栈中。

由于 Go 编译器无法确定其生命周期,导致无法以这种方式分配内存的 Go 值被称为“逃逸到堆”。 “堆”可以被认为是内存分配的一个大杂烩,Go语言的值需要被放置在堆的某个地方。 在堆上分配内存的操作通常称为“动态内存分配”,因为编译器和运行时都可以对如何使用该内存以及何时可以清理它做出很少的假设。这就是GC的用武之地:它是一个专门标识和清理动态内存分配的系统。

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

Go语言的值是否逃逸取决于使用它的上下文和Go语言编译器的逃逸分析算法。 当值逃逸时,试图准确地列举它将是脆弱和困难的:算法本身相当复杂,并且在不同的Go语言版本中会有所变化。 有关如何识别哪些值逃逸而哪些值不逃逸的详细信息,请参阅消除堆分配一节。

跟踪垃圾回收

垃圾回收可能指自动回收内存的众多实现方法,例如引用计数。 在本文档的上下文中,垃圾回收指的是跟踪垃圾回收,其通过跟踪指针传递来识别正在使用的、所谓的活动对象。

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

对象和指向其他对象的指针一起形成对象图。 为了识别活动内存,GC从程序的开始遍历对象图,这些指针标识了程序明确在使用的对象。 根的两个例子是局部变量和全局变量。 遍历对象图的过程被称为扫描

此基本算法对所有跟踪GC通用。 跟踪GC的不同之处在于,它们对于发现的存活内存会做什么。 Go语言的GC使用了标记(mark)清扫(sweep)技术,这意味着为了跟踪它的过程,GC也会将它遇到的值标记为存活。 跟踪完成后,GC将遍历堆中的所有内存,并将所有未标记的对象的内存设置为可用于分配的内存。 此过程称为扫描(scanning)

您可能熟悉的另一种技术是将存活对象移动到另一部分新内存中,并留下一个转发指针,以后将使用该指针更新应用程序的所有指针。 我们称以这种方式移动对象的GC为移动GC; Go的GC不是这样子的,它是非移动GC

GC的周期

由于Go GC是一个标记 - 清扫GC,因此它大致分为两个阶段:标记阶段清扫阶段。 虽然这句话似乎是重复的,但它包含了一个重要的见解:在跟踪完所有内存之前,不可能释放内存以供分配,因为可能仍有未扫描的指针使对象保持活动状态。 因此,清扫动作必须与标记动作完全分开。 此外,当没有与GC相关的工作要做时,GC也可能根本不活动。 GC在清扫、关闭、标记这三种状态之间不断循环,这就是所谓的GC周期。在本文档中,将清扫作为GC周期的开始,然后是关闭,标记。

接下来的几个章节我们将集中讨论如何直观地了解GC的成本,以帮助用户调整GC参数,从而提升程序的性能。

了解成本

GC本质上是一个构建在更复杂系统上的复杂软件。 当试图理解GC并调整其行为时,很容易陷入细节的泥潭。 本节旨在提供一个框架,用于说明Go GC的开销和调优参数。

开始讨论前,先了解基于三个简单公理的GC成本模型。

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

  2. GC的内存开销包括存活的堆内存、标记阶段之前分配的新堆内存,以及元数据空间(即使与前两个的开销成比例,但相比之下元数据空间开销也很小)。

    注意:存活的堆内存是由上一个GC周期确定为存活的内存,而新堆内存是在当前周期中分配的任何内存,在标记结束时可能是存活的,也可能不是存活的。

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

    注意:从渐进的角度来说,清扫比标记和扫描要难衡量,因为它必须执行与整个堆的大小成比例的工作,包括被确定为非存活(即“死”)的内存。 然而,在当前的实现中,清扫操作比标记和扫描快得多,因此在本讨论中可以忽略其相关成本。

这种模型简单而有效:它准确地对GC的主要成本进行了分类。 然而,这个模型没有说明这些成本的规模,也没有说明它们是如何相互作用的。 为了对此建模,考虑以下情况,我们称这种场景为稳态(steady-stat)。

注意:稳态可能看起来是人为的,但它的确代表了应用程序在某个恒定工作负载下的行为。 当然,在应用程序运行时,工作负载也可能发生变化,但通常应用程序行为看起来总体上像是一串稳定状态,中间穿插着一些瞬态行为。

注意:稳定状态对存活堆没有任何假设。 它可能会随着每个后续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 的目标是在总堆大小超过目标堆大小之前完成一个收集周期。 总堆大小定义为上一个周期结束时的活动堆大小,加上自上一个周期以来应用程序分配的任何新堆内存。 同时,目标堆内存定义为:

Target heap memory = Live heap + (Live heap + GC roots) * GOGC / 100

举个例子,假设一个Go语言程序,它的存活堆大小为8 MiB,goroutine栈为1 MiB,全局变量中的指针为 1 MiB。 如果GOGC值为100,则在下一次GC运行之前将分配的新内存量将为10 MiB(即 Live heap + GC roots = 10MiB工作量的100%),总堆占用量为18 MiB。 如果GOGC值为50,则它将为50%,即分配的新内存量为5 MiB。 如果GOGC值为200,则为200%,即分配的新内存量20 MiB。

注意:通过GOGC目标堆,仅在Go 1.18以后包含GC roots。 以前,它只会计算存活堆。 通常,goroutine 堆栈中的内存量非常小,并且存活堆大小支配着所有其他 GC 工作源,但是在程序有数十万个 goroutine 的情况下,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(前提是memory limit没有使用)。 从概念上讲,此设置等效于将GOGC设置为无穷大值,因为在触发GC之前新内存的数量是无限的。

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

使用滑块调整GOGC的值,以查看应用程序在总持续时间和GC开销方面的响应情况。 每次GC循环都会在新堆降为零时发生。 X轴移动以始终显示程序的完整CPU持续时间。 请注意,GC使用的额外CPU时间会增加总持续时间。

注 :请移步原文进行体验

img

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

注意:图形显示的是CPU时间,而不是完成程序所需的挂钟时间(wall-clock time)。 如果程序在1个CPU上运行并充分利用其资源,则它们是等效的。 真实的的程序可能运行在多核系统上,并且不会始终100%地利用CPU。 在这些情况下,GC的挂钟时间影响会比较低。

注意:Go GC的最小总堆大小为4 MiB,因此如果GOGC设置的目标值低于该值,则会取整。 这个图形展示反映此细节。

这里有一个动态的和更有真实感的例子。 同样,在没有GC的情况下,应用程序需要10个CPU秒才能完成,但在中途,稳态分配率急剧增加,并且活动堆大小在第一阶段发生了一些变化。 这个示例演示了当活动堆大小实际上发生变化时,稳定状态可能是什么样子的,以及更高的分配率如何导致更频繁的GC周期。

img

内存限制 (Memory limit)

在Go 1.19之前,GOGC是唯一一个可以用来修改GC行为的参数。 虽然它作为一种设置权衡(trade-off)的方式非常有效,但它没有考虑到可用内存是有限的。 考虑当活动堆大小出现短暂峰值时会发生什么情况:因为GC将选择与活动堆大小成比例的总堆大小,所以GOGC必须被配置为峰值活动堆大小相匹配的值,即使在通常情况下,较高的GOGC值会提供了更好的权衡效果。

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

img

如果示例工作负载在可用内存略高于60 MiB的容器中运行,则GOGC不能增加到100以上,即使其余GC周期有可用内存来使用该额外内存。 此外,在一些应用中,这些瞬时峰值可能是罕见的并且难以预测,从而导致偶然的、不可避免的并且可能代价高昂的内存不足情况。

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

这个内存限制设置了Go语言运行时可以使用的最大内存总量。 包含的特定内存集是runtime.MemStatsSys - HeapReleased的值,或者等价于runtime/metrics的公式/memory/classes/total:bytes - /memory/classes/heap/released:bytes

因为Go GC可以显式控制它使用多少堆内存,所以它会根据这个内存限制和Go运行时使用的其他内存来设置总的堆大小。

下面的可视化描述了来自GOGC部分的相同的单阶段稳态工作负载,但这次Go运行时额外增加了10 MiB的开销,并且内存限制可调。 尝试在GOGC和内存限制之间移动,看看会发生什么。

img

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

回到我们前面的瞬态堆峰值的例子,通过设置内存限制并打开GOGC,我们可以获得两全其美的结果:不违反内存限制,且更好地节约资源。 请尝试以下交互式可视化。

img

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

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

现在,虽然内存限制显然是一个强大的工具,但使用内存限制并不是没有代价的,当然也不会使GOGC的实用性失效。

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

这种情况,即程序不断进行GC,存活内存较多,无法进行预期的内存清理,称为系统颠簸(thrashing)。 这是特别危险的,因为它严重地拖延了程序,可能会导致用户程序无法执行。 更糟糕的是,它可能会发生在我们试图避免使用GOGC的情况下:一个足够大临时堆尖峰会导致程序无限期地停止! 尝试在瞬态堆峰值可视化中降低内存限制(大约30 MiB或更低),并注意最坏的行为是如何从堆峰值开始的。

在许多情况下,无限期暂停比内存不足情况更糟,因为后者往往会导致更快的失败以便我们发现和处理。

因此,内存限制被定义为软限制。 Go语言运行时并不保证在任何情况下都能保持这个内存限制;它只承诺了一些合理的努力。 内存限制的放宽对于避免系统颠簸行为至关重要,因为它为GC提供了一条出路:让内存使用超过限制以避免在GC中花费太多时间。

这在内部是如何工作的?GC mitigates 设置了一个在某个时间窗口内可以使用的CPU时间量的上限(对于CPU使用中非常短的瞬时峰值,有一些滞后)。 此限制当前设置为大约50%,具有2 * GOMAXPROCS CPU-second窗口。 限制GC CPU时间的结果是GC的工作被延迟,同时Go程序可能会继续分配新的堆内存,甚至超过内存限制。

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

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

建议用法

虽然内存限制是一个强大的工具,Go语言运行时也会采取措施来减少误用造成的最坏行为,但谨慎使用它仍然很重要。 下面是一些关于内存限制在哪些地方最有用,以及在哪些地方可能弊大于利的建议。

虽然尝试为共享程序“保留”内存是很诱人的,但除非程序完全同步(例如,Go程序在被调用程序执行时调用某些子进程和阻塞),否则结果将不太可靠,因为两个程序都不可避免地需要更多内存。 让Go程序在不需要内存的时候使用更少的内存,总体上会产生更可靠的结果。 此建议也适用于过量使用的情况,在这种情况下,在一台计算机上运行的容器的内存限制之和可能会超过该计算机可用的实际物理内存。

这有效地将内存不足的风险替换为严重的应用程序速度减慢的风险,这通常不是一个有利的交易,即使Go语言努力减轻系统颠簸。 在这种情况下,提高环境的内存限制(然后可能设置内存限制)或降低GOGC(这提供了比系统颠簸缓解更干净的权衡)将更加有效。

延迟时间

到目前为止,本文将应用程序建模在在GC执行时会暂停这一公理上。 确实存在这样的GC实现,它们被称为stop-the-world GC。

然而,Go GC并不是完全停止世界,实际上它的大部分工作都是与应用程序同时进行的。 这样做的主要原因是它减少了应用程序延迟。 具体来说,延迟是指单个计算单元(例如,web请求)的端到端持续时间。

到目前为止,本文主要考虑应用程序吞吐量,或这些操作的聚合(例如,每秒处理的web请求)。 请注意,GC周期部分中的每个示例都侧重于执行程序的总CPU持续时间。 然而,这样的持续时间对于例如web服务来说意义要小得多。 虽然吞吐量(即每秒的查询数)对于web服务仍然很重要,但通常每个单独请求的延迟甚至更重要。

就延迟而言,stop-the-world GC可能需要相当长的时间来执行其标记和扫描阶段,在此期间,应用程序以及在web服务的上下文中的任何正在进行的请求都无法取得进一步的进展。 相反,Go GC确保了任何全局应用程序暂停的时间都不会以任何形式与堆的大小成比例,并且在应用程序主动执行的同时执行核心跟踪算法(暂停在算法上与 GOMAXPROCS 成比例更大,但最常见的是停止运行 goroutine 所需的时间)。 这种选择并非没有成本,因为在实践中,它往往会导致吞吐量较低的设计,但需要注意的是,较低的延迟并不一定意味着较低的吞吐量,并且随着时间的推移,Go 垃圾收集器的性能在延迟和吞吐量方面都在稳步提高。

到目前为止,Go 的当前 GC 的并发性质并未使本文档中讨论的任何内容无效:没有任何陈述依赖于这种设计选择。 GC 频率仍然是 GC 在 CPU 时间和内存之间权衡吞吐量的主要方式,事实上,它也扮演着延迟的角色。 这是因为 GC 的大部分成本都是在标记阶段处于活动状态时产生的。

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

然而,理解延迟通常比理解吞吐量更复杂,因为它是程序即时执行的产物,而不仅仅是成本的聚合之物。 因此,延迟和GC频率之间的联系更加脆弱,可能不那么直接。 下面是一个可能导致延迟的来源列表,供那些倾向于深入研究的人使用。

这些延迟源在trace中可见,除了需要额外工作的指针写入。

其他资源

虽然上面提供的信息是准确的,但它缺乏充分理解Go GC设计中的成本和权衡的细节。 有关详细信息,请参阅以下其他资源。

关于虚拟内存注意事项

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

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

由于虚拟内存只是操作系统维护的映射,因此保留不映射到物理内存的大型虚拟内存的成本通常非常小。

Go语言运行时通常在以下几个方面依赖于这种虚拟内存开销视图:

因此,虚拟内存指标,比如top中的“VSS”,在理解Go语言程序的内存占用方面通常不是很有用。 相反,应该关注“RSS”和类似的度量,它们更直接地反映了物理内存的使用情况。

优化指南

确定成本

在尝试优化Go语言应用程序与GC的交互方式之前,首先确定GC是一个主要的开销,这一点很重要。

Go生态系统提供了大量的工具来识别成本和优化Go应用程序。 有关这些工具的简要概述,请参阅诊断指南。 在这里,我们将重点讨论这些工具的一个子集,以及应用它们的合理顺序,以便理解GC的影响和行为。

1、CPU profile

优化程序的一个很好的起点是CPU profiling。 CPU profiling提供了CPU时间花费在何处的概述,尽管对于没有经验的人来说,可能很难确定 GC 在特定应用中的开销。 幸运的是,理解profile的GC主要归结为了解runtime包中不同函数的含义即可。 以下是这些函数中用于解释CPU profile文件的有用子集。

注意:下面列出的函数不是叶子函数,因此它们可能不会显示在pprof工具为top命令提供的默认值中。 相反,使用top cum命令或直接对这些函数使用list命令,并将注意力集中在累计百分比列上。

注意:在一个大部分时间都处于空闲状态的Go应用程序中,Go GC会消耗额外的(空闲的)CPU资源来更快地完成任务。 结果,该函数可以表示它认为是免费采样部分。 发生这种情况的一个常见原因是,应用程序仅在一个 goroutine 中运行,但 GOMAXPROCS 大于 1。

2、trace

虽然CPU profile文件非常适合用于确定时间在聚合中的花费点,但对于指示更细微、更罕见或与延迟具体相关的性能成本,它们的用处不大。 另一方面,trace提供了Go语言程序执行的一个短窗口的丰富而深入的视图。 它们包含了与Go GC相关的各种事件,可以直接观察到具体的执行路径,沿着应用程序与Go GC的交互方式。 所有被跟踪的GC事件都在跟踪查看器中被方便地标记为GC事件。

有关如何开始使用执行trace的信息,请参阅 runtime/trace 的文档。

3、GC Trace

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

通过设置GODEBUG=gctrace=1,可以启用GC Trace。 此跟踪生成的输出记录在runtime包文档的环境变量部分中。

一个称为pacer trace的技术用来补充GC跟踪,提供了更深入的见解,它通过设置GODEBUG=gcpacertrace=1来启用。 解释这个输出需要理解GC的pacer(参见其他参考资料),这超出了本指南的范围。

消除堆分配

降低GC成本的一种方法是让GC管理较少的值。 下面描述的技术可以带来一些最大的性能改进,因为正如GOGC部分所展示的,Go语言程序的分配率是GC频率的一个主要因素,GC频率是本指南使用的关键成本度量。

Heap profiling

在确定GC是一个巨大开销的来源之后,消除堆分配的下一步是找出它们中的大多数来自哪里。 为此,memory profiles 文件(实际上是堆内存 profile 文件)非常有用。 请查看文档以了解如何开始使用它们。

内存 profile 文件描述了程序堆中分配的来源,并通过分配时的堆栈跟踪来标识它们。 每个内存 profile 文件可以按四种方式分析:

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

注意:默认情况下,内存 profile 文件只对堆对象的子集进行采样(间隔采样),因此它们不会包含有关每个堆分配的信息。 但是,这足以找到热点。 若要更改采样率,请参见runtime.MemProfileRate

为了降低GC成本,alloc_space通常是最有用的视图,因为它直接对应于分配率。 此视图将指示可提供最大益处的分配热点。

逃逸分析

一旦在Heap profile 文件的帮助下确定了堆分配热点,如何消除它们? 关键是要利用Go语言编译器的逃逸分析,让Go语言编译器为这个内存找到替代的、更有效的存储空间,比如在goroutine栈中。 幸运的是,Go语言编译器能够描述为什么要将Go语言的值逃逸到堆中。 有了这些知识,就变成了重新组织源代码以改变分析结果的问题(这通常是最困难的部分,但超出了本指南的范围)。

至于如何从Go语言编译器的逃逸分析中获取信息,最简单的方法是通过Go语言编译器支持的调试标志,该标志以文本格式描述了对某个包应用或未应用的所有优化。 这包括值是否逃逸。 尝试下面的命令,其中package是Go语言包的路径:
$go build -gcflags=-m=3 [package]

此信息也可以在 VS Code 中可视化为覆盖图。 此覆盖在VS Code Go插件设置中配置和启用:

最后,Go编译器以机器可读(JSON)格式提供了这些信息,可以用来构建其他定制工具。 有关这方面的更多信息,请参见Go语言源代码中的文档

基于特定实现的优化

Go GC对存活内存的统计很敏感,因为对象和指针的复杂图既限制了并行性,又为GC生成了更多的工作。 因此,GC包含了一些针对特定公共结构的优化。 下面列出了对性能优化最直接有用的方法。

注意:应用下面的优化可能会因为混淆意图而降低代码的可读性,并且可能无法在Go语言的各个版本中保持。 希望只在最重要的地方应用这些优化。 可以使用确定成本一节中列出的工具来确定这些地点。

因此,依赖于索引而不是指针值的数据结构虽然类型不太好,但性能可能会更好。 仅当对象图很复杂并且 GC 花费大量时间标记和扫描时,才值得这样做。

因此,将结构体类型中的指针字段放在开头可能是有利的。 只有当应用程序花费大量时间进行标记和扫描时,才值得这样做。 (理论上,编译器可以自动执行此操作,但尚未实现,并且结构字段的排列方式与源代码中所写的相同。)

此外,GC必须与它所看到的几乎每个指针交互,因此,例如,使用切片中的索引而不是指针,可以帮助降低GC成本。

附录

关于 GOGC 的附加说明

GOGC 部分声称,将 GOGC 翻倍会使堆内存开销翻倍,并将 GC CPU 成本减半。 要了解原因,让我们在数学上对其进行分解。

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

Target heap memory = Live heap + (Live heap + GC roots) * GOGC / 100

Total heap memory = Live heap + New heap memory
⇒
New heap memory = (Live heap + GC roots) * GOGC / 100

由此我们可以看到,将 GOGC 翻倍也会使应用程序在每个周期分配的新堆内存量(New heap memory)翻倍,这会加大堆内存开销。 请注意,Live heap + GC roots 是 GC 需要扫描的内存量的近似值。

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

Total GC CPU cost = (GC CPU cost per cycle) * (GC frequency) * T
总开销 = 单次GC开销 * 频率 * 时间

每个周期的 GC CPU 成本可以从 GC 模型中得出:

GC CPU cost per cycle = (Live heap + GC roots) * (Cost per byte) + Fixed cost

存活对象数 * 扫描每字节开销 + 固定开销

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

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

GC frequency = (Allocation rate) / (New heap memory) = (Allocation rate) / ((Live heap + GC roots) * GOGC / 100)

把这些放在一起,我们得到了总成本的完整方程:

Total GC CPU cost = (Allocation rate) / ((Live heap + GC roots) * GOGC / 100) * ((Live heap + GC roots) * (Cost per byte) + Fixed cost) * T

对于足够大的堆(代表大多数情况),GC 周期的边际成本支配着固定成本。 这可以显着简化 GC CPU 总成本公式。

Total GC CPU cost = (Allocation rate) / (GOGC / 100) * (Cost per byte) * T

从这个简化的公式中,我们可以看到,如果我们将 GOGC 翻倍,我们将总 GC CPU 成本减半。 (请注意,本指南中的可视化确实模拟了固定成本,因此当 GOGC 翻倍时,它们报告的 GC CPU 开销不会完全减半。)此外,GC CPU 成本在很大程度上取决于分配率和扫描内存的每字节成本。 有关如何具体降低这些成本的更多信息,请参阅优化指南。

注意:存活堆的大小和 GC 实际需要扫描的内存量之间存在差异:相同大小的活动堆但具有不同的结构会导致不同的 CPU 成本,但内存成本相同 ,可能导致不同的权衡。 这就是为什么堆的结构是稳态定义的一部分。 堆目标可以说应该只包括可扫描的活动堆,作为 GC 需要扫描的内存的更接近的近似值,但是当可扫描的活动堆数量非常少但活动堆很大时,这会导致退化行为。

参考文章

https://tip.golang.org/doc/gc-guide

https://colobu.com/2022/07/16/A-Guide-to-the-Go-Garbage-Collector/