一、背景
朋友的一个服务,某个集群内存的
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 监控线程
sysmon执行一个无限循环,一开始每次循环休眠20us,之后(1ms后)每次休眠时间倍增,最终每一轮都会休眠 10ms。
sysmon主要如下几件事
- 释放闲置超过
5分钟的span物理内存,scavenging。(Go 1.12之前)
- 如果超过两分钟没有执行垃圾回收,则强制执行
GC。
- 将长时间未处理的
netpoll结果添加到任务队列
- 向长时间运行的
g进行抢占
- 收回因为
syscall而长时间阻塞的p
三、问题排查过程
3.1 内存泄露?
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除了程序真正使用的内存,还包括: