https://pengrl.com/p/20033/
事情是这样的,线上一个服务,启动后RSS随任务数增加而持续上升,但是过了业务高峰期后,任务数已经下降,RSS却没有下降,而是维持在高位水平。
那内存到底被谁持有了呢?为了定位问题,我把进程的各项Go runtime内存指标,以及进程的RSS等指标持续采集下来,并以时间维度绘制成了折线图
https://github.com/q191201771/pprofplus
https://pengrl.com/p/21292/
RSS是Resident Set Size(常驻内存大小)的缩写,用于表示进程使用了多少内存(RAM中的物理内存),RSS不包含已经被换出的内存。RSS包含了它所链接的动态库并且被加载到物理内存中的内存。RSS还包含栈内存和堆内存。
VSZ是Virtual Memory Size(虚拟内存大小)的缩写。它包含了进程所能访问的所有内存,包含了被换出的内存,被分配但是还没有被使用的内存,以及动态库中的内存。
假设进程A的二进制文件是500K,并且链接了一个2500K的动态库,堆和栈共使用了200K,其中100K在内存中(剩下的被换出或者不再被使用),一共加载了动态库中的1000K内容以及二进制文件中的400K内容至内存中,那么:
RSS: 400K + 1000K + 100K = 1500K
VSZ: 500K + 2500K + 200K = 3200K
由于部分内存是共享的,被多个进程使用,所以如果将所有进程的RSS值加起来可能会大于系统的内存总量。
申请过的内存如果程序没有实际使用,则可能不显示在RSS里。比如说一个程序,预先申请了一大批内存,过了一段时间才使用,你会发现RSS会增长而VSZ保持不变。
还有一个概念是PSS,它是proportional set size(proportional是成比例的意思)的缩写。这是一种新的度量方式。它将动态库所使用的内存按比例划分。比如我们前面例子中的动态库如果是被两个进程使用,那么:
PSS: 400K + (1000K/2) + 100K = 400K + 500K + 100K = 1000K
一个进程中的多个线程共享同样的地址空间。所以一个进程中的多个线程的RSS,VSZ,PSS是完全相同的。linux下可以使用ps或者top命令查看这些信息。
https://pengrl.com/p/20031/
https://pengrl.com/p/24169/
http://man7.org/linux/man-pages/man2/madvise.2.html
http://www.man7.org/linux/man-pages/man2/mmap.2.html
https://github.com/golang/go/issues/23687
https://github.com/golang/go/issues/28466
本着DRY原则,我把采集和绘制部分专门制作成了一个开源库,业务方代码可以十分方便的接入,绘制出如上样式的折线图,并通过网页实时查看。git地址:https://github.com/q191201771/pprofplus
图中的指标,VMS和RSS是任何linux进程都有的。Sys、HeapSys、HeapAlloc、HeapInuse、HeapReleased、HeapIdle是Go runtime记录的内存情况。
VMS和RSS的含义可以看这篇: 《[译] linux内存管理之RSS和VSZ的区别》
Go runtime中的指标含义可以看这篇: 《Go pprof内存指标含义备忘录》
简单来说,RSS可以认为是进程实际占用内存的大小,也是一个进程外在表现最重要的内存指标。HeapReleased是Go进程归还给操作系统的内存。在 《如何分析golang程序的内存使用情况》 这篇老文章中,实验了随着垃圾回收,HeapReleased上升,RSS下降的过程。
但是这次的案例,从图中可以看到,HeapReleased上升,RSS却从来没有下降过。。
我们来具体分析。(以下我就不重复解释各指标的含义了,对照着看上面那两篇文章就好)
首先从业务的任务数来说,从启动时间03-13 17:47:17开始,是持续增长的,到22:17:17之后开始下降,再到03-14 16:17:27之后,又开始上升。之后就是循环反复。这是业务上实际内存需求的特点。
VMS和RSS的整体波形一致,维持在一定差值,符合预期。
Sys和RSS几乎重叠,说明确实是Go代码使用的内存,符合预期。
HeapSys和Sys的波形一致,维持在一个比较小的差值,说明大部分内存都是堆内存,符合预期。
HeapInuse和HeapAlloc是持续震荡的,波形一致,维持在一定差值,业务高峰期时上升,低峰期下降,符合预期。
HeapIdle在首次高峰前震荡上升,之后一直和HeapInuse的波形相反,说明起到了缓存的作用,符合预期。
HeapIdle和HeapReleased波形一致,符合预期。
那么回到最初的问题,为什么HeapReleased上升,RSS没有下降呢?
这是因为Go底层用mmap申请的内存,会用madvise释放内存。具体见go/src/runtime/mem_linux.go的代码。
madvise将某段内存标记为不再使用时,有两种方式MADV_DONTNEED和MADV_FREE(通过标志参数传入):
MADV_DONTNEED标记过的内存如果再次使用,会触发缺页中断
MADV_FREE标记过的内存,内核会等到内存紧张时才会释放。在释放之前,这块内存依然可以复用。这个特性从linux 4.5版本内核开始支持
显然,MADV_FREE是一种用空间换时间的优化。
在Go 1.12之前,linux平台下Go runtime中的sysUnsed使用madvise(MADV_DONTNEED)
在Go 1.12之后,在MADV_FREE可用时会优先使用MADV_FREE
具体见 https://github.com/golang/go/issues/23687
Go 1.12之后,提供了一种方式强制回退使用MADV_DONTNEED的方式,在执行程序前添加GODEBUG=madvdontneed=1。具体见 https://github.com/golang/go/issues/28466
ok,知道了RSS不释放的原因,回到我们自己的问题上,做个总结。
事实上,我们案例中,进程对执行环境的资源是独占的,也就是说机器只有这一个核心业务进程,内存主要就是给它用的。
所以我们知道了不是自己写的上层业务错误持有了内存,而是底层做的优化,我们开心的用就好。
另一方面,我们应该通过HeapInuse等指标的震荡情况,以及GC的耗时,来观察上层业务是否申请、释放堆内存太频繁了,是否有必要对上层业务做优化,比如减少堆内存,添加内存池等。
好,这篇先写到这,最近还有两个线上实际业务的内存案例,也用到了上面的pprofplus画图分析