Go内存泄漏?不是那么简单!

最近遇到一个Go内存不释放的问题,记录一下测试和调研的情况。我到不把它归为Go内存泄漏的问题,因为它和一般的内存泄漏的方式不同。

Go 常见内存泄漏的情况

Go程序可能会在一些情况下造成内存泄漏。go101网站总结了各种内存泄漏的情况,我在这里简单罗列一下:

  • 获取长字符串中的一段导致长字符串未释放
  • 同样,获取长slice中的一段导致长slice未释放
  • 在长slice新建slice导致泄漏
  • goroutine泄漏
  • time.Ticker未关闭导致泄漏
  • Finalizer导致泄漏
  • Deferring Function Call导致泄漏

内存回收分析

实际问题

写这篇文章的初衷是我在实现一个新项目的时候遇到一个问题。这个项目使用了一个缓存组件对请求的结果进行缓存,以提高请求的耗时。这个缓存组件对使用的最大内存进行了限制,比如缓存占用的最大内存为1GB。运行过程中可以对这个最大值进行调整,比如我们可以调整到100MB。在调整的过程中发现虽然最大内存从1GB调整到100MB之后,程序的RSS依然占用很大,一直是1GB+ ~ 2GB的内存,感觉内存并没有降下去。

可以看到缓存调小了它占用的内存确实降到几乎为0了:

但是释放的内存并没有返回给操作系统(HeapReleased)

当然经过相应的测试和调研之后,可以看到缓存的最大内存减少后占用内存和RSS也下降了:

仓促之间我只截取了很小一段时间的指标,实际观察很长时间也是这样。

测试程序

我在这个项目中实现了一个 LRU的cache, 这个cache基于内存管理,一旦使用的内存超过了MaxMemory,就会自动进行内存清理工作,将最不常用的缓存项删除,具体实现太长就不贴出来了,基本上就是map + container/list + sync.Mutex的实现,实现的接口如下:

1
2
3
4
5
6
type Cache interface {
AddValue(slot uint32, key string, value []byte)
GetAndValidate(key string, bizKey []byte) (value *CachedValue, ok bool)
SetMaxMemory(m int64)
Clear()
}

现在通过一个程序进行测试,分别测试测试前增加一千万条数据将最大内存从1G减少到1B强制垃圾回收四个动作之后的内存的使用情况,代码如下:

1
2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
➜ memoryleak git:(master) ✗ GODEBUG=gctrace=1 go run leak.go
gc 1 @0.027s 0%: 0.009+0.43+0.009 ms clock, 0.037+0.13/0.31/0.82+0.038 ms cpu, 4->4->0 MB, 5 MB goal, 4 P
gc 2 @0.048s 0%: 0.005+0.40+0.003 ms clock, 0.022+0.17/0.29/0.91+0.014 ms cpu, 4->4->0 MB, 5 MB goal, 4 P
……
gc 4 @0.063s 6%: 0.003+7.8+0.028 ms clock, 0.012+0.12/6.1/15+0.11 ms cpu, 23->24->22 MB, 24 MB goal, 4 P
gc 5 @0.121s 5%: 0.003+12+0.035 ms clock, 0.013+1.3/11/27+0.14 ms cpu, 42->43->40 MB, 44 MB goal, 4 P
🍀 before: inuse: 656 KB, idle: 63 MB, released: 0 B, heapsys: 63 MB, sys: 66 MB
gc 1 @0.007s 3%: 0.031+0.94+0.013 ms clock, 0.12+0.10/0.84/0.18+0.052 ms cpu, 4->4->4 MB, 5 MB goal, 4 P
gc 2 @0.013s 7%: 0.004+4.3+0.045 ms clock, 0.017+0.078/3.9/0.85+0.18 ms cpu, 7->8->8 MB, 8 MB goal, 4 P
……
gc 17 @22.855s 6%: 0.018+517+0.011 ms clock, 0.072+105/516/11+0.047 ms cpu, 2441->2644->1368 MB, 2553 MB goal, 4 P
🍀 added: inuse: 1 GB, idle: 942 MB, released: 0 B, heapsys: 2 GB, sys: 2 GB, current: 1023 MB
gc 18 @24.763s 6%: 0.015+87+0.009 ms clock, 0.063+6.0/83/13+0.038 ms cpu, 2568->2622->128 MB, 2737 MB goal, 4 P
gc 19 @25.295s 6%: 0.014+35+0.009 ms clock, 0.056+0.41/35/64+0.037 ms cpu, 247->263->90 MB, 257 MB goal, 4 P
gc 20 @25.397s 6%: 0.015+89+0.004 ms clock, 0.061+14/50/0.60+0.019 ms cpu, 173->194->95 MB, 181 MB goal, 4 P
gc 21 @25.551s 6%: 0.012+59+0.010 ms clock, 0.050+17/59/0.46+0.043 ms cpu, 175->207->105 MB, 191 MB goal, 4 P
🍀 after decreased: inuse: 156 MB, idle: 2 GB, released: 0 B, heapsys: 2 GB, sys: 2 GB, current: 0 B
gc 22 @25.651s 6%: 0.003+67+0.003 ms clock, 0.015+0/52/14+0.012 ms cpu, 156->156->74 MB, 211 MB goal, 4 P (forced)
scvg-1: 2740 MB released
scvg-1: inuse: 75, idle: 2740, sys: 2815, released: 2740, consumed: 75 (MB)
🍀 after gc: inuse: 75 MB, idle: 2 GB, released: 2 GB, heapsys: 2 GB, sys: 2 GB, current: 0 B

