如何利用小学数学知识来推导 Go GC 调优

438 阅读10分钟

GC 是什么?

GC 是垃圾回收(Garbage Collection)的缩写,是一种自动内存管理机制。在编程语言中,程序员通常需要手动分配和释放内存,但是这种方式容易出现内存泄漏和野指针等问题,因此一些编程语言引入了 GC 机制,自动管理内存的分配和释放。

GC 机制会在程序运行时自动检测不再使用的内存,并将其回收,以便程序可以继续运行。GC 机制可以减少程序员的工作量,同时也可以提高程序的性能和稳定性。

Go GC 的原理介绍

在 Go 中,GC 采用了标记-清除算法,其中标记采用的是三色标记法。

简单介绍一下,标记-清除算法是一种常见的 GC 算法,它分为两个阶段:标记阶段和清除阶段。在标记阶段,GC 会遍历程序中的所有对象,并标记所有仍在使用的对象。在清除阶段,GC 会清除所有未被标记的对象,以便回收内存。标记-清除算法的缺点是会产生内存碎片,影响程序的性能。

为了解决标记-清除算法的缺点,Go 引入了三色标记法。三色标记法将对象分为三种颜色:白色、灰色和黑色。在 GC 开始时,程序创建的对象都标记为白色。在标记阶段,GC 会从根对象开始遍历程序中的所有对象,并将其标记为灰色。然后,GC 会遍历所有灰色对象的引用,并将其标记为黑色。这个过程会一直进行,直到所有灰色对象的引用都被标记为黑色,此时只会存在黑色和白色对象。在清除阶段,GC 会清除所有白色对象,并将所有黑色对象重新标记为白色。三色标记法可以避免内存碎片的产生,提高程序的性能和稳定性。

在标记前和标记后有一个很重要的阶段,被称为 STW(Stop-The-World),顾名思义此阶段会暂停所有程序,此时程序会出现卡顿。因为进行标记前,Go 需要初始化一些三色标记的环境,进行一些轻量级的准备工作;标记完成后,Go 需要针对此次 GC 的结果进行一些统计,并计算出下一次 GC 的内存额度。所以 Go 每个版本的迭代对 GC 的优化,很重要的一部分工作就是减少 STW 的时间,目前官方声称 Go 的 STW 已经是亚毫秒级了。

那么很多同学会有疑问,既然标记前后都需要 STW,那么标记中不需要进行 STW 吗?垃圾回收流程与代码逻辑并发执行。代码逻辑的执行会产生或改变对象引用,如果标记时阶段不加以区分,对象引用的变化不是会让标记阶段无止尽的执行下去?

Golang GC 采用了写屏障技术来保证标记过程的原子性,以避免出现上述因程序运行导致对象被重复标记或漏标的情况。大致原理是写屏障技术会在对象被修改时,将其标记为灰色,以便 GC 可以重新遍历其引用。这样可以保证标记过程的正确性和完整性。Golang 采用的写屏障为混合写屏障(即插入写屏障 + 删除写屏障),关于写屏障的详细原理这里就不展开说明了,静待后续开坑。

Go GC 计算公式

从上一个 GC 周期结束为时间起点,仍存活的对象定义为活动堆(live heap),活动堆加上应用程序时间起点以来分配的内存,定义为新堆(New heap)。新堆加上活动堆则是总堆(Total heap)。

Go Runtime会在每次 GC 周期结束之后,计算出一个目标堆内存(Target heap memory ),同时保证总堆大小超过目标堆大小之前(即:Total heap memory < Target heap memory)完成垃圾收集。而 GOGC 则是计算目标堆内存的重要参数,也是权衡 CPU 开销和内存开销的关键变量。

Go 1.18 前,目标堆的计算公式如下:

Totalheapmemory=Liveheap+NewheapTotal heap memory = Live heap + New heap

Targetheapmemory=Liveheap+LiveheapGOGC/ 100Target heap memory = Live heap + Live heap * GOGC / 100

默认情况下 GOGC 为 100,这就意味着每次 Go runtime 预留分配的内存空间与之前的活动推大小一致。通过公式可知,GC 触发与 Live heap 有着强关联性,但 Live heap 并不包含 goroutine,因为 goroutine 堆栈中的内存量非常小,相比较 Live heap 可以忽略,所以之前都是通过 Live heap 大小支配所有其他 GC 工作来源。但在程序有数十万个 goroutine 的情况下,GC 会做出错误的判断。所以:

Go 1.18 前,目标堆的计算公式如下:

Totalheapmemory=Liveheap+NewheapTotal heap memory = Live heap + New heap

Newheap=(Liveheap+GCroots)GOGC/100New heap = (Live heap + GC roots) * GOGC / 100

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

例如,考虑一个 Go 程序,其活动堆大小为 8 MiB,goroutine 堆栈为 1 MiB,全局变量中的指针为 1 MiB。然后,如果 GOGC 值为 100,则在下一次 GC 运行之前分配的新内存量将为 10 MiB,或者 10 MiB 工作量的 100%,总堆占用空间为 18 MiB。如果 GOGC 值为 50,则它将是 50%,即 5 MiB。如果 GOGC 值为 200,则为 200%,即 20 MiB。

无论是 1.18 前还是 1.18 后,Target heap 越大,GC 等待开始另一个标记阶段的时间就越长,从而降低 GC 频率,反之亦然。GC 频率高 CPU 成本高,GC 频率低堆内存开销大。

而 GOGC 是 一个在 GC CPU 和内存权衡中选择一个点的参数。所以只要记住一句话,将 GOGC 加倍将使堆内存开销加倍,并使 GC CPU 成本大致减半,反之亦然。有兴趣的同学可以参考 官方原文:A Guide to the Go Garbage Collector

