一、背景
朋友的一个服务,某个集群内存的RSS
使用率一直在80%
左右,他用的是8核16G
, 双机房一共206
个实例。
但是在pprof
里面查的堆内存才使用了6.3G
左右,程序里面主要用了6G
的LocalCache
所以heap
用了6.3G
是符合预期的。
朋友让我帮忙看下,额外的内存到底是被啥占用了。
二、基础知识
2.1 TCMalloc 算法
Thread-Caching Malloc 是Google
开发的内存分配算法库,最开始它是作为Google
的一个性能工具库perftools
的一部分。
TCMalloc
是用来替代传统的malloc
内存分配函数。它有减少内存碎片,适用于多核,更好的并行性支持等特性。
2.2 mmap 函数
mmap
它的主要功能是将一个虚拟内存区域
与一个磁盘上的文件
关联起来,以初始化这个虚拟内存区域的内容,这个过程成为内存映射(memory mapping
)。
直白一点说,就是可以将一个文件
,映射到一段虚拟内存
,写内存的时候操作系统会自动同步内存的内容到文件。内存同步到磁盘,还涉及到一个PageCache
的概念,这里不去过度发散,感兴趣朋友可以自己搜下。
文件
可以是磁盘上的一个实体文件
,比如kafka
写日志文件的时候,就用了mmap
。
文件
也可以是一个匿名文件
,这种场景mmap
不会去写磁盘,主要用于内存申请的场景。比如调用malloc
函数申请内存,当申请的大小超过MMAP_THRESHOLD
(默认是128K
)大小,内核就会用mmap
去申请内存。再比如TCMalloc
也是通过mmap
来申请一大块内存(匿名文件
),然后切割内存,分配给程序使用。
网上很多资料一介绍mmap
,就会说到zero copy
,就是相对于标准IO
来说少了一次内存Copy
的开销。让大多数人忽略了mmap
本质的功能,认为mmap=zero copy
还有一个值得一说的mmap
申请的内存不在虚拟地址空间的堆区
,在内存映射段(Memory Mapping Region)
2.3 Golang 内存分配
Golang的内存分配 是用的 TCMalloc
(Thread-Caching Malloc
)算法, 简单点说就是Golang
是使用 mmap 函数去操作系统申请一大块内存,然后把内存按照 0~32KB``68
个 size
类型的mspan
,每个mspan
按照它自身的属性 Size Class 的大小分割成若干个object
(每个span默认是8K),因为分需要gc
的mspan
和不需要gc
的mspan
,所以一共有136
种类型。
mspan
:Go
中内存管理的基本单元,是由一片连续的8KB
的页组成的大块内存,每个mspan
按照它自身的属性Size Class
的大小分割成若干个object
,mspan
的Size Class
共有68种(算上0) , numSpanClasses = _NumSizeClasses << 1 (因为需要区分需要GC和不需要GC的)
mcache
:每个工作线程都会绑定一个mcache
,本地缓存可用的mspan
资源。
mcentral
:为所有 mcache
提供切分好的 mspan
资源。需要加锁
mheap
:代表Go
程序持有的所有堆空间,Go
程序使用一个mheap
的全局对象_mheap来管理堆内存。
Go
的内存分配器在分配对象时,根据对象的大小,分成三类:小对象(小于等于16B
)、一般对象(大于16B
,小于等于32KB
)、大对象(大于32KB
)。
大体上的分配流程:
>32KB
的对象,直接从mheap
上分配;(16B,32KB]
的对象,首先计算对象的规格大小,然后使用mcache
中相应规格大小的mspan
分配;<=16B
的对象使用mcache
的tiny
分配器分配;
如果mcache
没有相应规格大小的mspan
,则向mcentral
申请
如果mcentral
没有相应规格大小的mspan
,则向mheap
申请
如果mheap
中也没有合适大小的mspan
,则向操作系统申请
2.4 TCMalloc 的内存浪费
Golang
的 sizeclasses.go 源码里面已经给我们已经计算了出每个size
的tail waste
和max waste
比例
// class bytes/obj bytes/span objects tail waste max waste min align
// 1 8 8192 1024 0 87.50% 8
// 2 16 8192 512 0 43.75% 16
// 3 24 8192 341 8 29.24% 8
// 4 32 8192 256 0 21.88% 32
// 5 48 8192 170 32 31.52% 16
// 6 64 8192 128 0 23.44% 64
// 7 80 8192 102 32 19.07% 16
// 8 96 8192 85 32 15.95% 32
// 9 112 8192 73 16 13.56% 16
// 10 128 8192 64 0 11.72% 128
.... 略
// 58 14336 57344 4 0 5.35% 2048
// 59 16384 16384 1 0 12.49% 8192
// 60 18432 73728 4 0 11.11% 2048
// 61 19072 57344 3 128 3.57% 128
// 62 20480 40960 2 0 6.87% 4096
// 63 21760 65536 3 256 6.25% 256
// 64 24576 24576 1 0 11.45% 8192
// 65 27264 81920 3 128 10.00% 128
// 66 28672 57344 2 0 4.91% 4096
// 67 32768 32768 1 0 12.50% 8192
我们看下tail waste
和max waste
的计算方式,源码如下:
spanSize := c.npages * pageSize
objects := spanSize / c.size
tailWaste := spanSize - c.size*(spanSize/c.size)
maxWaste := float64((c.size-prevSize-1)*objects+tailWaste) / float64(spanSize)
alignBits := bits.TrailingZeros(uint(c.size))
if alignBits > pageShift {
// object alignment is capped at page alignment
alignBits = pageShift
}
for i := range minAligns {
if i > alignBits {
minAligns[i] = 0
} else if minAligns[i] == 0 {
minAligns[i] = c.size
}
}
sizeclase=8
的时候obj= 96
,所以tailWaste = 8192%96 = 32
,maxWaste = ((96-80-1)* 85 + 32)/ 8192 = 0.1595
2.5 Go 查看内存使用情况几种方式
执行前添加系统环境变量
GODEBUG='gctrace=1'
来跟踪打印垃圾回收器信息,具体打印的内容含义可以参考官方文档。gctrace: 设置gctrace=1会使得垃圾回收器在每次回收时汇总所回收内存的大小以及耗时, 并将这些内容汇总成单行内容打印到标准错误输出中。 这个单行内容的格式以后可能会发生变化。 目前它的格式: gc # @#s #%: #+#+# ms clock, #+#/#/#+# ms cpu, #->#-># MB, # MB goal, # P 各字段的含义: gc # GC次数的编号,每次GC时递增 @#s 距离程序开始执行时的时间 #% GC占用的执行时间百分比 #+...+# GC使用的时间 #->#-># MB GC开始,结束,以及当前活跃堆内存的大小,单位M # MB goal 全局堆内存大小 # P 使用processor的数量 如果信息以"(forced)"结尾,那么这次GC是被runtime.GC()调用所触发。 如果gctrace设置了任何大于0的值,还会在垃圾回收器将内存归还给系统时打印一条汇总信息。 这个将内存归还给系统的操作叫做scavenging。 这个汇总信息的格式以后可能会发生变化。 目前它的格式: scvg#: # MB released printed only if non-zero scvg#: inuse: # idle: # sys: # released: # consumed: # (MB) 各字段的含义: scvg# scavenge次数的变化,每次scavenge时递增 inuse: # MB 垃圾回收器中使用的大小 idle: # MB 垃圾回收器中空闲等待归还的大小 sys: # MB 垃圾回收器中系统映射内存的大小 released: # MB 归还给系统的大小 consumed: # MB 从系统申请的大小
代码中使用
runtime.ReadMemStats
来获取程序当前内存的使用情况var m runtime.MemStats runtime.ReadMemStats(&m)
通过
pprof
获取http://127.0.0.1:10000/debug/pprof/heap?debug=1 在输出的最下面有MemStats的信息 # runtime.MemStats # Alloc = 105465520 # TotalAlloc = 334874848 # Sys = 351958088 # Lookups = 0 # Mallocs = 199954 # Frees = 197005 # HeapAlloc = 105465520 # HeapSys = 334954496 # HeapIdle = 228737024 # HeapInuse = 106217472 # HeapReleased = 218243072 # HeapObjects = 2949 # Stack = 589824 / 589824 # MSpan = 111656 / 212992 # MCache = 9600 / 16384 # BuckHashSys = 1447688 # GCSys = 13504096 # OtherSys = 1232608 # NextGC = 210258400 # LastGC = 1653972448553983197
2.6 Sysmon 监控线程
Go Runtime
在启动程序的时候,会创建一个独立的M
作为监控线程,称为sysmon
,它是一个系统级的daemon
线程。这个sysmon
独立于GPM
之外,也就是说不需要P
就可以运行,因此官方工具go tool trace
是无法追踪分析到此线程。
sysmon
执行一个无限循环,一开始每次循环休眠20us
,之后(1ms
后)每次休眠时间倍增,最终每一轮都会休眠 10ms
。
sysmon
主要如下几件事
- 释放闲置超过
5
分钟的span
物理内存,scavenging
。(Go 1.12之前) - 如果超过两分钟没有执行垃圾回收,则强制执行
GC
。 - 将长时间未处理的
netpoll
结果添加到任务队列 - 向长时间运行的
g
进行抢占 - 收回因为
syscall
而长时间阻塞的p
三、问题排查过程
3.1 内存泄露?
服务内存不正常,本能反应是不是内存泄露了?朋友说他们服务内存一周内一直都是在80%~85%
左右波动,然后pprof
看的heap
的使用也是符合预期的。看了下程序的Runtime
监控,容器的内存监控,都是正常的。基本可以排除内存泄露的可能性。
3.2 madvise
排除了内存泄露的可能性,再一个让人容易想到的坑就是madvise
,这个感觉是GO 1.12
~ Go 1.15
版本,被提到很多次的问题。
什么是 madvise ?
madvise() 函数建议内核,在从addr
指定的地址开始,长度等于len
参数值的范围内,该区域的用户虚拟内存应遵循特定的使用模式。内核使用这些信息优化与指定范围关联的资源的处理和维护过程。如果使用madvise()函数的程序明确了解其内存访问模式,则使用此函数可以提高系统性能。”
MADV_FREE
:(Linux 4.5
以后开始支持这个特性),内核在当出现内存压力时才会主动释放这块内存。MADV_DONTNEED
:预计未来长时间不会被访问,可以认为应用程序完成了对这部分内容的访问,因此内核可以立即释放与之相关的资源。
Go Runtime 对 madvise 的使用
在Go 1.12
版本的时候,为了提高内存的使用效率,把madvise
的参数从MADV_DONTNEED
改成MADV_FREE
,具体可以看这个CR,然后又加个debug
参数来可以控制分配规则改回为MADV_DONTNEED
,具体可以看这个CR
runtime
中调用madvise
的代码如下:
var adviseUnused = uint32(_MADV_FREE)
func sysUnused(v unsafe.Pointer, n uintptr) {
// ... 略
var advise uint32
if debug.madvdontneed != 0 {
advise = _MADV_DONTNEED
} else {
advise = atomic.Load(&adviseUnused)
}
if errno := madvise(v, n, int32(advise)); advise == _MADV_FREE && errno != 0 {
// MADV_FREE was added in Linux 4.5. Fall back to MADV_DONTNEED if it is
// not supported.
atomic.Store(&adviseUnused, _MADV_DONTNEED)
madvise(v, n, _MADV_DONTNEED)
}
}
使用MADV_FREE
的问题是,Golang
程序释放的内存,操作系统并不会立即回收,只有操作系统内存紧张的时候,才会主动去回收,而我们的程序,都是跑在容器中的,所以造成了,我们容器内存使用快满了,但是物理机的内存还有很多内存,导致的现象就是用pprof
看的内存不一样跟看的RES
相差巨大。
由于MADV_FREE
导致的pprof
和top
内存监控不一致的问题,导致很多开发者在GO
的GitHub
上提issue
,最后Austin Clements
(Go
开源大佬)拍板,把MADV_FREE
改回了MADV_DONTNEED
,具体可以看这个CR
大佬也在代码里面做了个简单解释如下:
// On Linux, MADV_FREE is faster than MADV_DONTNEED,
// but doesn't affect many of the statistics that
// MADV_DONTNEED does until the memory is actually
// reclaimed. This generally leads to poor user
// experience, like confusing stats in top and other
// monitoring tools; and bad integration with
// management systems that respond to memory usage.
// Hence, default to MADV_DONTNEED.
该改动已经在 Go 1.16 合入了。我看了下朋友服务的GO
版本是1.17
,所以是MADV_FREE
的问题基本也可以排除了。
2.3 memory scavenging
既然排除了内存泄露
,然后也不是madvise()
的问题,只能猜想是不是内存是不是还没有归还给操作系统。
Go
把内存归还给系统的操作叫做scavenging
。在Go
程序执行过程中,当对象释放的时候,对象占用的内存并没有立即返还给操作系统(为了提高内存分配效率,方式归还以后又理解需要申请),而是需要等待GC
(定时或者条件触发)和scavenging
(定时或者条件触发)才会把空闲的内存归还给操作系统。
当然我们也可以在代码里面调用debug.FreeOSMemory()
来主动释放内存。debug.FreeOSMemory()
的功能是强制进行垃圾收集,然后尝试将尽可能多的内存返回给操作系统。具体代码实现如下:
//go:linkname runtime_debug_freeOSMemory runtime/debug.freeOSMemory
func runtime_debug_freeOSMemory() {
GC() // 第一步强制 GC
systemstack(func() { mheap_.scavengeAll() }) // 第二步 scavenging
}
GC 触发机制
GO
的GC
触发可以分为主动触发和被动触发,主动触发就是在代码里面主动执行runtime.GC()
,线上环境我们一般很少主动触发。这里我们主要讲下被动触发,被动触发有两种情况:
当前内存分配达到一定比例则触发,可以通过环境变量
GOGC
或者代码中调用runtime.SetGCPercent
来设置,默认是100
,表示内存增长1
倍触发一次GC
。比如一次回收完毕后,内存的使用量为5M
,那么下次回收的时机则是内存分配达到10M
的时候。定时触发
GC
,这个是sysmon
线程里面干的时区,一般是2
分钟(runtime
中写死的)内没有触发GC
,会强制执行一次GC
。具体代码如下:// forcegcperiod is the maximum time in nanoseconds between garbage // collections. If we go this long without a garbage collection, one // is forced to run. // // This is a variable for testing purposes. It normally doesn't change. var forcegcperiod int64 = 2 * 60 * 1e9
// gcTriggerTime indicates that a cycle should be started when
// it's been more than forcegcperiod nanoseconds since the
// previous GC cycle.
gcTriggerTime
// check if we need to force a GC
if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && atomic.Load(&forcegc.idle) != 0 {
lock(&forcegc.lock)
forcegc.idle = 0
var list gList
list.push(forcegc.g)
injectglist(&list)
unlock(&forcegc.lock)
}
scavenging 触发机制
GO 1.12
之前是通过定时触发,2.5min
会执行一次scavenge
,然后会回收超过5分钟内没有使用过的mspan
,具体源码如下:
// If a heap span goes unused for 5 minutes after a garbage collection,
// we hand it back to the operating system.
scavengelimit := int64(5 * 60 * 1e9)
// scavenge heap once in a while
if lastscavenge+scavengelimit/2 < now {
mheap_.scavenge(int32(nscavenge), uint64(now), uint64(scavengelimit))
lastscavenge = now
nscavenge++
}
这样会有个问题是,如果不停的有大量内存申请和释放,会导致mspan
内存一直不会释放给操作系统
(因为不停被使用然后释放),导致堆内存监控和RSS
监控不一致。具体可以看 runtime: reclaim memory used by huge array that is no longer referenced 这个Issue
,还有一个问题因为内存释放不及时,容易在低内存的设备上OOM
,具体可以看 Running Go on Low Memory Devices
这个文章。
基于以上这些问题,Austin Clements
大佬提交了一个Issue
:runtime: make the scavenger more prompt,Austin Clements
提出如果我们只考虑在scavenge
阶段需要释放多少个mspan
,这个是比较难的。我们应该分离关注点,通过关注释放和重新获得内存的成本
,下次GC的堆大小
,我们愿意承担的CPU和内存开销
来计算出应该释放多少mspan
,提议保留的内存大小应该是过去一段时间内,堆内存回收大小的峰值乘以一个常数,计算回收方式如下:
retain = C * max(current heap goal, max({heap goals of GCs over the past T seconds}))
C = 1 + ε = 1.1
T = 10 seconds
这个提议2016.08.31
提出以后,但是一直没有人去实现。
直到2019.02.21
的时候Michael Knyszek
重新提了一个Proposal
:runtime: smarter scavenging。
这个Proposal
目标是:
- 降低
Go
应用程序的RSS
平均值和峰值。 - 使用尽可能少
CPU
来持续降低RSS
。
runtime
做内存回收策略,有三个关键问题
- 内存回收的速率是多少?
- 我们应该保留多少内存?
- 什么内存我们应该归还给操作系统?
实现方法
Scavenge
速度应该与程序Alloc
内存的速度保持一致。- 保留的内存大小应该是一个常量乘以过去
N
次GC
的峰值。runtime: make the scavenger more prompt - 在
unscavenged spans
中,优先清除基地址高的。
上面的Proposal
主要提交如下:
runtime: add background scavenger
runtime: remove periodic scavenging
结论
上面,我们知道了pprof
抓的堆内存的大小和RSS
不一致,有几种可能:
- 是程序申请的内存还没有被
GC
。 - 内存虽然被
GO
执行了GC
,但是可能并没有归还给操作系统(scavenging
)。
为了验证一下上面的结论,我上机器抓了下heap
的统计:
nx-x-x(service@stock:prod):ss# curl http://ip:port/debug/pprof/heap?debug=1 | grep Heap
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0# 0xa47ba9 runtime/pprof.writeHeapInternal+0xc9 /usr/local/go/src/runtime/pprof/pprof.go:566
# 0xa47a46 runtime/pprof.writeHeap+0x26 /usr/local/go/src/runtime/pprof/pprof.go:536
100 913M 0 913M 0 0 86.8M 0 --:--:-- 0:00:10 --:--:-- 90.6M# 0xa47ba9 runtime/pprof.writeHeapInternal+0xc9 /usr/local/go/src/runtime/pprof/pprof.go:566
# 0xa47a46 runtime/pprof.writeHeap+0x26 /usr/local/go/src/runtime/pprof/pprof.go:536
# HeapAlloc = 11406775960
# HeapSys = 13709377536
# HeapIdle = 2032746496
# HeapInuse = 11676631040
# HeapReleased = 167829504
# HeapObjects = 49932438
这里我主要关注几个参数:
HeapInuse: 堆上使用中的mspan
大小。
HeapReleased:归还了多少内存给操作系统。
HeapIdle:空闲的mspan
大小。HeapIdle - HeapReleased
等于runtime
持有了多少个空闲的mspan
,这部分还没有释放给操作系统,在pprof
的heap
火焰图里面是看不到这部分内存的。
stats.HeapIdle = gcController.heapFree.load() + gcController.heapReleased.load()
上面我们获取机器的内存信息如下
HeapInuse = 11676631040 ≈ 10.88G // 堆上使用内存的大小
HeapIdle - HeapReleased = 2032746496 - 167829504 ≈ 1.73G // 可以归还但是没有归还的内存
两个加起来,也差不多12~13G
左右,所以容器的内存使用率是80%
也是符合预期的。
还有个问题,为什么我们程序的localcache
大小设置的只有了6G
,实际heap
使用了10.88G
,因为HeapInuse
除了程序真正使用的内存,还包括:
- 程序释放的内存,但是还没有被
GC
。这部分内存还是算在HeapInuse
中(这个应该是大头)。 - 上面说的
mspan
的max waste
和tail waste
这部分也在HeapInuse
(这个应该很少)。 - 假设一个
8k
的mspan
上只使用了一个大小为8Byte
的obj
,这个在HeapInuse
会算8K
。
总结
golang
堆内存大小不一定跟RSS
一致,它跟GC
、scavenging
时机有关。如果有大量lcoalcache
申请释放,很可能导致RSS
远远大于heap
使用的大小。