一个 Go 程序不释放内存的问题
2020-01-19
Go 的内存管理可以算是两层的,执行垃圾回收以后,没有用到的内存会归还给 Go 的 runtime,这是一层。然后 Go 的 runtime 什么时候把没有使用到的内存,再归还给操作系统,这是第二层。 最近遇到一个内存不释放问题,发生在第二层,即 Go 的 runtime 没有及时地把内存归还给操作系统。
查了一下相关的 issue,这个 issue 里面讲了内存回收归还给系统的问题,它希望做一个更好的清扫(scavenge)策略。
假设内存使用有一个峰值后没有再使用了,但是 Go 没把内存及时归还给系统。系统在内存不足的时候可能把这个进程杀掉,也就是 OOM Kill。用户可以自己调用 debug.FreeOSMemory,这个调用会执行 GC 然后把未使用的内存归还给系统。但是,
- 一次将内存都归还给系统,会有问题。这个操作太重了。会有延迟抖动,因为涉及到了 lock
- 需要用户自己调这个函数,对代码是有侵入性的
- 再次重用内存的时候会有较多开销,因为有 page fault
Go1.11 以前,最初的策略是两分半钟的时间周期性的跑一次,这个操作叫 scavenge,然后如果发现一块内存在 5 分钟都没有被使用过,就归还空间给系统。
定期扫的问题是,这个归还给系统的策略很不实时。比如说应用如果有一波 5 分钟一次周期性的内存峰值的场景,内存占用就会一直是峰值那么多。另外,如果全部还给操作系统了,后面又需要重新全部申请回来,这个开销也挺大。
改进的目标之一是尽量让进程占用的物理内存量准确,另一个是不会有太多额外开额,比如增加 CPU 负担或者导致更多缺页中断。尽量让 RSS 跟实际的堆内存使用量一致,但是也要留足够的空间给下次分配,避免每次找操作系统申请。尽量的平滑。
这里提一下向操作系统的申请和释放。用 mmap 获取到的是进程的虚拟地址,当进程实际访问到这个地址的时候,会发生缺页中断,然后操作系统会建立虚拟地址和物理地址之间的映射关系,分配物理内存页。 频繁的缺页中断,或者是修改这些映射关系,都会影响到进程的 TLB,这些都会有性能开销的,要走到操作系统层并引起一些抖动。
Go1.12 里面的改进,除了周期性的清理,还增加了一个 heap growth 的触发清理。也就是说 heap 使用的增长也有机会触发 scavenge,这样就可以让回收更加及时。
不过 1.12 的策略里面,包括一个保留最近 N 次 GC 操作后的使用量峰值:
Retain some constant times the peak heap goal over the last N GCs
它的理由是,通过前面 N 次 GC 的最大值,可以让延迟更加平滑一点。保留一段时间,可以避免频繁向操作系统申请和释放,减少缺页中断的发生。这个改动其实是有一点问题的,保留最近 N 次 GC 后的堆内存的峰值,会引起内存不释放。
它的一个假设是,进程的内存使用量基于处于一个 steady 状态。其实假设是有点问题的,因为很多业务场景就是一个波峰一个波峰的。最正确的做法可能是,评估释放需要多少成本,重新获取内存要多少成本。释放后需要重新获取的概率是多大等等,这些综合因素来决定,当然这只是理想中的情况。
runtime 会保留一部分内存不归还系统,以备接下来的申请操作。在 1.11 以前的版本,分配的时候用的是 best fit 策略,1.12 里面改成 first fit 了。另外还有一个细节是,在 1.2 里面,把哪些内存归还给系统了,哪些没有归还,分开考虑。在重用的时候,这种分开考虑会让分配操作倾向于新的虚拟空间,涉及一些虚拟空间碎片的影响。
频繁归还和申请的策略会导致内存碎片过多的问题,影响应用的性能。比如说这种 workload 分配一块大的连续内存,释放它。再分配一些小内存,这时会在原来大块内存里面切。然后再分一次大内存,这时只能开辟新区域,如此反复。这样会造成虚拟空间有很多的洞洞。
在 Go1.12 里面有归还问题:一个突发大查询之后,会有一直占用不释放。
完全不释放不行,到了 Go1.13,做的是更激进一点的归还策略,更激进的释放策略其实是一个平滑,不是保留 N 次里面最高的,而是根据情况做一个平滑的函数。
关于释放操作,是调用操作系统函数 madvise
。madvise 是有参数控制释放的行为的。1.13 里面用的 MADV_FREE
,而之前用的是 MADV_DONTNEED
,就是说以前释放得比较激进。
这两者行为差异是,MADV_DONTNEED
告诉操作系统,这块虚拟地址我不再使用了,你直接释放掉物理页吧。而 MADV_FREE
是告诉操作系统,我不用了,你可以释放这些内存页了,不过释放的时机你自己定,可以推迟到整个系统内存压力比较大的时候再释放都行。
所以到了 Go1.13,还是有看起来内存不释放的问题,分歧是在于,Go 的 runtime 认为调用过 MADV_FREE 之后就算归还给操作系统了的。而操作系统认为,只有内存压力比较大的时候,才去处理 MADV 的 hint 真正释放物理页。
可以设置 GODEBUG=madvdontneed=1 控制这个行为,让系统归还激进一些。
将来(1.13+),HeapSys 不会再下降,因为后面 runtime 不会再 munmap 内存了,而是改为调用 madvise 并增加 HeapReleased。用 HeapSys - HeapRelease 应该是接近实际内存使用量的,不考虑碎片问题的话。
结论
- 1.11 以前的,能释放没毛病,不过申请释放对性能有影响
- 1.12 的,单个大查询之后再无使用的场景,内存很难被释放
- 1.13 的,runtime 认为自己释放了,但进程内存还占着,只有操作系统内存压力较大才释放
期待未来会好一点。