1、介绍
本指南旨在通过提供对Go垃圾收集器的深入了解,帮助高级Go用户更好地理解他们的应用程序成本。它还提供了Go用户如何使用这些见解来提高应用程序的资源利用率的指导。它不要求您了解垃圾收集,但要求您熟悉Go编程语言。
Go语言负责安排Go值的存储;在大多数情况下,Go开发者不需要关心这些值存储在哪里,或者为什么存储。然而,在实践中,这些值通常需要存储在计算机物理内存( physical memory)
中,而物理内存是有限的资源。由于内存是有限的,必须仔细管理和回收内存,以避免在执行Go程序时耗尽内存。根据需要分配和回收内存是Go实现的工作。
自动回收内存的另一个术语是垃圾回收(garbage collection
)。在较高的级别上,垃圾收集器(或简称GC
)是一种系统,它通过识别不再需要的内存部分来代表应用程序回收内存。Go标准工具链提供了一个随每个应用程序附带的运行时库(runtime library),这个运行时库包括一个垃圾收集器。
请注意,本指南中所描述的垃圾收集器的存在并不是由Go规范保证的,仅仅是语言本身管理的Go值的底层存储。这种省略是有意的,可以使用完全不同的内存管理技术。
因此,本指南是关于Go编程语言的特定实现,可能不适用于其他实现。具体来说,以下指南适用于标准工具链(gc Go编译器和工具)。Gccgo
和Gollvm
都使用非常相似的GC实现,因此应用了许多相同的概念,但细节可能有所不同。
此外,这是一个动态文档,将随着时间的推移而更改,以最好地反映Go的最新版本。本文档目前描述的是Go 1.19版本的垃圾收集器。
2、Go 值存在什么地方
在深入讨论GC之前,让我们首先讨论一下不需要由GC管理的内存。
例如,存储在局部变量中的非指针Go值可能根本不受Go GC的管理
,而Go将安排分配创建在它的词法作用域( lexical scope)内相关的内存。一般来说,这比依赖GC更有效,因为Go编译器能够预先确定何时释放内存并发出清理内存的机器指令。通常,我们将这种为Go值分配内存的方法称为“栈分配(stack allocation
)”,因为空间存储在goroutine栈上。
不能以这种方式分配内存的Go值(因为Go编译器不能确定其生存期)被称为逃逸到堆上(escape to the heap
)。“堆”可以被认为是全方位的(catch-all
)内存分配,用于需要将Go值放在某个地方的时候。在堆上分配内存的行为通常被称为“动态内存分配(dynamic memory allocation
)”,因为编译器和运行时都不能对如何使用内存以及何时清理内存做出很少的假设。这就是GC的用武之地:它是一种专门识别和清理动态内存分配的系统。这就是GC的用武之地:它是一种专门识别和清理动态内存分配的系统。
Go值需要逃逸到堆的原因有很多。原因之一可能是它的大小是动态决定的。例如,考虑一个切片的后备数组,其初始大小是由一个变量决定的,而不是一个常数。注意,逃逸到堆也必须是可传递的(transitive):如果对一个Go值的引用被写入另一个已经确定要转义的Go值,那么这个值也必须逃逸。
Go值是否逃逸是使用它的上下文和Go编译器的逃逸分析算法的函数决定的。试图精确地枚举值何时逸出是非常脆弱和困难的:算法本身相当复杂,并且在Go版本之间会发生变化。有关如何确定哪些值可以转义,哪些值不能转义的详细信息,请参见评估堆分配一节。
3、跟踪垃圾收集
垃圾回收可以有许多不同的自动回收内存的方法;例如,引用计数(reference counting
)。在本文档的上下文中,垃圾收集指的是跟踪(tracing
)垃圾收集,它通过传递性地跟踪指针来标识正在使用的所谓活动(live
)对象。
让我们更严格地定义这些术语。
Object
对象是动态分配的内存块,其中包含一个或多个Go值。Pointer
引用对象内任意值的内存地址。这自然包括了形式为*T
的Go值,但也包括了部分内置的Go值。字符串、切片、通道、映射和接口值都包含GC必须跟踪的内存地址。
对象和指向其他对象的指针一起构成了对象图(object graph
)。为了识别活动内存,GC从程序的根(roots
)开始遍历对象图,指向标识程序确实正在使用的对象。根
的两个例子是局部变量和全局变量。遍历对象图的过程称为扫描(scanning
)。
这个基本算法对所有跟踪GC都是通用的。追踪GC的不同之处在于,当他们发现内存是活动的时,他们会做什么。Go的GC使用标记-清除(mark-sweep)技术,这意味着为了跟踪其进程(progress),GC还将它遇到的值标记(marks
)为活动值。跟踪完成后,GC然后遍历堆中的所有内存,并使所有未标记的内存可用来分配。这个过程叫做清扫(sweeping
)。
您可能熟悉的另一种技术是实际将对象移动(move
)到内存的新部分,并留下一个转发指针,稍后用于更新应用程序的所有指针。我们称以这种方式移动对象的GC为移动GC(moving GC
);Go有一个不移动( non-moving
)的GC。
4、GC周期 (GC cycle
)
因为Go GC是标记-清除GC,所以它大体上分为两个阶段:标记阶段和清除阶段。虽然这句话似乎是重复的,但它包含了一个重要的见解:在跟踪所有内存之前,不可能把内存释放再用来分配,因为可能仍然有一个未扫描的指针保持对象的活动。因此,清扫的行为必须与标记的行为完全分开。 此外,当没有与GC相关的工作要做时,GC也可能根本不活动。GC在清除、停止和标记这三个阶段中不断地循环,这就是所谓的GC周期。出于本文档的目的,请考虑GC循环从清除开始、停止、然后标记。
接下来的几节将重点介绍如何直观地了解GC的成本(costs),以帮助用户根据自己的情况调整GC参数。
理解成本(costs)
GC本质上是一个构建在更复杂系统上的复杂软件。在尝试理解GC并调整其行为时,很容易陷入细节中。本节旨在提供一个框架,用于推断Go GC的成本和调优参数。
首先,考虑基于三个简单维度的这种GC成本模型。
- 1 GC只涉及两个资源: CPU时间 和 物理内存。
- 2 GC的内存成本包括活动堆内存、标记阶段开始之前分配的新堆内存以及元数据空间,即使与之前的成本成正比,相比之下也很小。
注意:活动堆内存是由前一个GC周期确定为活动的内存,而新的堆内存是当前周期中分配的任何内存,到最后可能是活动的,也可能不是。
- 3 GC的CPU成本建模为每个周期的固定成本,以及随活动堆大小成比例缩放的边际成本。
注意:渐进地说,清除比标记和扫描更糟糕,因为它必须执行与整个堆的大小成比例的工作,包括被确定为非活动的(即
dead
)内存。然而,在当前的实现中,清除要比标记和扫描(marking and scanning
)快得多,因此在本讨论中可以忽略其相关成本。
这个模型简单但有效:它准确地分类了GC的主要成本。然而,这个模型没有说明这些成本的规模,也没有说明它们是如何相互作用的。为了建立模型,考虑下面的情况,从这里开始称为稳态(steady-state
)。
- 应用程序分配新内存的速率(以字节每秒为单位)是恒定的。
注意:重要的是要理解这个分配率完全独立于这个新内存是否是活动的。没有一个是活的,所有都是活的,或者部分是活的。(在此基础上,一些旧的堆内存也可能死亡,因此,如果该内存是活动的,则活动堆大小不一定会增长。)
更具体地说,假设一个web服务为它处理的每个请求分配了2mib的总堆内存。在请求期间,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 frequency
)决定。
还有一个细节需要定义,这就是GC决定启动的时间。注意,这直接在任何特定的稳定状态下设置GC频率,定义了权衡。在Go中,决定何时开始GC是用户可以控制的主要参数。
5、GOGC
在较高的级别上,GOGC
决定GC CPU和内存之间的权衡。
它的工作原理
是在每个GC周期之后确定目标堆大小,这是一个下一个周期中总堆大小的目标值。GC的目标
是在总堆大小超过目标堆大小之前完成收集周期。总的堆大小定义为上一个周期结束时的活动堆大小,加上应用程序自上一个周期以来分配的任何新堆内存。同时,目标堆内存定义为:
Target heap memory = Live heap + (Live heap + GC roots) * GOGC / 100
例如,考虑一个Go程序,其活堆大小为8mib, goroutine堆栈大小为1mib,全局变量指针大小为1mib。然后,如果GOGC值为100,那么在下一次GC运行之前将分配的新内存量将为10mib,或10mib工作的100%,总的堆占用为18mib。如果GOGC值为50,那么它将是50%,或5 MiB。当GOGC值为200时,它将是200%,或20 MiB。
注意:从Go 1.18开始,GOGC只包含根集。以前,它只计算活动堆。通常,goroute例程堆栈中的内存数量非常小,并且活动堆大小主导了GC工作的所有其他来源,但在程序有数十万个goroute例程的情况下,GC做出的判断很差。
堆目标控制GC频率:目标越大,GC可以等待启动另一个标记阶段的时间越长,反之亦然。虽然精确的公式对于进行估计很有用,但最好从其基本目的来考虑GOGC
:一个在GC CPU和内存权衡中选择一个点的参数。关键是GOGC
翻倍将使堆内存开销翻倍,GC CPU成本大约减半,反之亦然。(详细解释见附录。)
注意:目标堆大小只是一个目标,有几个原因可以解释为什么GC周期不能在目标处正确结束。首先,足够大的堆分配可能会超出目标。然而,在GC实现中出现的其他原因超出了本指南迄今为止使用的GC模型。有关更多详细信息,请参阅延迟部分,但完整的详细信息可以在其他资源中找到。
GOGC
可以通过GOGC
环境变量(所有Go程序都可以识别)进行配置,也可以通过runtime/debug
试包中的SetGCPercent
API进行配置。
注意,通过设置GOGC=off
或调用SetGCPercent(-1)
,也可以使用GOGC
完全关闭GC(如果内存限制不适用)。从概念上讲,这个设置相当于将GOGC
设置为无穷大,因为触发GC之前的新内存数量是无限的。
6、内存限制
在Go 1.19之前,GOGC
是唯一可以用来修改GC行为的参数。虽然作为一种设置权衡的方式,它工作得很好,但它没有考虑到可用内存是有限的。考虑一下当活动堆大小出现瞬时峰值时会发生什么:因为GC将选择与活动堆大小成比例的总堆大小,所以必须为活动堆大小峰值配置GOGC
,即使在通常情况下,更高的GOGC
值提供了更好的权衡。
如果示例工作负载运行在一个可用内存略多于60mib的容器中,那么GOGC不能增加到超过100,即使剩余的GC周期有可用内存来使用额外的内存。此外,在某些应用程序中,这些短暂的峰值可能很罕见且难以预测,从而导致偶尔出现的、不可避免的、可能代价高昂的内存不足情况。
这就是为什么在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运行时会采取措施减轻误用造成的最坏行为,但仔细使用它仍然很重要。下面是一组关于内存限制在哪些地方最有用和最适用,以及在哪些地方弊大于利的小建议。
- Do 当你的Go程序的执行环境完全在你的控制范围内,并且Go程序是唯一可以访问某些资源集(即某种内存预留,如容器内存限制)的程序时,就可以利用内存限制。
7、延迟
本文档中的可视化显示将应用程序建模为当GC执行时,应用程序暂停。GC实现确实存在这样的行为,它们被称为“stop-the-world”GC。
然而,Go GC并不是完全stop-the-world
,它的大部分工作都是与应用程序并发的。这主要是为了减少应用程序延迟(latencies
)。具体来说,是单个计算单元(例如web请求)的端到端持续时间。到目前为止,本文档主要考虑了应用程序吞吐量(throughput
)(例如每秒处理的web请求)。注意,GC周期部分中的每个示例都关注正在执行的程序的总CPU持续时间。然而,这样的持续时间对于web服务来说意义不大。虽然吞吐量对web服务来说仍然很重要(即每秒查询次数),但通常每个请求的延迟更重要。
就延迟而言,stop-the-world GC可能需要相当长的时间来执行标记和清除阶段,在此期间,应用程序以及web服务上下文中的任何正在运行的请求都无法取得进一步进展。相反,Go GC避免使任何全局应用程序暂停的长度与堆的大小成比例,并且在应用程序正在运行时执行核心跟踪算法(the core tracing algorithm)。(暂停时间在算法上与GOMAXPROCS
的比例更大,但最常见的是由停止运行goroutine所花费的时间所决定。)并发收集并不是没有成本的:在实践中,相比等效的“停止世界”垃圾收集器,它通常会导致低吞吐量的设计。然而,需要注意的是,更低的延迟并不意味着更高的吞吐量,Go垃圾收集器的性能随着时间的推移稳步提高,无论是延迟还是吞吐量。
Go当前GC的并发特性并没有使本文中讨论的任何内容无效:没有一个语句依赖于这种设计选择。GC频率仍然是GC在CPU时间和内存之间进行吞吐量权衡的主要方式,事实上,它还承担了延迟的角色。这是因为GC的大部分成本是在标记阶段活动时发生的。
8、优化指南
8.1 鉴别成本
在尝试优化Go应用程序与GC的交互方式之前,首先要确定GC是一个主要成本,这很重要
Go生态系统提供了许多用于识别成本和优化Go应用程序的工具。有关这些工具的简要概述,请参阅诊断指南。在这里,我们将重点关注这些工具的一个子集以及应用它们的合理顺序,以理解GC的影响和行为。
CPU profiles
CPU分析(CPU profiling)是一个很好的开始。CPU分析提供了CPU时间花费位置的概述,但对于未经训练的人来说,可能很难确定GC在特定应用程序中所扮演的角色的大小。幸运的是,理解GC如何适应主要归结为了解runtime
包中不同函数的含义。下面是这些函数的一个有用子集,用于解释CPU配置文件。
注意:下面列出的函数不是叶函数,所以它们可能不会出现在pprof工具提供的top命令的默认值中。相反,可以直接在这些函数上使用
top -cum
命令或list
命令,并关注累积百分比列。
runtime.gcBgMarkWorker
专用标记工作程序的入口点。在这里花费的时间与GC频率、对象图的复杂性和大小有关。它表示应用程序标记和扫描所花费的时间的基线。
注意:在一个大部分空闲的Go应用程序中,Go GC将使用额外的(空闲的)CPU资源来更快地完成工作。因此,这个符号可能代表了很大一部分样本,它认为这些样本是免费的。可能发生这种情况的一个常见原因是,应用程序完全运行在一个goroutine中,但GOMAXPROCS是>1。
runtime.mallocgc
堆内存分配器的入口点。在这里花费的大量累积时间(>15%)通常表明分配了大量内存。
runtime.gcAssistAlloc
函数goroute例程进入,以腾出一些时间来协助GC进行扫描和标记。在这里花费的大量累积时间(>5%)表明应用程序在分配速度方面可能超过了GC。它表示GC的影响程度特别高,也表示应用程序花在标记和扫描上的时间。注意,这包含在runtime.mallocgc
调用树,它也会膨胀。
执行的追踪
虽然CPU分析文件对于确定总的时间花费位置非常有用,但对于指示更微妙、很少或与延迟具体相关的性能成本来说用处不大。另一方面,执行的跟踪(Execution traces)为Go程序的执行提供了一个丰富而深入的视图。它们包含与Go GC相关的各种事件,可以直接观察到特定的执行路径,以及应用程序可能如何与Go GC交互。在跟踪查看器中,跟踪的所有GC事件都被方便地这样标记。
关于如何开始执行跟踪,请参阅 runtime/trace 包的文档。
GC traces
当所有这些都失败时,Go GC提供了一些不同的特定跟踪,可以更深入地了解GC行为。这些跟踪总是直接打印到STDERR,每个GC周期一行,并通过所有Go程序都能识别的GODEBUG
环境变量进行配置。它们主要用于调试Go GC本身,因为它们需要熟悉GC实现的细节,但偶尔也有助于更好地理解GC行为。
通过设置 GODEBUG=gctrace=1
,可以启用核心GC跟踪。此跟踪产生的输出记录在运行时包文档的环境变量部分中。
补充的GC跟踪称为“步行者跟踪(pacer trace)”,可以提供更深入的见解,可以通过设置 GODEBUG=gcpacertrace=1
来启用。解释此输出需要理解GC的“步行者”(请参阅其他参考资料),这超出了本指南的范围。
附加资源
虽然上面提供的信息是准确的,但它缺乏充分理解Go GC设计中的成本和权衡的细节。有关更多信息,请参阅下面的其他参考资料。
The GC Handbook
一个关于垃圾收集器设计的优秀的通用资源和参考。
TCMalloc
C/C++ 内存分配器TCMalloc的设计文档,Go内存分配器是基于它的。
Go 1.5 GC公告
博客文章宣布Go 1.5 并发GC,其中更详细地描述了算法。
Getting to Go
深入介绍Go的GC设计到2018年的演变。
Go 1.5 并发GC pacing
用于确定何时开始并发标记阶段的设计文档。
Smarter scavenging聪明的清除
修改Go运行时向操作系统返回内存的方式的设计文档。
可伸缩页面分配器
修改Go运行时管理它从操作系统获得的内存的方式的设计文档
GC pacer重新设计(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”和类似的度量,它们更直接地反映物理内存的使用情况。