14

一个 Go 程序不释放内存的问题

 3 years ago
source link: https://www.zenlife.tk/go-scavenge.md
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

一个 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 认为自己释放了,但进程内存还占着,只有操作系统内存压力较大才释放

期待未来会好一点。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK