Go底层原理与工程化实践
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

1.3.2 Uber半自动化GC调优

2021年年初,Uber技术团队开始探索对GC调优的可能性。首先,该团队通过分析Go服务的CPU使用情况,发现在大部分关键Go服务中GC占用了大量CPU资源,如图1-3所示。

图1-3 GC的CPU使用率示例

函数runtime.scanobject是GC标记扫描阶段的核心函数,而标记扫描阶段也是整个GC流程中最耗时的阶段,所以该函数的CPU使用率可以代表GC的CPU使用率。从图1-3中可以看到,GC占用了24.05%的CPU资源。也就是说,GC调优对于节省CPU资源非常有意义。

另外,在1.3.1小节中也提到,Go语言提供了3种GC触发方式:申请内存、定时触发以及手动触发。同时,申请内存触发GC的方式依赖于环境变量GOGC,这使得我们可以简单地通过调整GOGC的值(增加GOGC)来实现GC调优(减少GC)。但是这样一来,Go服务就需要更多的内存。幸运的是,大多数Go服务所需的CPU与内存的比例为1:1~1:2,而Uber的主机CPU与内存的比例是1:5,因此完全可以利用更多的内存来减少GC。

不过,Uber技术团队在实现GC调优时,发现固定的GOGC并不适合Uber的服务。为什么呢?

首先,Uber的Go服务都是容器化部署,容器的最大内存限制是不同的,所以GOGC也应该根据不同Go服务配置不同的值。

其次,Go服务的流量是有波动的,内存使用量也是有波动的。低峰期Go服务的内存使用量可能只有100MB,但是高峰期Go服务的内存使用量可能达到1GB。如果采用固定的GOGC,可能会导致内存不足的问题,如图1-4和图1-5所示。

图1-4 正常流量,左侧GOGC采用默认值,右侧GOGC采用固定值

图1-5 双倍流量,左侧GOGC采用默认值,右侧GOGC采用固定值

参考图1-4与图1-5,Go服务容器的内存限制都是1GB。图1-4表示正常流量情况下,GOGC分别使用默认值(100)以及固定值(300)时触发GC的内存阈值。图1-5表示双倍流量情况下,GOGC分别使用默认值(100)以及固定值(300)时触发GC的内存阈值。可以看到,当遇到双倍流量情况时,如果GOGC依然采用固定值,触发GC的内存阈值将超过Go服务容器的内存限制,这将会导致内存不足的问题。

最终Uber技术团队通过动态调整GOGC解决上述问题。一方面,使用容器最大内存限制(从cgroup读取)的70%作为内存使用上限;另一方面,实时采集Go服务的内存使用量,并根据实时内存使用量以及内存使用上限动态调整GOGC。Uber半自动化GC调优示例如图1-6所示。

图1-6 双倍流量,左侧GOGC采用默认值,右侧GOGC动态调整

参考图1-6,Go服务容器的内存限制是1GB,Go服务的内存上限是700MB。图1-6表示双倍流量情况下,GOGC分别使用默认值(100)以及动态调整(133)时触发GC的内存阈值。可以看到,当动态调整GOGC时,触发GC的内存阈值并没有超过Go服务的内存上限,当然也就不会导致内存不足的问题。那么GOGC的动态值是怎么计算的呢?如下所示:

最后补充一点,Uber的半自动化GC调优方案需要实时采集Go服务的内存指标,其最初的方案是每秒采集一次,然后对应地调整GOGC。这种方案开销较大,并且准确率不高,因为Go服务每秒可以执行多次GC。幸运的是,Go语言有终结器(参考函数runtime.SetFinalizer),通过这个函数我们可以设置一个回调函数,该函数在对象将被垃圾回收时执行,也就是说只需要这个回调函数采集Go服务的内存指标并且动态调整GOGC即可。Uber技术团队最终采取的就是这一方案。

基于上述思路,Uber开发了半自动化调优库GOGCTuner。在几十个关键服务部署了GOGCTuner之后,Uber技术团队深入分析了这些服务的CPU利用率,发现仅这些服务就累计节省了7万个内核。