首先,我们复习一下Go垃圾回收的日志的意义,再进一步看各个阶段内存的变化。

以这一条为例:

1
gc 21 @25.551s 6%: 0.012+59+0.010 ms clock, 0.050+17/59/0.46+0.043 ms cpu, 175->207->105 MB, 191 MB goal, 4 P
  • gc 21: 21是垃圾回收的编号,逐步递增,可能会从1重新开始
  • `@25.551s`: 自程序开始经历了多少时间,这里是25秒多
  • 6%: 自程序启动花在GC上的CPU时间百分比, CPU 6%花在了GC上
  • 0.012+59+0.010 ms clock: GC各阶段的墙上时间(wall-clock),各阶段包括STW sweep terminationconcurrent mark and scanSTW mark termination
  • 0.050+17/59/0.46+0.043 ms cpu: 各阶段的CPU时间。各阶段同上,其中mark/scan阶段又分成了assist timebackground GC timeidle GC time阶段
  • 175->207->105 MB: GC开始时、GC结束的heap大小、存活(live)的heap大小
  • 191 MB goal:下一次垃圾回收的目标值
  • 4 P: 使用的处理器的数量
  • (forced): 强制垃圾回收, 程序中调用runtime.GC()或者类似操作
  • scvg-1: 2740 MB released: gctrace的值大于0时,如果垃圾回收将内存返回给操作系统时,会打印一条summary,包括下一条数据

通过对每一项的介绍,你应该了解了go gc日志的含义,接下来让我们看看我们的测试各阶段的内存占用情况,也就是标记🍀的日志:

1
2
3
4
🍀 before: inuse: 656 KB, idle: 63 MB, released: 0 B, heapsys: 63 MB, sys: 66 MB
🍀 added: inuse: 1 GB, idle: 942 MB, released: 0 B, heapsys: 2 GB, sys: 2 GB, current: 1023 MB
🍀 after decreased: inuse: 156 MB, idle: 2 GB, released: 0 B, heapsys: 2 GB, sys: 2 GB, current: 0 B
🍀 after gc: inuse: 75 MB, idle: 2 GB, released: 2 GB, heapsys: 2 GB, sys: 2 GB, current: 0 B
  • 在程序刚启动时,内存占用很小, 真正inuse不到1MB。
  • 我们增加了上万条数据,每条数据光数就1KB,如果加上key的大小,以及管理cache的一些数据结构的额外开销,占用就比较大了,粗略统计inuse的占用就达到了1GB以上,idle的span的字节数不到1GB,从操作系统获得了2GB的内存,没有内存返回。可以看到cache使用的内存粗算为1023MB。
  • 我们将cache的最大内存设置为1B,这会触发cache对象的清理工作,因为最大内存很小,导致后续的增加缓存操作实际并不会缓存对象,可以看到缓存的实际大小为0B。可以看到inuse讲到了156MB,我们可以把它看作额外的一些开销,实际上开始添加的对象都被回收掉了。idle span的字节数达到了2GB,但是并没有内存返还给操作系统。这会导致操作系统认为这个程序占用内存达到2GB,linux服务器上有可能会导致OOM killer杀掉这个程序。
  • 我们进行了一次强制垃圾回收(实际调用debug.FreeOSMemory(),它会进行一次强制垃圾回收),可以看到虽然idle span的值还是2GB+,但是实际其中的2GB+的大小返还给操作系统了,如果这个时候你能够通过top观察程序的内存使用的话,可以看到这个程序的RES占用很小了。

