go垃圾回收调优
Posted 2022-11-10 10:54 +0800 by ZhangJie ‐ 1 min read
相关背景
在go1.19之前,go程序内存调优的方式主要是通过环境变量GOGC(或者debug.SetGCPercent(?))来控制,
它的效果是影响go runtime计算nextGC heapGoal的大小:
- 较早的版本计算方式为:heapGoal = heapMarked + (heapMarked) * GOGC / 100,
- 后续go迭代时发现非堆内存也是有影响的,于是go1.18完善了下 heapGoal = heapMarked + (heapMarked + GCROOTs) * GOGC/100,这里的GCROOTS=(gstacks+globals)
GC pacer的目的就是为了根据上述公式计算下次GC的heapGoal,然后在必要时(比如malloc时)决定是否要GC。
默认初始heapGoal大小为4MB,如果靠GOGC来控制的话,会比较频繁触发GC,对绝大多数server程序而言频繁GC占比较多CPU,程序整体吞吐、响应延迟会受一定影响。
所以业界一般会通过两种方式来调优:
- ballast,利用一块不用的大内存(比如1GB),来推高下次GC的heapGoal,通过这种方式来降低GC频率
- GC tuner,动态设置GOGC,定义一个对象为其设置finalizer,每轮GC结束时触发它并检查当前进程当前的内存占用情况,并与允许的最大内存占用进行比较,并计算出达到最大内存占用才触发GC时GOGC应该设定的值(其思路和go1.19 GOMEMLIMIT类似)
项目以前的方案
项目以前使用的是go1.16.5,这个版本中也只有GOGC一个控制GC的选项,使用的是ballast的方案:
- 在服务初始化阶段去初始化一个大内存而推高下次GC时的heapGoal
- 不同程序可能对内存需求不同,配置文件中允许自定义ballast大小,默认为1GB
包括业界在内都是介绍了ballast如何使用:
- 全局变量声明,垃圾回收器会认为其在整个进程生命周期内reachable
- 局部变量声明,通过runtime.KeepAlive(…)来欺骗垃圾回收器这之前对象reachable
但是,好像只看到了一派祥和,我们使用时却遇到了Rss占用问题。
问题1:ballast占物理内存
在测试环境(很多套测试环境)都有比较大概率发现服务在几乎空闲时,物理内存占用竟然高达1.1g…这很不符合常理。
通过pprof跟踪内存分配,发现内存分配比较大的路径就是这个压舱石(pprof mem采样是看的虚拟内存)。
然后top、pmap等跟踪可疑进程发现其确实存在1GB左右的anon区域,且该区域为dirty**(其实gdb把内存dump一看全是0,就很容易联想到类似对象分配后memset的操作)**。
根据了解的go GC、内存分配器相关的知识,了解到go向操作系统申请内存时通过mmap的方式,释放内存是通过madvise+MADV_DONTNEED/MADV_FREE的方式。
go1.12的时候改成了FREE默认代替DONTNEED,这两个选项是有区别的,详细的可以看下man手册(man 2 madvise),FREE的效率更好一点,但是也有一些不好的副作用。
go1.16之后linux下又恢复成了DONTNEED,因为FREE不会立即让进程的RSS降下来,会误导很多监控、开发运维人员
内存分配器为了提高分配效率、GC效率会进行一定的组织,这些概念大家应该有听说过,mheap、spanClass、mcentral、mspan、markbits、allocbits,还有p.mcache。
- smallSize<=32K的分配,走p.mcache(tinySize的更特殊一点,略)
- largeSize>32K的分配,直接走mheap
这些对象不管是从p.mcache->mcentral->arena->mheap路径来分配的,还是从mheap直接分配的,最终都是建立在从操作系统申请来的page里的,而这些page是由page allocator申请的。即使是很大的对象,最终也是以存在span描述的区域里的。
一个span可以很多pages,而在里面分配一个对象时,这些page是可以复用的,如果之前其中一个page用过了,但是现在申请一个更大的对象,之前这个旧的page也是所需pages中的一个,那么分配器会先清零memclr…这段内存区域,这样在操作系统看来就是要真正分配内存,并且因为写了0,那就是全为dirty。
ps:可能我们以为我mmap新申请的内存页面,不写不也是0,为什么不只写之前复用过的page呢?嗯,道理归道理,现在的分配器实现就是这么干的,详见allocNeedsZero(base, npages)
这个函数。
结论:就是通过ballast这种方案初始化有点tricky,在更大范围的应用中不是很可靠:
- 虽然通过一些尽早分配的办法可以避免上述问题,但是不太可靠。因为不确定哪天import了一个包,里面干了些内存分配比较多的操作,说不定影响到ballast占物理内存。
- 另外ballast这种方案不可移植,它是否占内存与特定平台也有一定关系,并不总是说mmap了一段内存过来不读写就不占内存,windows下的分配就是立即分配。
问题2:可能引发OOM问题
本来是想通过ballast来在程序内存负载低时尽量减少GC活动,但是当内存负载高了之后,还是希望它能多清理下GC的,如果清理不及时就容易OOM。特别是混部场景下,这种问题就更明显。
我们可以在内存占用较高时,把GOGC设置的小一点来频繁的触发GC,来缓解这个问题。但是如果项目已经是使用的GOGC=100+ballast方案的话可能就不是很好调节,因为下次GC的heapGoal已经被推高了。
如果一开始就采用动态设置GOGC的方式,就更容易实施,比如uber的GC Tuner。在内存占用离上限(uber容器部署时通常取可用物理资源的70%)比较大时就用更大的GOGC(大于100),随着内存占用变高,GOGC也越来越小,通过更频繁的GC来尽可能避免OOM。
项目现在的方案
社区呼吁go能提供内存软限制,目标就是内存占用在达到限制前尽量减少GC活动,当内存占用高了(甚至超了)限制就更及时地GC,以让内存占用保持在一个合理的限制内。
这个想法其实就和大家最初使用压舱石的初衷比较接近了,也可以用这个方案GOMEMLIMIT/GOGC组合来达到此效果(关掉GOGC=off,指定软限制GOMEMLIMIT)。
uber的动态GC Tuner的思路和软限制大致类似,不过go1.19为了支持软限制还是做了不少工作的,包括内存使用较高时能够更加激进GC(gcStart)、更加激进地归还内存给操作系统(scavenge)。
详细了解下GOMEMLIMIT
和GOGC类似,我们也可以通过环境变量GOMEMLIMIT来指定软限制,也可以通过debug.SetMemoryLimit(?)来设置。
背景部分我们提到了GOGC如何影响下次GC时heapGoal的计算,GOMEMLIMIT也是通过影响下次GC时的heapGoal计算来发挥作用的,并且是各自根据GOGC、GOMEMLIMIT计算。
goal = memoryLimit // p0
- (mappedReady - heapFree - heapAlloc) // p1
- max(mappedReady - memoryLimit, 0) // p2
- memoryLimitHeapGoalHeadroom // p3
解释下:
p0:memoryLimit:就是指定的软限制
p1:noheap overheads
p2:超出限制的部分
p3:1mb的headroom(留点buffer)
那这里的goal和GOGC算出的goal,以哪个为准呢?可以简单理解为以小的为准,真实情况是有一点点微调,可以不关注。这样当内存占用达到这个goal时,就会触发GC了。
项目推荐的GC设置
首先,要理解GOMEMLIMIT的初衷,它是一个软限制,意思是允许的进程可用内存上限。当进程使用内存少时它可以减少GC来让mutator拥有更多的CPU时间来干活,当进程占用内存高时它可以更频繁GC来回收内存,避免OOM。
虽然它可以在小内存占用时减少GC频率,但是开发者应该意识到它和ballast还是有差别的。ballast是啥,压舱石的初衷是小内存占用时减少GC频率,但是对内存使用上限并没有控制。
因此参考ballast的大小为1GB来设置GOMEMLIMIT为1GB或者2GB是没有道理的,我们应该根据实际部署情况来界定各个进程允许的资源使用上限来确定GOMEMLIMIT的值:
- 比如没有混部,per container per service或per host per service,那么可以用70%的内存资源作为软限制值,这样GC频率控制比较好,又留了较多的buffer给系统中其他服务,这也是uber容器化部署的一个经验值。
- 再比如有混布,一台机器部署了10个服务,那每个进程允许的软限制值肯定不能继续用机器内存的70%这个值,应该划分的更小,比如平均下7%,或者针对不同进程的实际情况在服务配置文件中进行指定。
另外由于go支持通过读取环境变量GOGC、GOMEMLIMIT的方式来在一开始gcinit的时候进行设置,所以我们应该遵循这样的原则,这两个变量都应该独立遵守下面的原则:
- 环境变量配置优先级最高,这样符合go使用习惯
- 环境变量未指定时,读取配置文件中的自定义值
- 如果配置文件没指定,则使用默认值
软限制实践的过程
最开始确实是直接GOMEMLIMIT=1gb+GOGC=off这样来设置的,但是测试过程中发现,这个方案是有问题的。有经验的开发人员可能已经意识到问题在哪里了:
ballast方案中,我们并不会GOGC=off直接关闭GC,这样虽然ballast推高了下次GC的阈值,但是sysmon还是能做到每隔两分钟(2min)强制GC一次的(forced GC)。所以如测试环境下观察的那样,大多数进程实际占用物理内存并不多,因为GC回收内存了;
而在我们GOMEMLIMIT+GOGC=off组合情况下,因为GOGC被关闭了,此时sysmon即使过了2min这个间隔期,也不会去触发forced GC(这个从源码中一看便知)。这样问题就来了,当内存占用小于1GB时基本上不会触发GC,因为非堆内存占用很少,按照GOMEMLIMIT计算出的下次的heapGoal跟GOMEMLIMIT差不多,所以基本上不会触发GC,这就会导致各个进程占用物理内存接近软限制,而如果混部的go进程多的话,就很容易导致机器内存占用率过高。
ps:我们是一台物理机部署了70个微服务,内存缓慢增长到了1gb作用。
那我们应该如何进一步解决这个问题呢?解决问题前,首先要搞清楚我们追求什么。
在进程内存占用很少时,尽量不触发GC,或者不要频繁GC;
这个问题可以归结于GOGC=off+GOMEMLIMIT设多大的问题
在进程内存占用较多时,要触发GC来回收内存,不要因为达到容器、虚拟机、物理机等分配的资源限制(cgroup来控制)被OS给OOM kill掉;
这个问题可以归结于GOMEMLIMIT上限设多大合适的问题
进程steady状态时占用内存不要停留在GOMEMLIMIT附近,以避免频繁GC对服务性能产生不良影响、抖动;
这个问题可以归结于服务负载均衡、监控问题,超过软限制即超出预期处理能力,需要告警并扩容
这就是我们一步步得出前面go1.19 GC推荐设置的过程和思考。
ps:在升级go1.19并调整GC设置后,项目压测发现这个性能提升也是比较明显的,提升了1.2~1.3倍,能提升多少其实和具体项目处理逻辑相关,就不多展开了。
还有一个问题,加了软限制后是否内存占用一定小于软限制值?
首先,不一定。
其次,这要看在特定负载下的内存分配、标记清除速率。通常可以根据GC cycle结束时collector扫描的内存量、分配内存和标记内存的比率、mutator:collector的cpu时间占比75%:25%,key算出下个GC Cycle中大致能分配的内存量。这个值也是一个触发GC的参考值,通过这个能够让内存分配、回收保持一定的稳态。
当内存分配时,g可以被要求作为assistG去清理一定量的内存,然后再执行分配,清理和分配的内存量差不多,通过这个也能让进程内存占用保持一定的稳态。
scavenge清理内存时现在也会参考这个软限制值,去释放内存给OS,这也是一个可以保持内存占用处于稳态的方法。
感兴趣的可以读下这块的源码,太多细节的东西,已经不能随意找到博客、文章给解答了 :)
关于GC Tuner
关于动态GC调优,uber有一个实现方案,根据其公开的技术文章 Uber’s Engineering Manages to Cut 70k CPUs by Tuning GO GC,大致是一个动态设置GOGC来让进程占用物理内存尽量不要超过设定的内存占用百分比的一个东西,从这个意义上来说,它的作用和GOMEMLIMIT很类似。github上有个参考实现,详见 GC Tuner。
但是如前面所说,go1.19在支持软限制方面,除了内存占用较高时更频繁地触发GC(gcStart),也会更频繁地进行内存释放归还给操作系统(scavenge),效果会更好。所以已经升级到go1.19的项目建议使用软限制代替GC Tuner。
既然是动态调优,可能意味着更大的可定制型,也不排除大家后面能搞出更优秀的GC Tuner来,怎么调优就不是本文要讨论的了。
相关的注意事项
其实不管是通过以前GOGC这个唯一控制项,还是现在GOGC+GOMEMLIMIT组合的方式,开发人员都应该对自身服务性能、资源占用有个清晰的认识。这就是说,在必要的部署机型下做压测应该常态化,这样才能在服务部署运维时有更清晰的认识,内存占用多少算是正常、不正常,负载多高应该选择扩容、缩容。
现在很多都已经容器化部署了,但是对于扩缩容依赖的CPU、MEM阈值要有认识。容器化部署隔离性好一点,如果存在混部,那对这里GOMEMLIMIT=?+GOGC=off还会有更好的认识,因为GC不及时可能导致混部的其他服务申请不到内存资源。
不管用那种GC调优,对服务自身的认识都是每一个开发人员所应该关注、提高的。