Golang服务GC优化
背景
在上半年的服务性能优化项目中,主要针对业务代码不合理的地方进行了瘦身与优化。优化后压测的cpu pprof显示:gc消耗的cpu占比几乎达到25%,gc次数也比较多,因此在优化业务系统后,Golang GC是另一个性能瓶颈,存在很大的优化空间。
思路
首先确定问题:在压测过程中GC CPU占比过大,GC次数较多;
初步解决思路:减少GC CPU占比 → 减少GC单次耗时或者减少GC次数
调研业内常用方案:减少GC单次耗时:减少堆内对象生成;GC次数:减少堆内对象生成、调整GC参数
方案试验及原理探究:详见GC原理分析与GC优化方案
原理分析
GC算法
Golang 采用了基于并发标记与清扫算法的三色标记法
简单的说就是:GC开始时从根集合对可能需要回收的对象进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收。
GC流程
Mark Prepare - STW
做标记阶段的准备工作,需要停止所有正在运行的goroutine(即STW),标记根对象,启用内存屏障,内存屏障有点像内存读写钩子,它用于在后续并发标记的过程中,维护三色标记的完备性(三色不变性),这个过程通常很快,大概在10-30微秒。Marking - Concurrent
标记阶段会将大概25%(gcBackgroundUtilization)的P用于标记对象,逐个扫描所有G的堆栈,执行三色标记,在这个过程中,所有新分配的对象都是黑色,被扫描的G会被暂停,扫描完成后恢复,这部分工作叫后台标记(gcBgMarkWorker)。这会降低系统大概25%的吞吐量,比如MAXPROCS=6,那么GC P期望使用率为6*0.25=1.5,这150%P会通过专职(Dedicated)/兼职(Fractional)/懒散(Idle)三种工作模式的Worker共同来完成。
这还没完,为了保证在Marking过程中,其它G分配堆内存太快,导致Mark跟不上Allocate的速度,还需要其它G配合做一部分标记的工作,这部分工作叫辅助标记(mutator assists)。在Marking期间,每次G分配内存都会更新它的”负债指数”(gcAssistBytes),分配得越快,gcAssistBytes越大,这个指数乘以全局的”负载汇率”(assistWorkPerByte),就得到这个G需要帮忙Marking的内存大小(这个计算过程叫revise),也就是它在本次分配的mutator assists工作量(gcAssistAlloc)。Mark Termination - STW
标记阶段的最后工作是Mark Termination,关闭内存屏障,停止后台标记以及辅助标记,做一些清理工作,整个过程也需要STW,大概需要60-90微秒。在此之后,所有的P都能继续为应用程序G服务了。Sweeping - Concurrent
在标记工作完成之后,剩下的就是清理过程了,清理过程的本质是将没有被使用的内存块整理回收给上一个内存管理层级(mcache -> mcentral -> mheap -> OS),清理回收的开销被平摊到应用程序的每次内存分配操作中,直到所有内存都Sweeping完成。当然每个层级不会全部将待清理内存都归还给上一级,避免下次分配再申请的开销,比如Go1.12对mheap归还OS内存做了优化,使用NADV_FREE延迟归还内存。
GC的触发时机
在 Go 中主要会在三个地方触发 GC:
- 监控线程 runtime.sysmon 定时调用(2min);
- 手动调用 runtime.GC 函数进行垃圾收集;
- 申请内存时 runtime.mallocgc 会根据堆大小判断是否调用;
优化方向
降低 GC 频率;
为什么降低GC频率有用呢?
因为:
- 一般情况下,mark 耗时要比 sweep 大很多,相比 mark,sweep 更轻量级 (free)
- gc 三色标记的时间复杂度,一般是和当前存活 object 总数有关,和当前 unused object 总 数无太大关系
- 降低 gc 频率意味 unused objects 个数增加,当前存活的 object 总数不会有太大变化,所 以当 gc 真正来临时即使系统中已经积压很多 unused object 也不会让 gc 付出太多代价
对于gc频率, cpu, mem的关系,Go官方在稳态下有一个比较简单的兑换比例: doubling GOGC doubles heap memory overheads and halves GC CPU costs
减少堆上对象数量;
优化方案
对象池化(减少堆上的对象数量)
对象池化:sync.pool
原理: 使用 sync.pool() 缓存对象,减少堆上对象分配数;
sync.pool 是全局对象,读写存在竞争问题,因此在这方面会消耗一定的 CPU,但之所以通常用它优化后 CPU 会有提升,是因为它的对象复用功能对 GC 和内存分配带来的优化,因此 sync.pool 的优化效果取决于锁竞争增加的 CPU 消耗与优化 GC 与内存分配减少的 CPU 消耗这两者的差值;
调整GOGC参数
原理: GOGC 默认值是 100,也就是下次 GC 触发的 heap 的大小是这次 GC 之后的 heap 的一倍,通过调大 GOGC 值(gcpercent)的方式,达到减少 GC 次数的目的;
公式(go 1.19之前):gc_trigger = heap_marked * (1+gcpercent/100)
heap_marked:上一个 GC 中被标记的(存活的)字节数;
gcpercent:通过 GOGC 来设置,默认是 100,也就是当前内存分配到达上次存活堆内存 2 倍时,触发 GC;
在 go 1.19 及之后,这个公式变为了 heap_marked + (heap_marked + GC roots) * gcpercent / 100
GC roots:全局变量和goroutine的栈
存在问题: GOGC 参数不易控制,设置较小提升有限,设置较大容易有 OOM 风险,因为堆大小本身是在实时变化的,在任何流量下都设置一个固定值,是一件有风险的事情。
ballast 内存控制
原理: 仍然是从利用了下次 GC 触发的 heap 的大小是这次 GC 之后的 heap 的一倍这一原理,初始化一个生命周期贯穿整个 Go 应用生命周期的超大 slice,用于内存占位,增大 heap_marked 值降低 GC 频率;实际操作有以下两种方式
相比于设置 GOGC 的优势:
安全性更高,OOM 风险小;
是当前业内较为成熟的方案,有很多项目已采用该方案;
GCTuner
简述: 同上文讲到的设置 GOGC 参数的思路相同,但增加了自动调整的设计,而非在程序初始设置一个固定值,可以有效避免高峰期的 OOM 问题。
优点: 不需要修改 GO 源码,通用性较强;
缺点: 对内存的控制不够精准。
GO SetMemoryLimit(go 1.19 及之后)
原理:通过对 Go 使用的内存总量设置软内存限制来调整 Go 垃圾收集器的行为。
此选项有两种形式:runtime/debug调用的新函数SetMemoryLimit和GOMEMLIMIT环境变量,通过设置GOGC=off,Go 运行时将始终将堆增长到满内存限制。
Bigcache
会在内存中分配大数组用以达到 0 GC 的目的,并使用 map[int]int,维护对对象的引用;
(当 map 中的 key 和 value 都是基础类型时,GC 就不会扫到 map 里的 key 和 value)
类似ballast 内存控制手段的效果
堆外分配
绕过 Go runtime 直接分配内存,使 runtime 感知不到此块内存,从而不增加 GC 开销。
fastcache:直接调用 syscall.mmap 申请堆外内存使用;
offheap:使用 cgo 管理堆外内存;
问题:管理成本高,灵活性低;