最近项目做上线前的压力测试,遇到一个”诡异”的问题,GameServer进程持有内存,即使在所有机器人下线后一段时间,内存依然没有归还OS。
问题描述
服务器启动后,内存占用大概600M,此时机器人开始陆续上线,服务器的内存使用逐渐增加,最高达到11GB。 大概持续1个小时后,机器人开始陆续下线,机器人全部下线后GameServer进程的RSS竟丝毫没有下降。当时有些困惑,Golang GC最迟2分钟就会触发1次,是程序BUG? 还是垃圾回收没触发?
内存采样
- top命令查看, RSS(RES)为11GB
- pprof对内存heap和allocs数据采样,发现inuse_space不到1GB
- 输出进程运行时信息, HeapSys(从OS分配的堆内存), HeapAlloc(进程已用内存), HeapIdle(未使用的内存), 分别为11GB, 0.8GB, 10.2GB
从内存采样结果来看,基本可以排除程序Bug和GC没触发这两种情况。
原因
查了很多资料,最后在Golang项目的issues
中找到了答案。内存分配器为什么持有10.2GB内存不归还OS?Golang项目组成员是这样解释的:
- 进程把持有的内存归还给OS或向OS重新分配内存都会有一定代价。
- 应用程序通常不会有这样的峰值,如果你的程序运行在服务器或容器中,通常会有足够的内存。所以及时归还没有意义。原文
解决方法
在文中作者给出了方法,调用debug.FreeOSMemory()
函数手动触发垃圾回收并主动把多余的内存归还OS, 我在Win10和Linux两个平台分别做了实验。
- Win10上调此函数时,会立即把多余内存归还给OS。
- Linux上调此函数时,无效。 根据官方的解释,在
golang1.12
中,Linux内核版本>4.5采用的是MADV_FREE方式来释放内存,当OS有内存压力时,才会把多余内存归还给OS。即使调用FreeOSMemory()
也不会生效。原文
解决:Linux平台可以给进程设置环境变量,禁用MADV_FREE。GODEBUG=madvdontneed=1 ./GameServer
之前一直有误解,被GC的内存直接归还OS。但事实并非如此,Golang的内存分配器会一直持有GC的内存,直到OS有内存压力或调用debug.FreeOSMemory()
手动归还。
为了弄清楚这一问题,我看了一遍Golang内存分配流程。
一、概念及名词解释
- 页(Page):进程中的地址都是虚拟地址,地址总线指向的物理地址才是真实地址,页表的作用就是为了实现虚拟地址和物理地址的转换,页大小默认为4KB。详情参考
- 伪共享(false sharing):现代多核CPU的缓存一般分为三级,每个核上都一、二级缓存,而第三级缓存则是多个核之间共享。当Core从内存取数据时,一次性会取CacheLine大小的数据到自己的缓存中,然后存进CacheLine。但如果Core1、Core2同时读取了变量A或者它们CacheLine有交集,假如Core1先修改,则Core2的CacheLine会失效(CacheMiss),只有在Core1数据写回内存后,Core2重新读取, 这样造成了本来应该是并行的操作变成线性。这种情况在多核高并发的场景下影响很大。Golang内存分配器为了规避这种情况,在central上通过pad来填充不够CacheLineSize的部分,避免CacheMiss以提高多核并发时的性能。
- RSS(Resident Set Size):常驻内存大小,当前进程已经分配的内存大小。用top命令可以看到RSS(RES)。
- MADV_FREE:当应用程序不需要addr,len区间的页时,内核只会在OS内存有压力时释放。在此之前,这些页只是被标记为待释放的,当这些页即将被写时才会触发释放。原文
- Span: Golang内存管理的基本单位,一个sapn包含多个页。
1 | type mspan struct { |
span会被切分成8byte ~ 32KB不等obj class1 ~ class66, 例如:class2把大小为8K的span分成了512个16B的obj,当newobj时,根据对象大小,找到相应的class进行分配,比如 8byte < obj <=16byte 会用class为2的sapn分配。>32KB的对象会用class0分配。
1 | class bytes/obj bytes/span objects tail waste max waste |
- 小对象:对象大小<=16B, 之所以是16B是在浪费空间和可合并性之间做的权衡,小对象在class为2的span(noscan)上,微分配器根据对象实际大小来分配。
- 普通对象:对象大小<=32KB, 在class1~class66的sapn上分配。
- 大对象:对象大小>32KB, 分配时会跳过mcache、mcentral直接在mheap上分配, 所对应的class值为0
- mcache:每个P都有一个mcache, 当分配<=32KB的小对象时,首先在当前P的mcache中分配,因为P中任意时刻只有1个goroutine在执行,所以mcache中分配对象不需要加锁
1 | type mcache struct { |
- mcentral: 全局缓存(包含在mheap中),当mcache分配不成功,会向mcentral申请,因为mcentral是全局的,向mcentral申请需要加锁(多个P可能同时向mcentral申请)。当申请成功后,把对应的span从nonempty移到empty,并设置回P
1 | type mcentral struct { |
- mheap: mheap是一个全局对象(包含了mcentral),在runtime.mallocinit()中初始化,包含了所有分配过的span和种规格(class1~66)的central,这样做可以减少锁的粒度,例如:从central申请class为2的span时,只用对central[2]加锁,不用对整个central加锁,central中的pad是为了避免伪共享(false sharing)而填充的字节。
1 | type mheap struct { |
二、分配流程
1.计算对象大小
2.在mcahe.alloc中找到对应大小的span,若对应span的obj已经用完。
3.在heap的central中分配
4.在heap的arena中分配
5.向OS申请
下面是内存分配流程的部分源代码(go1.12.7),任何一步成功分配到span都会返回给mcache
三、源码分析
1 | //内存分配开始函数 |