Go语言垃圾回收指南
涛叔本文是 Go 语言官方文档 A Guide to the Go Garbage Collector 的中译本。我之前尝试总结 Go 语言 GC 相关问题。在准备资料的过程中我发现官方手册基本上是最系统的学习资料了,所以尝试将其译成中文。当今时代机器翻译已经越来智能, Go 语言开发者也大都具备英语阅读能力。在这个背景下做翻译,更多是帮自己加深理解,顺便也提高一下英语水平😄如果能帮助到大家,那更是善莫大焉。
个人能力有限,错误再所难免,还请读者不吝赐教。以下是翻译正文。
简介
本指南会深入讨论 Go 语言垃圾回收器的工作原理1,旨在帮助高级 Go 开发者更好地理解应用程序所使用的资源成本2。本文还会指导 Go 开发者利用这些原理来提升应用程序的资源使用效率。读者阅读本指南不需要具备垃圾回收相关知识,但一定要熟悉 Go 语言程序设计。
Go 语言负责为变量3安排存储空间。在大多数情况下, Go 开发者不需要关心值的内容在哪里保存或者为什么要保存到这里(如果有的话)。但实践中,这些值需要保存到计算机的物理内存,而且物理内存是一种有限资源。因为其有限,所以 Go 程序在运行的时候需要小心翼翼地管理和回收内存,这样才不会在执行过程中耗光所有内存资源。按需分配和回收内存这部分工作正是由 Go 语言实现来完成的。
自动回收内存的另一种术语是垃圾回收。从很高的视角来看,垃圾回收器(或者简称GC)是一种内存回收系统,它代表应用程序来识别内存中什么部分已经不再使用。 Go 标准工具链会提供一组运行时库,它们会随所有应用程序一同发布。 Go 运行时库里就包含了垃圾回收器。
注意,Go语言规范不能确保本手册所描述的垃圾回收器一定存在。语言规范只定义了变量在底层的存储方式。为了利用各种激进的内存管理技术,语言规范有意省略了这部分内容。
所以说,本指南的内容仅限于 Go 语言特定的实现版本,可能不适用于其他版本的实现。特别地,本指南仅适用于标准工具链(gc
4编译器和工具)。 Gccgo 和 Gollvm 的 GC 实现很相似,很多概念也都适用,但某些细节可能有差异。
此外,这是一个动态文档,将随着时间的推移不断更新,以最好地反映 Go 的最新版本。本文当前描述的是 Go 1.19 的垃圾回收器。
变量的存储位置
在深入讨论 GC 之前,我们先讨论一下有哪些内存不需根 GC 来管理。
比如说,非指针类型的局部变量就几乎不需要 GC 来处理。相反,Go 在为变量分配内存的时候会关联到创建它们的词法作用域。一般来说,这种处理方式比依赖 GC 回收效率要更高,因为 Go 编译器可以预先确定在什么时候释放内存并且会生成清理内存的机器指令。我们也这种内存分配方式称之为「栈分配」,因为所以数据都保存在协程的栈内存上。
如果编译器无法确定变量的生命周期,就不能使用上述方式分配内存。这种情况称为逃逸到堆上。 “堆” 可以被视为内存分配的杂物间,当变量需要分配内存的时候就到堆里找。对于在堆上分配的内存,编译器和运行时都很难假设什么时候会被用到以及什么时候可以清理。所以在堆上分配内存又被称为「动态内存分配」。这正是需要 GC 的原因所在:它是专门用于识别和清理通过动态方式分配的内存的系统。
变量需要逃逸到堆上的原因有很多。其中之一是变量占用的内存大小需要动态确定。试想这样的例子,切片底层是数组,它的长度不是常量,而是取决于另外一个变量。注意,堆逃逸会传染:如果变量逃逸到了堆上,它引用的变量也必须逃逸。
变量是否需要逃逸取决于使用该变量的上下文环境以及编译器的逃逸分析算法。很难准确罗列出所有变量需要逃逸的场景,因为算法本身非常复杂,而且会随着新版本发布不断变化。更多关于识别哪些变量需要逃逸,哪些不需的细节,请参考消除堆分配一节。
追踪式垃圾回收
垃圾回收可能指代多种不同的自动内存回收方式,例如引用计数技术。在本文中,垃圾回收特指追踪式5回收技术。该技术会通过传递式地跟踪指针跟踪来识别正在使用的对象,也就是所谓的活动6对象。
我们给出这些术语的严格定义。
- 对象 - 对象指动态分配的内存,存储一个或者多个变量
- 指针 - 指针指对象中变量的内存地址。显然指针包括类型为
*T
的变量。但指针还包括一些内置类型的变量。字符串、切片、通道、字典和接口变量都含有内存地址,GC 必须追踪它。
对象和指向其他对象的指针一起构成了所谓的对象图7。我们把指向程序正在使用的对象的指针称为根对象8。为了识别活动内存,GC 会从程序的根对象开始遍历对象图。局部变量和全局变量就是根对象的两个典型示例。遍历的过程称之为扫描9。
追踪式 GC 的基础算法都差不多。不同点在于发现活动内存后的后续操作。 Go 语言的 GC 采用的是标记-清除10技术。为了追踪扫描进度,该技术会给遇到的活动内存打上标记。扫描一但完成,GC 就会遍历堆上的所有内存并把没有标记的内存设置成可用内存。这一过程称之为清除。
你可能熟悉另外一种垃圾回收技术,它实际上会把活动对象移到新的内存区域并把新地址保存到对象 forwarding 指针字段,然后再根据该指针更新程序内所有的指针。我们称这种会移动对象的 GC 为移动式11 GC。但 Go 用的是非移动式 GC。
GC 循环
因为 Go GC 是标记-清除式 GC,所以它操作大致会分成两个阶段:标记阶段和消除阶段。这句话这看起来像是废话,但它却蕴藏了一条重要的见解12:因为在没有扫描的内存中可能有指针指向当前活动内存,释放垃圾内存必须等所有内存都扫描完成后才能执行。所以清除动作与标记动作必须完全分离。此外,在没有相关任务的时候,GC 必须暂停。 GC 不断地在清除、暂停和标记这三个阶段中来回切换,这就是 GC 循环。就本文件而言,我们可以认为 GC 循环从清除开始,然后是暂停,再然后是标记。
为了帮助开发者对 GC 参数做调优,接下来的几节将着重于帮助读者建立对 GC 成本的直觉。
理解 GC 成本
GC 本质上是一种很复杂的软件,而且是基于更加复杂的系统构建的。在尝试理解和调试 GC 的过程,大家很容易陷入到细节中去。本节尝试提供一个框架,用于推导 Go 语言 GC 的成本以及调整各参数的成本。
首先,假设 GC 成本模型基于如下三条简单公理。
GC 过程仅涉及两种资源:CPU时间和物理内存。
GC 的内存成本包括:活动堆内存13、标记阶段开始前新分配的堆内存14,以及元信息占用的内存。虽然元信息占用的内存跟前面两部分内存的数量成正比,但比较而言总量很小。
注意:活动堆内存指的是上一次 GC 循环识别到的活动内存。而新分配的堆内存指的是在当前 GC 循环中分配的内存,在当前循环结束后,这些内存可能还是活动内还,也可能不是活动内存。
模型假设每次 GC 循环的的 CPU 消耗是固定的,而且边际成本15与活动堆内存的大小成正比。
注意:渐进地讲16,消除阶段跟标记和扫描阶段相比要消耗更多的内存,因为这个阶段的工作量跟整个堆内存成正比,还包括被识别为垃圾的内存(也就是死内存)。然而,在当前实现中,跟标记和扫描阶段相比,消除阶段很快就结束了,所以接下来的讨论会忽略与该阶段成本。
这模型很简化,却非常有效:它可以精确地归纳出 GC 的主要成本。然而,这个模型确实不能展示这些成本的规模以及它们之间的相互作用。为了描述这些内容,考虑下面这种情况,从现在开始我们称之为稳定态17。
应用程序分配内存的速率(字节/秒)是常量。
注意:我需要理解很重要的一点,即这里的内存分配速率跟分配后的内存是否为活动内存没有任何关系。分配后的内存可能全是非活动内存,也可能全都是活动内存,还可能一部分是活动内存。(基于此,旧的堆内存也可能被回收,所以并非一分配内存活动堆内存就会增长。)
举个具体的例子。假设有一个 web 服务,每次处理请求都会分配 2 MiB 的堆内存。在请求过程中,这 2 MiB 内存最多有 512 Kib 是活动的。当服务处理完该请求后,所有内存都会释放。现在,为了简单起见,假设每次请求从开始到结束需要一秒钟的时间。然后,比如每秒 100 次请求的稳定请求量,会导致 200 MiB/s 的内存分配速率,而且堆活动内存的峰值会达到 50 MiB。
应用程序的对象图每一次都大致相同(对象的大小差不多,指针的数量也大致恒定,对象图的层数也大致相同)。
换个角度理解就是 GC 的边际成本是常量。
注意:上述稳定态看似是人为杜撰出来的,但它可以表示程序在特定负载下的工作情况。当然了,在程序运行期间负载会不断变化,但典型程序的行为可以看成是在一系列不同稳定态之间的来回切换。
注意:稳定态有对活动堆内存做任何假设。每次 GC 循环结束后,活动堆内存可能增长,也可能收缩,还可能不变。但同时分析这三类情况非常繁琐,而且也很难讲明白。所以本指南以堆活动内存保持不变为例进行分析。 GOGC 小节 会介绍一些关于堆活动内存出现变化的场景。
在稳定态中,堆活动内存是常数,只要以相同的时间间隔执行 GC,每次 GC 循环的消耗模型看起来就不会有差别。这是因为时间是固定的,程序的内存分配速率也是固定的,程序只会分配固定数量的堆内存。如果堆活动内存是常量,而且新分配的堆内存也是常量,所以内存消耗就是一样的。又因为堆活动内存大小相同,边际 GC 的 CPU 成本又一样,所以只要是 GC 间隔保持恒定, GC 消耗就是固定的。
现在考虑如果 GC 延迟运行的话 GC 消耗会不会产生变化。这样程序会分配更多内存,但每次 GC 循环的消耗不会增多。然而,因为其他的间隔是固定的,延迟运行会导致 GC 循环的次数减少,进而会降低总的 CPU 成本。反过来也成立。如果 GC 提前,分配的内存会变少,但 CPU 成本会上升。
这种情况表明 GC 需要在 CPU 时间成本和内存之间做取舍18。这种取舍取决于多长久执行一次 GC 操作。换句话说就是这种取舍完全由GC 频率来定义。
还需要再定义一个细节,那就是 GC 循环应该在什么时候开始。这会直接决定任意稳定态中 GC 的运行频率,也就是定义了前面说的取舍。在 Go 语言中,决定何时触发 GC 是程序员能控制的主要参数。
GOGC
在高层面上,GOGC 决定了 Go GC 在 CPU 和内存之间的取舍。
它的工作方式由 GC 循环后的堆内存的目标值、GC 循环之间的堆总内存的目标值共同决定。 GC 的目标是在堆内存总大小超出目标值之前完成回收循环。。堆内存的总大小定义为上次循环结束后的堆活动内存大小,加上两次循环之间新分配的内存数量。同时,目标堆内存数量19的定义如下:
Target heap memory = Live heap + (Live heap + GC roots) * GOGC / 100
举个例子。假设 Go 程序的堆活动内存是 8 MiB,协程栈内存为 1 MiB,全局亦是中的指针变量有 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 才包含根对象内存。之前的版本只包含堆活动对象。通常来说,协程的栈内存都非常小,堆活动内存在 GC 过程中占大头。但如果程序创建了成千上万个协程,GC 就会出现误判。
堆内存大小的目标值决定 GC 的频率:目标值越大,GC 等待开始下下次标记阶段的时间就越长;反之亦然。虽说精确公式对估算很有用,但我们最好是从基础目标上来理解 GOGC:它是在 GC CPU 和内存取舍之间的指针。这里的核心逻辑是GOGC 翻倍会导致堆内存开销翻倍而且 GC CPU 成本大致下降一半,反之亦然。(详细解释请参考附录。)
注意:堆的目标大小只是一个目标,有很多原因会导致 GC 循环无法完成目标。一种情况是,如果一次性分配足够大的堆内存就可以轻而易举地超过堆的目标大小。当然,也有些其他原因,主要是当前的 GC 实现超出来了本文所使用的GC 模型。但完整资料可以参考补充材料小节。
GOGC 可以通过GOGC
环境变量来配置(所以 Go 程序都会识别),也可以通过runtime/debug
包的SetGCPercent
函数来设置。
注意,我们可以通过设置GOGC=off
或者调用SetGCPercent(-1)
完全禁用 GC 功能(在没有设置内存上限的情况下)。从概念上讲,该设置等价于将 GOGC 设置成无穷大,也就是把触发 GC 的内存上限设置为无限大。
为了更好地理解已经讨论的内容,请大家操作下面的可视化模型。它是基于上述GC 成本模型构建的。该可视化模型展示了某程序的运行情况,它的 GC 间隔为 10 秒。该程序在进入稳定态前的第一秒做了一些初始化工作(堆活动内存不断增加)。程序总共分配 200 Mib 内存,但每一刻只有 20 MiB 是活动内存。该可视化模型假定 GC 只处理堆活动内存,而且程序没有使用其他额外的内存(现实很难出现)。
拖动滑块调整 GOGC 的取值,看程序的执行时间和 GC 开销20会如何变化。每次 GC 循环结束后新分配的堆内存数量会归零。新内存归零的时间包含第N次的标记时间和第N+1次的清除时间。注意,该演示模型(也包括本文中的其他演示模型)假设程序会在 GC 执行期间暂停,所以堆内存归零的时间完全是 GC 的 CPU 耗时。这样做仅仅是上为了简化演示模型,但不会影响我们的直觉21。 X轴会上下移动以始终展示完整的 CPU 时间。注意,增加 GC 频率会导致 CPU 使用时间也相应增长。
注意,GC 总会在CPU 或者峰值内存上产生一些开销。 GOGC 增大时,CPU 开销减少,但峰值内存会增加,而且跟堆活动内存的大小成比例。 GOGC 减少时,峰值内存降低,但代价是产生更多 CPU 开销。
注意:上图展示的是 CPU 时间,而非程序运行的实际时间。如果程序在单核设备上运行而且能够完全利用 CPU 资源,结果才会跟上图一样。现实中程序往往运行在多核设备上,也没法时时刻刻 100% 利用 CPU 资源。这种情况下 GC 对程序运行时间的影响会减弱。
注意:GO GC 要求堆内存最小为 4 MiB。如果 GOGC 设置堆内存的目标值小于该值,就会自动对齐22到 4 MiB。演示模型也反映了这一细节。
这里还有另外一个更为动态也更为真实的例子。如果不开 GC,程序需要消耗10秒钟的 CPU 时间。但这次程序在稳定态的内存分配速率中途急剧增长,而且在程序运行的第一个阶段,堆活动内存也有变化。本例演示了堆活动内存发生变化时程序稳定态的变化情况,同时也表明分配速率越高, GC 频率也越快。
内存限制
在 Go 1.19 之前,只有 GOGC 一个参数能调节 GC 的行为。虽然它在控制内存和 CPU 消耗的取舍方面很管用,但是它没有考虑内存有限的情况。设想一下,如果堆活动内存出现瞬态峰值会产生什么问题:因为 GC 处理的堆大小跟堆活动内存成比例,所以配置 GOGC 时一定要考虑堆活动内存的峰值大小。即使调大 GOGC 能产生更好的成本取舍得把 GOGC 调小。
下面的示例展示了堆内存偶尔出现分配峰值的情形。
如果上例中的程序是在容器里运行,而且容器可用的内存是 60 MiB。虽然峰值过后的 GC 周期有很多内存可以用,我们还是不能让 GOGC 超过 100。另外,在某些应用中,这种瞬态峰值很少见且难以预测,可能导致偶尔的、不可避免的、后果可能很严重的内存不足问题。
所以在 1.19 版本,Go 支持给运行内存设置上限。 Go 程序会识别环境变量GOMEMLIMIT
来设置内存上限23,也可以通过调用runtime/debug
包的SetMemoryLimit
函数来指定。
该配置指定了 GO 运行时所能使用的内存的最大数量。内存的详细构成可以参考runtime.MemStats
的字段:
Sys - HeapReleased
或者是runtime/metrics
的等价字段:
/memory/classes/total:bytes - /memory/classes/heap/released:bytes
因为 Go GC 可以精确控制自己能使用多少堆内存,所以它可以基于该内存限制来设置堆内存的上限以及 Go 运行时使用的内存量。
下面的例子跟 GOGC 小节里的单阶段稳定态示例是一样的,但这次增加了 10 MiB 的内存表示运行时内存开销,而且可以动态调节内存上限。大家可以拖 GOGC 和内存限于的滑块看会产生什么变化。
注意看,当内存上限比由 GOGC 决定的峰值内存(GOGC 为 100 时对应 42 MiB)低的时候, GC 频率会加大以确保峰值内存不会超过上限。
回到前面堆内存有瞬态峰值的例子。通过设置内存上限并调高 GOGC,我们可以兼顾两种效果:既不出现内存不足,也不会出现内存浪费。大家可以自行调整下面的演示模型。
注意,GOGC 和内存上限在某些取值组合下,峰值内存会跟内存上限重合,但程序其他的执行时段的总堆内存大小依然受 GOGC 控制。
我们也就观察到另一个有意思的现象:即使把 GOGC 设置为禁用,内存上限依然生效。事实上,这种特殊配置表示__最经济的资源利用__,因为它会在保证不超过内存上限的前提下设置最小的 GC 频率。在这种情况下,程序所有的操作都会导致堆内存增长到内存限制24。
现在,虽然内存限制是强大的工具,但并非没有成本。所以肯定无法替换 GOGC。
设想一下,当堆活动内存增长到很接近内存上限的时候会出现什么情况。在上面的稳定态演示模型中,关闭 GOGC 然后慢慢调小内存上限然后看会出现什么情况。注意,应用程序的总执行时间会有一个突变,因为这时 GC 需要不断地执行好让内存不超过内存上限。
这时候程序只能不断地执行 GC 循环,所以无法正常响应。这种情况称为假死25。程序在这种情况下基本就已经卡住26了,所以特别危险。更麻烦的是,它还会导致跟不使用 GOGC 一样的情况:堆内存瞬态峰值会让程序立刻卡住。可以把堆内存瞬态峰值示例中的内存上限调小(大约 30 MiB 或者更小),然后看从堆内存峰值开始出现的情况有多差。
在很多情况下,程序无限卡住比内存不足问题更严重。因为内存不足会导致程序立刻报错退出。
有鉴于此,内存上限被定义成软性限制27。 Go 运行时并不保证在所有情况下都能将内存维持在限制以内,它只是尽最大努力来处理。放松内存上限是避免出现假死问题的关键,因为它给了 GC 一种选择:为了避免 GC 消耗太多 CPU 时间,允许内存超过上限。
内部工作原理是这样的,对于同样的时间窗口,GC 会设置一个可用的 CPU 使用时间上限(时间窗口结束后会有一个很短的 CPU 使用尖峰)。当前该上限大约是 50%,时间窗口是2 * GOMAXPROCS
CPU 秒数。限制 GC 的 CPU 时间会导致垃圾延迟回收,Go 程序同时也可能继续分配新内存,甚至可能会超出内存的上限。
从直觉上看,把 GC 的 CPU 限制为 50% 是为了应对在内存充足情况下可能出现的最差情况。如果把内存上限配置错了,比如设的特别小,那么程序最多变慢两倍,因为 GC 最多只能占用 50% 的 CPU 时间。
注意:本文中的演示模型没有模拟 GC 的 CPU 使用限制。
使用建议
虽说内存上限是很强大的工具,而且 Go 运行时也采取措施缓解因为错误配置而产生的最坏行为,但在使用的时候还是得三思。下面是一组关于小建议集合,它们描述了内存上限在什么是候有用和适用,以及在什么情况下会带来更多问题。
配置内存上限需要确保程序的运行环境完全受控,而且 Go 程序是资源的唯一使用方(比如某些形式的内存预留场景,像是容器分配特定内存)。
在内存有限的容器内部署 web 服务就是比较好的例子。
这种场景有一条经验规则,就是预留 5-10% 的空间用于 Go 运行时无法感知的内存开销。
争取在条件变化的情况下动态调整内存上限。
一个很好的例子是程序使用 cgo 而且 c 库需要临时分配大量的内存。
如果 Go 程序需要跟其他程序共用有限的内存,而且这些程序跟 Go 程序没有关系,那在设置内存上限后不要关闭 GOGC。相反,应该通过内存上限来限制非预期的瞬时行为,并把 GOGC 设置为某个较小的值来优化平均场景。
虽然大家都想为同时运行的程序「预留」内存,但是除非程序之间有严格的同步关系,比如在 Go 程序中调用某些子进程阻塞自己等待被调方返回,否则很难得到可靠的效果。因为多个程序不可避免会占用更多内存。让 Go 程序在不需要的时候少用一点内存会产生可靠的效果。该建议也适应于超配28的情形,这种情形下所有容器分配的内存之和超过了宿主机物理内存。
如果无法控制程序的运行环境,就不要设置内存上限,尤其是在程序消耗的内存跟用户的输出成比例的情况下。
命令行工具或者桌面程序就是很好的例子。如果不确定程序的输出内容或者不确定系统有多少可用内存,在这种情况下还设置了内存上限,程序可能在意想不到的时候崩溃或者性能变的非常差。另外,高级的最终用户总是可以设置内存上限,只要他们愿意。
如果程序使用的内存已经接近环境的内存上限,就不要使用
GOMEMLIMIT
来避免出现内存不足的情况。这会把一个内存溢出问题变成服务进程放慢的问题。虽然 Go 语言已经努力消除程序假死,但这通常不是很好的选择。在这种情况下,更有效的办法是要么增加运行环境的内存上限(然后可能需要再设置
GOMEMLIMIT
),要么把 GOGC 调低(这会带来比假死更好的折衷效果)。
延迟
本文所有的可视化模型都假设在执行 GC 的时候程序会暂停。确实有这样的 GC 实现,我们称之为 “stop-the-world” GC。
但是 Go 的 GC 并非完全是 stop-the-world,而且它的大部分工作会跟应用程序并行执行。这种设计主要为了降低应用程序的延迟29。具体说就是一组完整计算操作(比如处理一次 web 请求)从开始到结束消耗的总时间。到目前为止,本文主要考虑的是程序的吞吐量30(例如每秒钟程序能处理的 web 请求数)。注意,在GC 循环部分的所有示例都在关注程序总的 CPU 使用时长。但是这种时长对 web 服务来说意义不大。虽说吞吐量(每分钟查询数量)很重要, 但 web 服务通常更关注单次请求的处理耗时。
就延迟来说,STW31 GC 在标记和清除阶段需要相当长的时间。在该时段内,应用程序无法继续执行。就 web 服务而言,服务在该时段内无法处理新的请求。相反,Go GC 避免产生跟堆内存成比例的全局应用暂停时间。它的核心是在程序运行的时候扫描算法也能并行执行。(从算法上看,暂停时间更多是跟 GOMAXPROCS 成正比,但是大部分时间都消耗在停止协程上)并行收集并非没有成本:在现践中,这类设计的吞吐量要比同类的 STW GC 要低。当然了,很重要的一点是低延迟并不一定意味的低吞吐量。在延迟和吞吐量两个方面, Go GC 的性能都在随时间稳步提升。
Go 当前 GC 的并行特性并不会影响本文到目前为止讨论的所有内容:这些内容跟并行设计无关。 GC 频率依然是影响 CPU 时间和内存占用这一取舍的主要因素。而且事实上,它也是影响程序延迟的主要方面。因为 GC 的主要开销来自于执行标记阶段的任务。
接下来的重点就是降低 GC 频率可以改进程序的延迟。这适用于通过调高 GOGC 或者内存上限来降低 GC 频率,也适用于优化指南列出的其他优化手段。
然而,跟吞吐量相比,延迟通常更难以理解。因为它是程序动态运行的产物,而非某些成本的汇总。所以延迟与 GC 频率之间的联系就没有那么直接了。下面给想深入学习的读者列出程序延迟的可能来源。
- GC 在标记阶段和清除阶段切换时会产生短暂的 STW 暂停
- 在标记阶段,如果 GC 的 CPU 使用率超过 25% 就会产生调度延迟
- 协程调整分配内存会导致 GC 延迟
- 在 GC 标记阶段,更新指针会产生额外的工作量
- 运行中的协程在 GC 在扫描其根对象的时候必须暂停
除了修改指针导致的额外工作外,所有延迟来源可以通过执行追踪查看。
补充材料
虽然本文的内容没有错误,但光靠本文很难完全理解 Go GC 设计中的开销和取舍。大家可以从下面的补充材料中获取更多信息。
- The GC Handbook — 非常好的关于通用垃圾回收器设计的参考材料。
- TCMalloc — C/C++ 内存分配器 TCMalloc 的设计文档,Go 内存分配器就是基于它。
- Go 1.5 GC announcement — 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”和同类指标,它们才能更为直接地反映物理内存的使用情况。
优化指南
识别成本
在做 GC 优化之前,最重要的是识别 GC 的主要成本。
Go 生态系统提供一系列的工具来识别和优化应用程序的成本。官方诊断指南有对这些工具的概要介绍。为了能更好理解 GC 的影响和行为,本文会按照一定顺序介绍其中的部分工具。
CPU 分析32
我们可以从CPU 分析开始。 CPU 分析包含程序 CPU 时间消耗的整体状况。但是如果没有受过训练,人们很难从中看出 GC 在特定程序中起到的巨大影响。不过幸运的是,我们可以把理解 GC 的状态归结为观察
runtime
包中特定函数的分析指标。下面是跟 CPU 分析相关的函数子集。注意:下列函数不是所谓的叶子函数33,所以
pprof
的top
命令默认不会展示这些函数。但可以对这些函数直接使用使用top -cum
命令或者list
命令然后关注 cumulative percent 这一列的数值。runtime.gcBgMarkWorker
: 执行标记阶段工作的协程的入口函数。该函数消耗的时间会随着 GC 频率、程序复杂度以及对象图的尺寸的增长而升高。它表示程序在标记和扫描阶段的时间消耗基线。注意:如果 Go 程序在多数时间都比较空闲,那么 Go GC 就会占用更多的(空闲)CPU 时间来加快 GC 过程。这会导致该函数在样本中的占比变得很高,不过一般认为这种情况并不会消耗资源。出现这种情况的主要原因是应用程序本身只用了一个协程,但
GOMAXPROCS
> 1。runtime.mallocgc
: 堆内存分配的入口函数。如果该函数的累计消耗时间很长(>15%) 基本预示着内存分配有点多了。runtime.gcAssistAlloc
: 执行该函数的协程会让出它们的时间一辅助 GC 进行扫描和标记。该函数累计消耗时间过长(>5%)意味着应用程序的内存分配速度已经超过了 GC 的回收速度。它表明 GC 对应用程序的影响已经非常大了,同时也代表程序在标记和清除阶段所消耗的时间。注意,这部分时间也包含在runtime.mallocgc
的调用树中。
执行追踪34
CPU 分析适用于在查看 CPU 的总体使用情况,但很难用于排查一些跟细微具体的延迟相关性能问题。但是执行追踪技术可以为 Go 程序在特定时间窗口的执行过程提供丰富且深入的追踪信息。这包括 Go GC 相关的一系列事件,以及可以直接观察特定的执行路径,同时还有应用程序跟 Go GC 的交互过程。所有 GC 事件都被打上了标签,可以很方便地在追踪工具中查看。
关于如何开始使用执行追踪技术请参考
runtime/trace
包的文档。GC 追踪
如果前面两类工具都没成功,那么 Go GC 提供几类不同的追踪工具,它们会展示更为底层的 GC 行为信息。这些追踪信息通常会直接输出到
STDERR
中,每行表示一个 GC 循环,可以通过 Go 语言通用的环境变量GODEBUG
来配置。这些信息要求使用者熟悉特定的 GC 实现,所以它们一般用于 Go GC 自身的调试。但有时候对更好地理解 GC 的行为也很有帮助。GC 追踪的核心功能可以通过
GODEBUG=gctrace=1
开启。输出内容的结构可以参考runtime
包对应环境变量的文档。还有一种补充的 GC 追踪信息,叫”pacer trace”,可以提供更加底层的信息,需要使用
GODEBUG=gcpacertrace=1
来开启。要想看懂这些输出结果需要理解 GC 的”pacer”(参见补充材料),这些内容已经超出了本文的讨论范围。
消除堆内存分配
降低 GC 成本的方法之一是让 GC 管理的变量越少越好。我们在GOGC中讲过,影响 GC 成本最关键的指标是 GC 频率。而影响 GC 频率最主要因素又是程序的内存分配速率。下面介绍的技术可能带来巨大的性能提升。
堆内存分析
确定GC 是主要的运行开销之后,消除堆内存分配的下一个步骤是是找出都是程序的哪些部分在分配内存。内存分析35(更确切的说是堆内存分析)就是非常有用的排查工具。如何使用内存分析可以查看官方文档。
内存分析通过触发内存分配操作的调用栈来确定到底是程序的哪些地方在分配内存。每一条内存分析信息可以分解成四部分。
inuse_objects
— 表示活动对象的数量。inuse_space
— 表示活动对象所使用的内存数量,单位是字节。alloc_objects
— 表示从程序运行开始已经分配的对象数量。alloc_space
— 表示从程序运行开始分配的内存总数。
我们可以通过pprof
工具的-sample_index
参数或者在交互命令行中执行sample_index
来切换不同的堆内存状态视图。
注意:内存分析默认会对堆对象进行抽样,所以不能包含第一次堆内存分配的信息。但是这用来识别内存分配热点已经足够了。调整采样率可以参考runtime.MemProfileRate
。
为了降低 GC 开销,最有用的指标是alloc_space
,因为它反映了内存分配速率。该指标能识别内存分配热点,从而可以提供最佳优化线索。
逃逸分析
一担通过堆内存分析识别出可能会分配堆内的地方,接下来该怎么消除这些内存分配操作呢?这里的关键是利用 Go 编译器的逃逸分析机制。逃逸分析可以让编译器使用用更加高效的方式来存储这些内存,比如说使用协程的栈内存。很幸运,Go 编译器可以描述自己为什么要把某值逃逸到堆上。知道这些内容,问题就变成了如何识别源代码中哪些部分会影响逃逸分析(这通常也是最困难的部分,但相关内容已经超出本指南的讨论范围)。
至于如何获取 Go 编译器逃逸分析,最简单的办法是使用编译的器调试选项。 Go 编译器支持通过该选项指定包的名字,然后以文本的方式输出跟指定包相关的所有优化信息,其中就包含变量是否需要逃逸到堆上。你可以试试下面的命令,其中[package]
指某个 Go 包的路径。
在 VS Code 里,这些信息可以通过层叠窗口36展示。层叠窗口需要通过 VS Code Go 插件来配置和开启。
最后,Go 编译器也提供适合机读格式(JSON),这样可以自己定制额外的构建工具。更多资料请参考Go 源代码中的文档。
特定实现相关的优化
Go GC 对活动内存的使用统计信息非常敏感,因为复杂的对象图和指针图都会限制 GC 的并发能力而且会产生更多的工作量。所以,GC 系统针对一些常用的数据结构提供了单独的优化手段。下面列出其中对性能优化最有用的部分。
注意:下列优化手段可能会模糊代码的意图进而降低代码的可读性,而且在新版本的 Go 中也可能不再有效。这些优化只能用在必要的地方。这些必要的地方可以通过识别成本部分提供的工具来定位。
有指针的值和没有指针的值要分开。
如果数据结构并不严格依赖指针,那么去掉指针会比较有有利,因为这样可以减少 GC 对程序施加的缓存压力。但这样一来,那些依赖下标而非指针的数据结构虽然在类型上不那么直观,但性能会更优。只有在对象图非常复杂而且 GC 在标记和描述阶段消耗大量时间的情况下才值得做这种优化。
GC 需要扫描完变量的最后一个指针才能结束。
所以,最好是把结构体变量中所有指针打包放在最前面。只有明确程序在标记和扫描阶段花费很多时间的情况下才值得做这种优化。(理论上编译器可以自动执行这类优化,但目前还没有实现。结构体的成员的内存是根据源码中位置来排布的。)
另外,GC 必须处理扫描到的每一个指针。比如在操作切片时,使用索引就比使用指针更能帮助减少 GC 开销。
附录
GOGC 的其他注意事项
GOGC 有讲说 GOGC 的值增加一倍会导致堆内存翻倍和 GC CPU 消耗减半。下面给出数学证明。
首先,堆目标大小设置了堆内存的最终大小。但该目标主要会影响堆上新分配的内存,因为活动内存是程序的基础部分37。
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 翻倍会导致每个周期内新分配的堆内存也翻倍,这部分包含堆内存的开销。注意堆活动内存 + GC roots是一个估计值,表示 GC 需要扫描的内存数量。
接下来看 GC 的 CPU 成本。总耗时可以分解成单个周期的耗时再乘以一定时间 T 内的 GC 频数。
Total GC CPU cost = (GC CPU cost per cycle) * (GC frequency) * T
单个周期内的 GC CPU 消耗可以通过GC模型来推导。
GC CPU cost per cycle = (Live heap + GC roots) * (Cost per byte) + Fixed cost
注意,因为标记和扫描阶段的成本占大头,所以清除阶段忽略。
在稳定态中,分配速率和单位字节内存的分配成本是常量,所以我们可以通过新分配的堆内存数量来求出分配速率:
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 翻倍的时候 CPU 成本并没有完全减半。)进一步讲,GC 的 CPU 成本主要由内存分配速率和单位字节内存的扫描成本决定。更多关于如何降低这些成本的具体信息可以参考优化指南部分。
注意:活动堆内存的大小跟 GC 需要扫描的内存大小之间还是有一些出入:同样大小的堆活动内存,如果值的结构不同,对应的 CPU 消耗也不一样。所以内存成本相同的情况下也可能选择不同的优化取舍。这就是为什么需要稳定态的定义中规定堆内存的结构。堆内存目标大小应该说只要包含活动内存中需要扫描的部分就可以了,这样可以比较好的估计 GC 需要扫描的内存。但是如果需要扫描的堆活动内存比较少而且堆内存的其他部分比较大的话,就会导致退化情况。
原文是 insights,本文译成工作原理。↩︎
原文是 cost 并在多处使用,本文会使用成本、开销、消耗等多种译法。↩︎
原文是 go valuses,一般会译成 go 值,应该译成「变量的值」才准确。我在本文将简化为「变量」。↩︎
是 go compiler 的缩写,表示官方用 go 语言实现的编译器,跟 gccgo 等其他实现对应。↩︎
原文是 tracing↩︎
原文是 live,表示正在使用的不需要回收的对象。后文中的活动内存、堆活动内存等也都指代 live。↩︎
原文是 object graph↩︎
原文是 roots↩︎
原文是 scanning↩︎
原文是 mark-sweep↩︎
原文是 moving↩︎
原文是 insight,没想到更好的译法😂↩︎
原文是 live heap memory↩︎
原文是 new-heap-memory↩︎
原文是 marginal cost,表示单位内存上涨导致的 CPU 成本上涨↩︎
原文是 asymptotically,没有找到合适的译法↩︎
原文是 steady state↩︎
原文是 trade off↩︎
原文是 target heap memory↩︎
原文是 overhead↩︎
原文是 the same intuition still applies,没有找到合适的译法↩︎
原文是 rounded up↩︎
原文是 memory limit↩︎
原文是 In this case, all of the program’s execution has the heap size rise to meet the memory limit.↩︎
原文是 thrashing↩︎
原文是 stall↩︎
原文是 soft↩︎
原文是 overcommit↩︎
原文是 latencies↩︎
原文是 throughput↩︎
原文是 stop-the-world↩︎
原文是 profiles↩︎
原文是 leaf functions。感谢Sam劉指正,此处指没有再嵌套调用其他函数,所有消耗的时间不包含子函数的CPU消耗↩︎
原文是 execution traces↩︎
原文是 memory profiles↩︎
层叠窗口原文是 overlay,没想到更好的译法↩︎
所以说比较少,变化也少。↩︎