为什么 mallogc 在 25% 以内 Go 的程序才是健康的

2025-01-23

Go 语言真的是一门非常优秀的语言,因为它能容忍一些非常差的程序员,写一些非常 SB 的程序,居然还能跑得动。"能跑"跟性能好是两个概念,其实 SB 程序运行状态是很不健康的。

我想提出一个最简单的判断 Go 程序是否健(S)康(B)的标准:其实可以直接用 profile 的火焰图里面,mallocgc 函数是否超过 25% 来判断。如果低于 25%,那么这个程序基本还是可以容忍的,而如何高于了,则程序写得不太好。这是经过长期观察得出的一个经验。

背后的逻辑其实是这样子的:Go 语言的 runtime 可以拿出 1/4 的核,dedicated 给 GC 使用,主要是 GC mark 阶段。也就是说,让出 25% 的计算资源,让用户不再为对象的分配和回收而苦恼。正常的程序,如果不是对象分配特别重,并且用户业务的 CPU 使用量也没有超过 25% 的时候,非常丝滑,只享受好处而没有代价。

而如何程序写得很挫,对象分配特别重,会发生什么事情呢?会导致垃圾回收的速度,跟不上对象分配的速度!那这种情况如何处理呢?让所有对象分配的操作,都去帮 GC 干活,这个后果就是导致卡顿,因为涉及到分配的调用,都从用户代码逻辑走到 Go runtime 里面,然后会慢下来。这个程序需要为自己过多的对象分配交"税",卡顿就是税的体现。程序的分配是很重,可以用 mallocgc 来衡量,Go 程序所有的分配都会走到这个函数中来,体现到火焰图上面,就是它的占比越高,表示对象分配越重。超过 25% 的 mallogc 比例,就是表示程序不太健康,会受 runtime GC 的影响比较大。

有一个比喻对于带 GC 的程序特别贴切:程序的运行就像是搬着砖走路,负重前行。对象分配的活动,就是砖。分配越严重,就表示要搬的砖越重。一个成年男性,搬着一块砖走路,可能不会有太多的影响。搬着五块砖走路,就不太轻松了。而搬着十块二十块砖,走一段就得停下来歇一阵。这个停下来歇息,就等价于程序的卡顿。如何走得轻松一点?就是不背那么重的砖,也就是优化程序,减少对象分配。

有的人可能觉得不服:老子机器好,CPU 核多,内存足,就是干,管它程序怎么写得稀烂的。那还真不是这样的,Go 程序写得烂的时候会遇到两种奇怪的场景:一种是 CPU 就是用不上去,另一种是 CPU 严重抖动。跟大家想象中的程序性能差是"CPU 压满了,但是 QPS 就那样"不太一样。很多人不懂性能,他们能看懂的,就只有最简单的指标,CPU 用到多少,内存用到多少,这种最简单的指标,天真地认为,只要 CPU 没用满,那就是硬件还没有使用到瓶颈,增加测试的并发就可以继续往上压。但是瓶颈并不是只有计算瓶颈一种类型,有可能 IO 瓶颈,有可能网络,或者锁,或者调度等等...这些都不如 CPU 那么直观,所以它们对于能力要求更高。

Go 的 runtime 其实就是一个这种性质的瓶颈,它并不能容忍用户瞎JB写代码,当分配重到一定的程度,程序就会不健康。但是这个瓶颈又很难"量化",比 IO 更难量化一些,因为它的内部细节是更多维度的。我们说 IO 瓶颈,比如可以测试磁盘,在固定 workload 下,得到的 iops 是多少来衡量。但是这个 workload 是变化的时候,度量就更难,大对象是多少,小对象是多少,大小对象混合的时候比例是多少,写入性能都会得到不同的结果。而 Go 的对象分配,分配多大的对象,以什么样的速度分配,是什么样的机器配置,可能都会得到不一样的"瓶颈"的答案,最终无法得到一个可以"量化"的数值,来衡量对象分配是什么速率的时候,程序的表现是有问题的。问题的表现,CPU 就是打不上去了,或者 CPU 就是抖得厉害。

注意由 GC 导致的性能瓶颈,它不随着 CPU 核数增加而 scale;不随着内存的增加而 scale,反而是内存越多,问题越严重。假设应用写得挫,50% 的计算是浪费在 mallocgc 上面的,那么当 CPU 核数翻翻,程序不变,这个比例是 50%,就相当于对象分配在整个程序执行中的占比,只要不优化程序,这块并不随 CPU 核数增加而变好。36核机器跑得不健康,换到64核的机器上了跑仍然还是不健康的。道理就好比女人生娃需要10个月,并不会10个女人生娃只需要一个月。再说内存,假设内存的处理速度是 10G/s,从 64G 变成 128G,这个速度也还是这样的。区别是,假设程序使用了 64G 的内存,GC 要把全部内存空间扫描一遍,需要 6.4s 完成,而如果程序使用了 128G 的内存,GC 反而需要 12.8s 才能处理完。所以当内存越大,程序使用的内存量越多的时候,问题反而是变得更糟糕的。

除了 mallocgc 在火焰图中的占比,还有另一个很值得关注的项是大对象的分配。不扣细节的大概就是,mcentral 和 mcache 分别是全局的和每个线程级的分配内存块的管理,goroutine 会先从线程级的分配,这里是不涉及加锁的,相对更快。如果这一级的内存块耗尽了,就需要向全局管理级别去申请内存块,这时就会涉及到加锁和调度之类的开销。小对象分配更不容易把局部的对象块耗尽,而大块的内存分配,比如说 8k 大小的申请,就需要直接到更上一级的内存管理去分配,于是对性能影响也就越严重。大量的大块内存的情况,更容易造成程序的行为是 CPU 使用不上去,而性能也不行。

应用使用 75% 的资源,GC 使用 25% 的资源,大家和平共处,业务表现平稳只是一个很理想的情况。实际是,GC 的发生是一阵一阵的,负载不变的情况下,比如说压力是 80%,然后 GC 发生了,原本应用程序能占用的计算资源在 GC 的时刻就会下降,于是响应就会变慢,发生抖动。如果是做数据库的,用 Go 写的,它抖啊抖的,我们就会把这款产品叫做"帕金森数据库"。甚至在没什么负载的时候也抖啊抖,是因为 Go 的 GC 在检测到业务没什么负载时,它会更激进地把空闲的计算资源都拿去做 GC,使得 GC 能够更快速地完成,这样子就是 GC 时刻 CPU 的大量占用。

说再说 Go 语言不行了,老老实实地承认是自己不行,神仙语言都救不了。证明自己到底行不行,先把 mallocgc 弄到 25% 以内吧!

golanggc

HNS.to is a highly insecure way of browsing Handshake domains and should only be used for demo or educational purposes. Click to see preferable resolutions methods