top命令中关于程序使用内存的项介绍:

  • %MEM:Memory usage (RES) 内存占用
    使用的物理内存

  • VIRT:Virtual Image (kb) 虚拟镜像
    总虚拟内存的使用数量

  • SWAP:Swapped size (kb)
    非驻留但是存在于程序中的内存,虚拟内存减去物理内存

  • RES:Resident size (kb)
    非swap的物理内存

  • SHR:Shared Mem size (kb)
    程序使用的共享内存,可以被其它进程所共享

可以看到,当对象释放的时候,释放出来的内存并没有立即返还给操作系统,而在我们进行了一次强制垃圾回收后才返还。 Go语言把返还的过程叫做scavenging (拾荒)。这个拾荒的算法一直在演化,可以查看issue #16930,相关的优化提案可以参考:issue #30333

原先的scavenging是每隔几分钟(5分钟)执行一次拾荒操作,保证程序使用的内存和RSS基本一致。后来在1.11、1.12的演化过程中,改成了"智能"的拾荒操作。目标是尽量避免全部返还给操作系统导致的很重的重获取的花销,但是这也带来了一个问题,那就是当前的拾荒设计对于偶尔一个尖峰,并不会将不用的大量内存返还给操作系统,也就是本文一开始我在项目中遇到的问题。这个问题在issue中也有讨论:

Thus, I propose the following heuristic, borrowed from #16930: retain C*max(heap goal, max(heap goal over the last N GCs))

What happens in an application that has a huge heap spike (say, an initial loading phase) and then the heap drops significantly? In particular, let's say this is drastic enough that the runtime doesn't even notice the drop until a 2 minute GC kicks in. At that scale, it could take a while for N GCs to pass, and we won't reclaim the heap spike until they do.

This is something that came to my mind recently too. An alternative is to set a schedule to decrease the scavenge goal linearly, or according to a smoothstep function, which goes to zero over N GCs. If this schedule ever gets below C * the heap goal, we use that instead. We'll get smoother cliffs in general and still make progress in the case you describe. Smoothstep is preferred here since we won't over-fit to transient drops in heap size, but this also means we might be slower to react in the case you described. I prefer not to over-fit here because that carries a performance cost.

这是一个坑,不幸踩到了。我们这个项目的需求就是运维人员有时候可以将缓存使用的最大内存设置一个比较小的数,设置之后,go运行时不触发拾荒事件,就会导致内存被大量占用而不返还给操作系统。

目前我的修改是在cache的最大内存调小后执行一次debug.FreeOSMemory(),这样可以保证不用的一些内存返还给操作系统。当然执行这个操作也是有代价的:

  • Returning all free memory back to the underlying system at once is expensive, and can lead to latency spikes as it holds the heap lock through the whole process.
  • It’s an invasive solution: you need to modify your code to call it when you need it.
  • Reusing free chunks of memory becomes more expensive. On UNIX-y systems that means an extra page fault (which is surprisingly expensive on some systems).

Go 1.13中对拾荒的实现有进行了改进,而且Go 1.13也快发布了,发布之后我再做进一步的测试,尽量避免使用debug.FreeOSMemory()

runtime.MemStats

