记一次内存占用异常排查 —— memory ballast 被分配了物理内存
memory ballast 的概念这里不再赘述,相信在使用 Golang 的读者应该都知道。确实不了解的话可以阅读提出这个概念的文章,里面有详细的描述。这几年,ballast 被大量运用,在大家的认知里,ballast 是降低 GC 频率的一个简单、实用的方法,我也一直没有看到过关于它的负面报道 —— 直到这次之前。
在 golang-nuts 邮件列表中,也有一个关于这个问题的讨论,但对 golang 了解不多,或者英文不太好的读者可能会一头雾水。本文会对这个问题的来龙去脉做一个简单易懂的概述。如果有错误,欢迎指正。
背景
最近遇到,总有一小部分实例,内存(RSS)占用比其他实例大。而且和正常的实例相比,经过反复排查也没有看出它们的环境有明显的差异。
后面发现,这些实例的 ballast 都整个地被分配了物理内存,并且是启动、创建 ballast 时就这样。
原因
OS(熟悉者可跳过)
众所周知,现代操作系统,尤其是类 Unix 系统中,虚拟内存机制被广泛使用。用户进程对内存的申请、访问等都是在虚拟地址空间内进行的,当进程访问内存时,才会通过“缺页异常”中断,调入对应的内存分页。
比如,当 Go runtime 申请了一块大小为 1GB 的连续内存时,会在虚拟地址空间中得到一段长度为 1GB 的地址,但在它被访问之前,OS 并不会调入对应的物理内存分页,此时也不会占用 1GB 的物理内存。这是 ballast 的理论基础。
ballast 通常的实现是,申请一个大切片,并设置它 KeepAlive(防止 Go 帮倒忙把它优化掉),然后保持它存在但永不访问它,这样结果就不会占用物理内存,同时会占着堆内存,使得 GC 的触发频率降低。
而事实上却出现了 ballast 占用物理内存的情况,最容易想到的原因是 Go runtime 在创建 ballast 大切片时访问了它。
Go runtime
在 Go 的内存分配机制中,大于 32KB 的内存属于大内存,是通过 mheap 分配的。Go 语言原本对应章节中有提到一个“清零”操作。如果在分配 ballast 的内存时,发生了这个清零操作,结果似乎就是会发生 ballast 吃内存的情况。Go 语言原本里没有介绍如何判断是否需要清零。
关于清零,在开头提到的邮件列表里,Golang 团队的开发者,也是下文将提到的 go1.19 GC 相关新特性的提出者,Michael Knyszek 进行了一段回复(译文):
runtime 有一个简单的启发式方法来避免清零操作,但它远非完美。 因此,ballast 本质上总是会有一点风险。 在某些平台上尤其如此,例如 Windows,因为无法避免将内存标记为已提交(Windows 可以自由地对范围内的内存使用按需分页,因此整体系统内存压力可能会增加,但您不能避免将其计为特定进程的已提交)。
判断的具体逻辑(Github 地址):
|
|
注:原子操作 Casuintptr 的作用是,如果 p1 == p2,则 p1 = p3 并 return 1;否则无操作,return 0。
它会去遍历此次分配内存将涉及到的各个 arena(Go 内存分配中的一类大对象,详见 Go 语言原本),分别检查它们的 zeroedBase(值越大说明无需清零的内存越少),判断是否需要清零,并会增大 zeroedBase 的值。即,它的值可以理解为已被分配过、需要清零的值。需要注意的是,只要有一个 arena 符合 arenaBase < zeroedBase,都是整体地返回 true。
可以看出,arena 里已经被分配过又回收的内存,再次分配给 ballast 时,这次分配就会被判断为需要清零,进而出现开头描述的问题。因为 ballast 通常都是在启动早期创建的,在它之前分配的内存很少,所以这是个概率较小的事件,但确实存在。
建议
对于仍在继续使用 ballast 的读者,为了预防此问题,建议考虑以下方案替代它。
memory target
这是 1.19 的新功能,可以设置一个固定数值的,GC 触发的目标堆大小。有两种方法:
- 环境变量 GOMEMLIMIT。设置为数字,单位 byte;也可以用数字加单位如 1MiB,1GiB。
- debug.SetMemoryTarget(limit int64),单位也是 byte
这个功能是为了替代 ballast 设计的,当它被设置后,runtime 会通过多种方法,包括调整 GC 触发频率、返还内存给操作系统的频率等,尽量使内存不超过它。它测量内存是否达到限制的指标是 go runtime 管理的所有内存,相当于 memStats 中 Sys - HeapReleased 的值。它的效果理论上类似且优于 ballast。
使用它限制内存时,可以关闭按比例的 GC(GOGC=off),或将其比例调大。
不过,它和 ballast 一样,不是硬限制,不要把它的值设置为环境允许的内存占用极限。
gc tuner
对于旧版本的 golang,还有一个方案是由 uber 提出的的。思路是动态地调整 GC 触发的比例。有两个开源实现:cch123/gogctuner、bytedance/gopkg/util/gctuner。
仍然使用 ballast
如果想继续使用 ballast ,我想以下两点可能有助于降低该问题发生的概率:
- 尽量早创建 ballast
- 在创建 ballast 前关闭 GC