GC 调优

下面我们就可以利用我们丰富小学的数学知识,从公式推导中我们可以梳理出 GC 调优思路。

1. 调整 GOGC

从公式中最直观的就是通过设置 GOGC 环境变量来调整 GC 的触发时间和阈值。设置 GOGC 分为两种方式:

1.1 通过程序设置

需要注意,此时 GOGC 参数的设置只对当前进程有效

import ( 
    "os" 
    "strconv" 
) 

func main() { 
    // 设置 GOGC 参数为 50 
    os.Setenv("GOGC", "50") 
    // 获取当前 GOGC 参数的值 
    gogc := os.Getenv("GOGC") 
    gogcValue, _ := strconv.Atoi(gogc) 
    fmt.Printf("GOGC is set to %d\n", gogcValue) 
}

或者通过 debug.SetGCPercent() 来设置。

1.2 设置 GOGC 系统环境变量

但这种直接设置 GOGC 方式有两个显而易见的缺点:

  • 设置固定不变的 GOGC 并不灵活,同时也不精确,想要达到最佳实践得反复调试观察,成本很高。
  • 设置过大的 GOGC 可能会导致 OOM 的问题(这个在 1.19 版本中有了比较好的解决方案)

2. Auto-tuning

由上方法,可想到其实理想的 GC 应该是,内存充足,CPU 较高时,GC 频率低一些;内存不足,CPU 较低时,GC 频率高一些,GC 在每次 GC 周期结束时,根据当前状态来进行一个最优参数的动态设置。 Uber 根据这个理想模型,提出了 Auto-tuning 的优化方案。具体实现效果和上述差不多,在每次 GC 结束时,根据容器的内存上限动态通过 debug.SetGCPercent() 调整 GOGC 的值,并在内存使用率达到 70% 时,强制 GC,实现内存硬限制来防止 OOM 发生。

Uber 实现原文:How We Saved 70K Cores Across 30 Mission-Critical Services (Large-Scale, Semi-Automated Go GC Tuning @Uber)

阿里也曾做过类似的尝试:Go 调用 Java 方案和性能优化分享

这种方式已经接近于 GC 的理想模型了,但很显然,这种方式的实现成本比较高,可迁移性也比较差,业界目前还没有一个具有此功能的插件能够低成本的给开发者使用,大部分都是根据自身业务 case by case 的定制开发。

3. Memory Ballast

我们除了从 GOGC 入手,还由公式可知目标堆的影响因素除了 GOGC 还有 Live heap。

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

而 Memory Ballast 原理就是想活动推中写入一个保活的大对象,影响 Live heap 大小,从而扩大目标堆,推迟 GC,减少 CPU 开销。我们可以在程序开始时分配一个大数组来实现该效果。实现代码如下:

func main() {

	// Create a large heap allocation of 5 GiB
	_ := make([]byte, 5<<30)

	// Application execution continues
	// ...
}

这个超大的数组就称为 Ballast。当使用默认 GOGC 时,下一次的目标堆最小都为 10GiB。也就是说下一次 GC 触发至少需要 New heap 分配的内存接近于 5 GiB。

这里使用字节数组作为 Ballast 的理由是,首先确保我们只向标记阶段添加一个额外的对象,其次字节数组没有任何指针(对象本身除外),GC 可以在 O(1) 时间内标记整个对象。

那么使用 Ballast 时,这个大对象会不会占用宝贵的实际物理内存呢?

答案是理论上不会,因为系统中的内存实际上是由操作系统通过页表寻址和映射的,ballast 的 slice 会被分配到程序的虚拟地址空间中。只有当我们尝试读取或写入 slice 时,会发生缺页,才会实际分配物理 RAM。更详细的内容可参考:Go memory ballast: How I learnt to stop worrying and love the heap

但对于此结论确实也存在争议,具体可见论坛上的讨论:Create a 1GB ballast but it takes up RSS and pages are Dirty?

4. 从程序上优化内存使用

上面提到了触发 GC 的条件是:总堆大小超过目标堆大小之前(即:Total heap memory < Target heap memory),而以上的方案都是通过目标堆的公式推导得出的,我们一直在尝试调大目标堆来减少 GC 频率,那么反之,在目标堆(Target heap memory)不变的情况下,减少总堆(Total heap memory)大小的分配速度,同样也可以减少 GC 的频率。

根据总堆公式:

Totalheapmemory=Liveheap+NewheapTotal heap memory = Live heap + New heap

可知总堆由 Live heap 和 New heap 组成,而 Live heap 是上一个 GC 周期结束后仍存活的对象,这块优化空间很小,所以我们需要将重点放在 New heap 这部分,而 New heap 是由 Live heap 加上应用程序时间起点以来分配的内存。

由此优化方向已经很清晰了,重点需要减少新变量的内存分配上,一般操作是:

  • 通过使用更少的变量、更小的数据结构和更少的缓存来减少内存使用。
  • 通过使用 sync.Pool 来重用对象,或者使用内存池来减少内存分配等。

这种优化方式需要结合业务场景,在编写代码时 case by case 的进行调优。

尾言

我们通过 GC 计算公式出发,控制其公式参数的变化,总结出了几个常用的调优方式。按照这套理论,今后无论是在面试还是项目中,只要你记住了 GC 计算公式,都可以现场推出调优方式,所以学习过程中,深入理解原理还是非常重要的。

当然光靠理论是不行的,调优是一个非常注重实践的操作。在项目中,首先我们的有完善性能测试和监控系统,才能发现问题,从而将我们的调优理论落地,而且在落地的过程中,需要综合考虑应用程序的特点、负载情况和性能需求,在日益复杂的软件架构中,是没有银弹的,这也是软件开发工程师的价值所在。