通过runtime.MemStats可以实时的获取Go运行时的内存统计信息,这个数据结构包含很多的字段。字段虽然很多,但是由于文档还是不够详细,如果没有深入理解Go语言内部的实现方式和相关的概念的话,不容易理解这个数据结构具体的含义,只根据字面值去理解很容易误用, 比如HeapIdle并不是Go占用的还没有释放的内存空间,其中的HeapReleased其实已经返还给操作系统了。

我将各个字段的中文解释列在了这里,如果你要监控go运行时的内存,需要仔细阅读相关的字段的解释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
type MemStats struct {
// 已分配的对象的字节数.
//
// 和HeapAlloc相同.
Alloc uint64
// 分配的字节数累积之和.
//
// 所以对象释放的时候这个值不会减少.
TotalAlloc uint64
// 从操作系统获得的内存总数.
//
// Sys是下面的XXXSys字段的数值的和, 是为堆、栈、其它内部数据保留的虚拟内存空间.
// 注意虚拟内存空间和物理内存的区别.
Sys uint64
// 运行时地址查找的次数,主要用在运行时内部调试上.
Lookups uint64
// 堆对象分配的次数累积和.
// 活动对象的数量等于`Mallocs - Frees`.
Mallocs uint64
// 释放的对象数.
Frees uint64
// 分配的堆对象的字节数.
//
// 包括所有可访问的对象以及还未被垃圾回收的不可访问的对象.
// 所以这个值是变化的,分配对象时会增加,垃圾回收对象时会减少.
HeapAlloc uint64
// 从操作系统获得的堆内存大小.
//
// 虚拟内存空间为堆保留的大小,包括还没有被使用的.
// HeapSys 可被估算为堆已有的最大尺寸.
HeapSys uint64
// HeapIdle是idle(未被使用的) span中的字节数.
//
// Idle span是指没有任何对象的span,这些span **可以**返还给操作系统,或者它们可以被重用,
// 或者它们可以用做栈内存.
//
// HeapIdle 减去 HeapReleased 的值可以当作"可以返回到操作系统但由运行时保留的内存量".
// 以便在不向操作系统请求更多内存的情况下增加堆,也就是运行时的"小金库".
//
// 如果这个差值明显比堆的大小大很多,说明最近在活动堆的上有一次尖峰.
HeapIdle uint64
// 正在使用的span的字节大小.
//
// 正在使用的span是值它至少包含一个对象在其中.
// HeapInuse 减去 HeapAlloc的值是为特殊大小保留的内存,但是当前还没有被使用.
HeapInuse uint64
// HeapReleased 是返还给操作系统的物理内存的字节数.
//
// 它统计了从idle span中返还给操作系统,没有被重新获取的内存大小.
HeapReleased uint64
// HeapObjects 实时统计的分配的堆对象的数量,类似HeapAlloc.
HeapObjects uint64
// 栈span使用的字节数。
// 正在使用的栈span是指至少有一个栈在其中.
//
// 注意并没有idle的栈span,因为未使用的栈span会被返还给堆(HeapIdle).
StackInuse uint64
// 从操作系统取得的栈内存大小.
// 等于StackInuse 再加上为操作系统线程栈获得的内存.
StackSys uint64
// 分配的mspan数据结构的字节数.
MSpanInuse uint64
// 从操作系统为mspan获取的内存字节数.
MSpanSys uint64
// 分配的mcache数据结构的字节数.
MCacheInuse uint64
// 从操作系统为mcache获取的内存字节数.
MCacheSys uint64
// 在profiling bucket hash tables中的内存字节数.
BuckHashSys uint64
// 垃圾回收元数据使用的内存字节数.
GCSys uint64 // Go 1.2
// off-heap的杂项内存字节数.
OtherSys uint64 // Go 1.2
// 下一次垃圾回收的目标大小,保证 HeapAlloc ≤ NextGC.
// 基于当前可访问的数据和GOGC的值计算而得.
NextGC uint64
// 上一次垃圾回收的时间.
LastGC uint64
// 自程序开始 STW 暂停的累积纳秒数.
// STW的时候除了垃圾回收器之外所有的goroutine都会暂停.
PauseTotalNs uint64
// 一个循环buffer,用来记录最近的256个GC STW的暂停时间.
PauseNs [256]uint64
// 最近256个GC暂停截止的时间.
PauseEnd [256]uint64 // Go 1.4
// GC的总次数.
NumGC uint32
// 强制GC的次数.
NumForcedGC uint32 // Go 1.8
// 自程序启动后由GC占用的CPU可用时间,数值在 0 到 1 之间.
// 0代表GC没有消耗程序的CPU. GOMAXPROCS * 程序运行时间等于程序的CPU可用时间.
GCCPUFraction float64 // Go 1.5
// 是否允许GC.
EnableGC bool
// 未使用.
DebugGC bool
// 按照大小进行的内存分配的统计,具体可以看Go内存分配的文章介绍.
BySize [61]struct {
// Size is the maximum byte size of an object in this
// size class.
Size uint32
// Mallocs is the cumulative count of heap objects
// allocated in this size class. The cumulative bytes
// of allocation is Size*Mallocs. The number of live
// objects in this size class is Mallocs - Frees.
Mallocs uint64
// Frees is the cumulative count of heap objects freed
// in this size class.
Frees uint64
}
}

