内存泄漏对于developer来说是十分常见的,每当出现内存泄漏后,都需要静下心来好好的review代码,寻找潜在的漏洞。
但对于 go developer又不是那么明显,因为 go 良好的内存管理(内存逃逸等技术),帮助 developer 没有必要这么在意内存释放的问题(相对于 c 系列)。
但是了解常见的内存泄漏场景,掌握分析内存的工具对于go developer的进阶又是格外重要的,下面总结了一些典型的内存泄漏场景,以及配套分析的工具,帮助大家一起迈过内存泄漏这道坎。
内存泄漏场景
在看具体的内存场景之前,我们先明确一件事,是什么导致了内存泄漏?
内存对象被多个变量引用,在gc回收时,由于其中一个未回收的变量仍在调用,导致其无法回收
我们需要理解gc是想回收的,但是发现这个内存对象,被其他的未回收的变量仍在引用,最终导致内存泄漏
字符串截取
由于go内部的优化导致的,对于一段字符串S的子截取sub_a、sub_b、sub_x,在底层会复用最大的父字符串S代表的字符串,从而避免内存频繁copy
但是由于复用了同一个字符串A,可能导致在不经意间泄漏了内存,看下面这个 case
var s0 string // 包层次变量,常规使用不会回收
func f(s1 string) {
// s1 看起来没有任何直接引用会释放,但是由于 s0 是 s1 的子字符串,底层会复用 s1
// 所以 s1 所占用的 1M 空间也不会释放
s0 = s1[:50]
}
func demo() {
s := createStringWithLengthOnHeap(1 << 20) // 创建了 1M 内存
f(s)
}
解决方案
string([]byte(xxx))
func f(s1 string) {
s0 = string([]byte(s1[:50]))
}
利用 []byte 能额外的创造出一块空间,但 []byte->string 又会额外创造出另一块空间,从而导致 2x50 字节空间的利用,多用了 50 字节
(" " + s1[:50])[1:]
func f(s1 string) {
s0 = (" " + s1[:50])[1:]
}
使用了 50+1 的空间,多用了 1 字节
strings.Builder
import "strings"
func f(s1 string) {
var b strings.Builder
b.Grow(50)
b.WriteString(s1[:50])
s0 = b.String()
}
除了太长,没有问题
strings.Repeat
import "strings"
func f(s1 string) {
s0 = strings.Repeat(s1[:50],1)
}
strings.Builder 简化版,略丑
strings.Clone
import "strings"
func f(s1 string) {
s0 = strings.Clone(s1[:50])
}
1.18 官方终于支持了
切片截取
与字符串截取类似,这种场景也存在同样的内存泄漏情况
var s0 []int
func g(s1 []int) {
s0 = s1[len(s1)-30:]
}
解决方案
func g(s1 []int) {
s0 = make([]int, 30)
copy(s0, s1[len(s1)-30:])
}
通过申请新的内存空间,并利用copy函数实现元素的复制
timer.Ticker
使用完后,需要stop,否则会发生泄漏,把这种case当做文件、http链接对待
Finalizers
func memoryLeaking() {
type T struct {
v [1<<20]int
t *T
}
var finalizer = func(t *T) {
fmt.Println("finalizer called")
}
var x, y T
// 会让 x 逃逸到堆上
runtime.SetFinalizer(&x, finalizer)
// x 和 y 发生循环引用
x.t, y.t = &y, &x
}
这种泄漏的原因是:
runtime.SetFinalizer
帮助 x 逃逸到堆上- x 和 y 发生循环引用,从而整体能避免回收
defer
由于在函数最后退出时才调用,会延迟内存释放,某种意义上也算是一种内存泄漏
协程泄漏
大量go出去的协程,没有结束,也会导致内存泄漏
内存泄漏查看工具
top
一般用来看哪些用cpu和memory的占用情况,可以很便捷的查看内存究竟占用了多少,这里需要注意以下几个内存参数:
- VIRT(virtual):虚拟内存,并不代表使用占用的
- RES(Reserved):驻留内存,代表实际占用
- SHR(share):共享内存,代表与其他进程共享的空间,多用于进程通信或者对系统lib库的调用
上图很好的展示了 VIRT、RES、SHR 三者的关系
pprof
go官方推荐的内存泄漏排查工具,能很好的从 cpu、memory(准确说是heap) 两个维度来查看内存的使用情况
因为pprof只能发现内存问题,而不一定是内存泄漏问题,所以存在无法定位的场景:
- 真的内存泄漏:真的内存泄漏后,无法触达,也无法采样到
- 是对堆的采样:不能完整的反应内存真实的使用情况,比如在goroutine泄漏时,内部使用的变量是在栈上,此时无法采样到
- 未能采样到:如果使用频次很低,可能无法采样到
排查思路
-
用 top 看是否是当前进程占用的比重最大(top)
-
通过
pprof
采样该进程的 memory 的使用情况,若能定位到则很快的能发现内存泄漏位置。 -
通过
pprof
采样该进程的 cpu 的使用情况,看看哪块 cpu 使用的比较多,从而顺藤摸瓜抓到内存泄漏的位置 -
上述方法都无法发现,恭喜你踩到大坑了,这时候你需要静下心来,考虑下一些常见的内存泄漏场景
- 各类资源文件,是否close,例如:timer.ticker
- 所有 go 协程的位置
- 全局变量
参考资料
Memory Leaking Scenarios -Go 101