Go运行时的内存分配算法可以查看文章: A visual guide to Go Memory Allocator from scratch (Golang) , 或者中文翻译: Go 内存分配器可视化指南,这是目前第一篇全面介绍Go运行时内存管理的文章。

runtime.SetGCPercent

GOGC设置垃圾回收的目标百分比。什么时候会触发Go运行时的垃圾回收操作呢,主要靠这个值。当这次新分配的数据和上一次垃圾回收后存活数据之比达到这个数值之后就会触发一次垃圾回收。

GOGC的默认值是100。设置GOGC=off会禁止垃圾回收。

你也可以通过代码设置这个参数,调用runtime.SetGCPercent进行设置。

MADV

MADV是Linux的一个特性,,可以看相关的介绍:MADV_FREE functionality

一直以来 go 的 runtime 在释放内存返回到内核时,在 Linux 上使用的是 MADV_DONTNEED,虽然效率比较低,但是会让 RSS(resident set size 常驻内存集)数量下降得很快。不过在 go 1.12 里专门针对这个做了优化,runtime 在释放内存时,使用了更加高效的 MADV_FREE 而不是之前的 MADV_DONTNEED。这样带来的好处是,一次 GC 后的内存分配延迟得以改善,runtime 也会更加积极地将释放的内存归还给操作系统,以应对大块内存分配无法重用已存在的堆空间的问题。不过也会带来一个副作用:RSS 不会立刻下降,而是要等到系统有内存压力了,才会延迟下降。需要注意的是,MADV_FREE 需要 4.5 以及以上内核,否则 runtime 会继续使用原先的 MADV_DONTNEED 方式。当然 go 1.12 为了避免像这样一些靠判断 RSS 大小的自动化测试因此出问题,也提供了一个 GODEBUG=madvdontneed=1 参数可以强制 runtime 继续使用 MADV_DONTNEED:runtime: provide way to disable MADV_FREE

相关issue和资料

这里列出了Go官方库中的一些内存泄漏相关的issue,以及关于Go内存泄漏的一些文章,感兴趣的同学可以进一步阅读。

  1. https://golang.org/pkg/runtime/#MemStats
  2. https://github.com/golang/go/issues/33684
  3. https://github.com/golang/go/issues/33376
  4. https://github.com/golang/go/issues/32284
  5. https://github.com/golang/go/issues/16843
  6. https://github.com/golang/go/issues/14521
  7. https://go101.org/article/memory-leaking.html
  8. http://play.golang.org/p/Nb39COQgxr
  9. https://www.freecodecamp.org/news/how-i-investigated-memory-leaks-in-go-using-pprof-on-a-large-codebase-4bec4325e192/
  10. https://medium.com/dm03514-tech-blog/sre-debugging-simple-memory-leaks-in-go-e0a9e6d63d4d
  11. https://github.com/golang/go/issues/16930
  12. https://github.com/golang/go/issues/30333
  13. https://go-review.googlesource.com/c/go/+/135395/
  14. https://github.com/golang/go/issues/23687
  15. https://ms2008.github.io/2019/06/30/golang-madvfree/
  16. https://golang.org/doc/go1.12#runtime