什么是内存泄漏
内存泄漏是指程序运行过程中,内存因为某些原因无法释放或没有释放。简单来讲就是,有代码占着茅坑不拉屎,让内存资源造成了浪费。如果泄漏的内存越堆越多,就会占用程序正常运行的内存。
比较轻的影响是程序开始运行越来越缓慢;严重的话,可能导致大量泄漏的内存堆积,最终导致程序没有内存可以运行,最终导致 OOM
(Out Of Memory,即内存溢出)。
但通常来讲,内存泄漏都是极其不易发现的,所以为了保证程序的健康运行,我们需要重视如何避免写出内存泄漏的代码。
Go中垃圾回收器采用的是“并发三色标记清除”算法
理论上,垃圾回收(gc)算法能够对堆内存进行有效的清理,这个是没什么可质疑的。但是要理解,垃圾回收能够正常运行的前提是,程序中必须解除对内存的引用,这样垃圾回收才会将其判定为可回收内存并回收
实际情况是,编码中确实存在一些场景,会造成“临时性”或者“永久性”内存泄露,是需要开发人员加深对编程语言设计实现、编译器特性的理解之后才能优化掉的
临时性泄露,指的是该释放的内存资源没有及时释放,对应的内存资源仍然有机会在更晚些时候被释放,即便如此在内存资源紧张情况下,也会是个问题。这类主要是string
、slice
底层buffer
的错误共享,导致无用数据对象无法及时释放,或者defer
函数导致的资源没有及时释放。
永久性泄露,指的是在进程后续生命周期内,泄露的内存都没有机会回收,如goroutine
内部预期之外的for-loop
或者chan select-case
导致的无法退出的情况,导致协程栈及引用内存永久泄露问题
常见的内存泄露场景,go101进行了讨论,总结了如下几种:
Kind of memory leaking caused by substrings
Kind of memory leaking caused by subslices
Kind of memory leaking caused by not resetting pointers in lost slice elements
Real memory leaking caused by hanging goroutines
real memory leadking caused by not stopping time.Ticker
values which are not used any more
Real memory leaking caused by using finalizers improperly
Kind of resource leaking by deferring function calls
下面讨论几种比较常见的
slice、string 误用造成内存泄漏
内存泄漏分析
slice
的结构这里使用一张《Go 入门指南》的图:
当我们对 slice
切片时,实际上新创建的 slice
的数组指针也是指向的旧 slice
指向的底层数组,只是可能指向的位置不同。
也就是说,使用切片时,新产生的 slice
与旧 slice
共用一个底层数组。
正常情况下,如果没有 y
这个切片,当 x
不再使用了,由于 x
和其指向的数组都不存在任何引用,它们会被垃圾回收机制回收。
如果 x
存在切片,比如上图的 y
,当 x
不再使用时,x
可以被回收,但由于 y
仍在引用底层数组,垃圾回收机制不会把底层数组回收。这就造成底层数组索引为 0 的位置的内存发生了泄露(谁也访问不到了)。
验证一下
让我们使用代码验证一下:
func testSlice() {
var a []int
for i := 0; i < 100; i++ {
a = append(a, i)
}
var b = a[:10]
println(&a, &b) // 0xc00003df58 0xc00003df40
println(&a[0], &b[0]) // 0xc000056000 0xc000056000
}
我们可以发现,a[0]
与 b[0]
地址是完全一样的,可以印证 a
与 b
底层用的是同一个数组。当 a
不再使用时,b
就会只引用 a
指向的底层数组的一部分。
假设 a
是一个大数组,而 b
只引用了一小部分,这就造成了底层数组其他未被引用的部分内存泄漏。即便 a
是一个小数组,如果内存中有很多类似 b
引用 a
这样代码,积少成多,也会导致大量内存泄漏。
解决方案
解决问题的核心是:如果我们需要使用切片时,尽量保证切片只作为局部变量使用,不会被传到方法外,这样在局部变量使用完后,该切片就会被回收.
如果我们不能保证将切片作为局部变量使用且不传递,则应该对需要的切片数据进行拷贝,防止内存泄露。如下所示的两种方式均可:
func testSliceSolution() {
var a, b []int
for i := 0; i < 100; i++ {
a = append(a, i)
}
b = append(b, a[:10]...)
println(&a[0], &b[0]) // 0xc000092400 0xc000094000
}
func testSliceSolution2() {
var a, b []int
for i := 0; i < 100; i++ {
a = append(a, i)
}
b = make([]int, 10)
copy(b, a[:10])
println(&a[0], &b[0]) // 0xc000092800 0xc00003ded0
}
time.Ticker 误用造成内存泄漏
可能有些人对 Ticker
并不熟悉,这里给出一个使用示例:
func testTickerNormal() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
go func() {
for {
fmt.Println(<-ticker.C)
}
}()
time.Sleep(time.Second * 3)
fmt.Println("finish")
}
内存泄露分析
这里我们分别对使用和不使用 Stop
方法进行测试
使用 Stop
方法停止 Ticker
:
func testTickerUsingStop() {
for i := 0; i < 1000000; i++ {
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for i := 0; i < 3; i++ {
<-ticker.C
}
}()
}
time.Sleep(10 * time.Second)
// 以下代码用于内存分析
f, _ := os.Create("1.prof")
defer f.Close()
runtime.GC()
_ = pprof.WriteHeapProfile(f)
log.Println("finish")
}
使用 go tool pprof 1.prof
,输入 top
得到输出如下:
Showing nodes accounting for 328.67MB, 99.85% of 329.17MB total
Dropped 9 nodes (cum <= 1.65MB)
flat flat% sum% cum cum%
313.62MB 95.28% 95.28% 313.62MB 95.28% runtime.malg
8.61MB 2.62% 97.89% 8.61MB 2.62% time.startTimer
6.44MB 1.96% 99.85% 6.44MB 1.96% runtime.allgadd
0 0% 99.85% 8.61MB 2.62% main.testTickerUsingStop.func1
0 0% 99.85% 320.06MB 97.23% runtime.newproc.func1
0 0% 99.85% 320.06MB 97.23% runtime.newproc1
0 0% 99.85% 320.06MB 97.23% runtime.systemstack
0 0% 99.85% 8.61MB 2.62% time.NewTicker
不使用 Stop 停止 Ticker:
func testTickerWithoutUsingStop() {
for i := 0; i < 1000000; i++ {
go func() {
ticker := time.NewTicker(time.Second)
for i := 0; i < 3; i++ {
<-ticker.C
}
}()
}
time.Sleep(10 * time.Second)
// 以下代码用于内存分析
f, _ := os.Create("2.prof")
defer f.Close()
runtime.GC()
_ = pprof.WriteHeapProfile(f)
log.Println("finish")
}
操作同上,得到输出如下:
Showing nodes accounting for 581.07MB, 99.83% of 582.07MB total
Dropped 11 nodes (cum <= 2.91MB)
flat flat% sum% cum cum%
349.64MB 60.07% 60.07% 349.64MB 60.07% runtime.malg
216.02MB 37.11% 97.18% 224.99MB 38.65% time.NewTicker
8.98MB 1.54% 98.72% 8.98MB 1.54% time.startTimer
6.44MB 1.11% 99.83% 6.44MB 1.11% runtime.allgadd
0 0% 99.83% 224.99MB 38.65% main.testTickerWithoutUsingStop.func1
0 0% 99.83% 356.08MB 61.17% runtime.newproc.func1
0 0% 99.83% 356.08MB 61.17% runtime.newproc1
0 0% 99.83% 356.08MB 61.17% runtime.systemstack
- flat表示此函数分配的内存并由该函数持有
- cum表示内存是由这个函数或它调用堆栈的函数分配的
可以看到不使用 Stop
方法时,time.NewTicker
占用内存会非常高,可以得出结论,这样确实会造成内存泄漏。
另外,需要注意的是,如果使用 Ticker
后 stop
了,却又尝试使用 <-ticker.C
,会造成 goroutine
阻塞,从而导致内存泄漏。如下代码所示:
channel 误用造成内存泄漏
都说 golang 10 次内存泄漏,9 次是 goroutine 泄漏。可见 go channel 内存泄漏的常见性。
一说到 go channel,很多人会使用“优秀”“哲学”这些词汇来描述。殊不知,go channel 恐怕还是 golang 中最容易造成问题的特性之一。
很多情况下,我们使用 go channel 时,常常以为可以关闭 channel,但实际上却没有关闭,这就是导致 go channel 内存泄漏的元凶。
情境一:select-case 误用导致的内存泄露
func main() {
fmt.Println("NumGoroutine:", runtime.NumGoroutine())
chanLeakOfMemory()
time.Sleep(time.Second * 3) // 等待 goroutine 执行,防止过早输出结果
fmt.Println("NumGoroutine:", runtime.NumGoroutine())
}
func chanLeakOfMemory() {
errCh := make(chan error) // (1)
// errCh := make(chan error, 1) // 正确
go func() { // (5)
time.Sleep(2 * time.Second)
errCh <- errors.New("chan error") // (2)
fmt.Println("finish sending")
}()
var err error
select {
case <-time.After(time.Second): // (3) 大家也经常在这里使用 <-ctx.Done()
fmt.Println("超时")
case err = <-errCh: // (4)
if err != nil {
fmt.Println(err)
} else {
fmt.Println(nil)
}
}
}
大家认为输出的结果是什么?正确的输出结果如下:
NumGoroutine: 1
超时
NumGoroutine: 2
这是 go channel 导致内存泄漏的经典场景。根据输出结果(开始有1个 goroutine
,结束时有2个 goroutine
),我们可以知道,直到测试函数结束前,仍有一个 goroutine
没有退出。
原因是由于 (1) 处创建的 errCh
是不含缓存队列的 channel
,如果 channel
只有发送方发送,那么发送方会阻塞;如果 channel
只有接收方,那么接收方会阻塞。
我们可以看到由于没有发送方往 errCh
发送数据,所以 (4) 处代码一直阻塞。直到 (3) 处超时后,打印“超时”,函数退出,(4) 处代码都未接收成功。
而 (2) 处的所在的 goroutine
在“超时”被打印后,才开始发送。由于外部的 goroutine
已经退出了,errCh
没有接收者,导致 (2) 处一直阻塞。
因此 (2) 处代码所在的协程一直未退出,造成了内存泄漏。如果代码中有许多类似的代码,或在 for 循环中使用了上述形式的代码,随着时间的增长会造成多个未退出的 gorouting
,最终导致程序 OOM
。
这种情况其实还比较简单。我们只需要为 channel
增加一个缓存队列。即把 (1) 处代码改为 errCh := make(chan error, 1)
即可
NumGoroutine: 1
超时
finish sending
NumGoroutine: 1
可能会有人想要使用 defer close(errCh)
关闭 channel
。比如把 (1) 处代码改为如下形式(错误):
errCh := make(chan error)
defer close(errCh)
由于 (2) 处代码没有接收者,所以一直阻塞。直到 close(errCh)
运行,(2) 处仍在阻塞。这导致关闭 channel
时,仍有 goroutine
在向 errCh
发送。
然而在 golang 中,在向 channel
发送时不能关闭 channel
,否则会 panic
。因此这种方式是错误的。
又或在 (5) 处 goroutine
的第一句加上 defer close(errCh)
。由于 (2) 处阻塞, defer close(errCh)
会一直得不到执行。
因此也是错误的。即便对调 (2) 处和 (4) 处的发送者和接收者,也会因为 channel
关闭,导致输出无意义的零值。
情景二:for-range 误用导致的内存泄露
上述示例中只有一个发送者,且只发送一次,所以增加一个缓存队列即可。但在其他情况下,可能不止有一个发送者(或者不只发送一次),所以这个方案要求,缓存队列的容量需要和发送次数一致。
一旦缓存队列容量被用完后,再有发送者发送就会阻塞发送者 goroutine
。如果恰好此时接收者退出了,那么仍然至少会有一个 goroutine
无法退出,从而造成内存泄漏。就比如下面的代码。不知道经过上面的讲解,读者是否能够发现其中的问题。
func main() {
fmt.Println("NumGoroutine:", runtime.NumGoroutine())
chanLeakOfMemory2()
//NumGoroutine: 1
//0
//1
//2
//...
//65
//context deadline exceeded
//NumGoroutine: 2
// chanLeakOfMemory22()
time.Sleep(time.Second * 3) // 等待 goroutine 执行,防止过早输出结果
fmt.Println("NumGoroutine:", runtime.NumGoroutine())
}
func chanLeakOfMemory2() {
ich := make(chan int, 100) // (3)
// sender
go func() {
defer close(ich)
for i := 0; i < 10000; i++ {
ich <- i
time.Sleep(time.Millisecond) // 控制一下,别发太快
}
}()
// receiver
go func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
for i := range ich { // (2)
if ctx.Err() != nil { // (1)
fmt.Println(ctx.Err())
return
}
fmt.Println(i)
}
}()
}
我们聪明地使用了 channel
的缓存队列。我们以为我们循环发送,发完之后就会把 channel
关闭。而且我们使用 for range
获取 channel
的值,会一直获取,直到 channel
关闭。
但在代码 (1) 处,接收者的 goroutine
中,我们加了一个判断语句。这会让代码 (2) 处的 channel
还没被接收完就退出了接收者 goroutine
。尽管代码 (3) 处有缓存,但是因为发送 channel
在 for
循环中,缓存队列很快就会被占满,阻塞在第 101 的位置。所以这种情况我们要使用一个额外的 stop channel
来终结发送者所在的 goroutine
。
func chanLeakOfMemory22() {
ich := make(chan int, 100)
stopCh := make(chan struct{})
// sender
go func() {
defer close(ich)
for i := 0; i < 10000; i++ {
select {
case <-stopCh:
return
case ich <- i:
}
time.Sleep(time.Millisecond) // 控制一下,别发太快
}
}()
// receiver
go func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
for i := range ich {
if ctx.Err() != nil {
fmt.Println(ctx.Err())
close(stopCh)
return
}
fmt.Println(i)
}
}()
}
可能有人会问,要是接收者 goroutine
关闭 stop channel
的时候,发送者又继续发送了怎么办?不会内存泄漏吗?
答案是不会的。因为只可能存在两种情况:
一种是发送者把数据发送到了缓存中,发送者想要继续发送时,select
发现 stop channel
已经关闭,发送者 goroutine
会退出;
一种是 channel
没有缓存了,发送者只能阻塞,此时 select
发现 stop channel
已经关闭,发送者 goroutine
也会退出。
总之,通常情况下,我们只会遇到这两种 go channel
造成内存泄漏的情况(一个发送者导致的内存泄漏和多个发送者导致的内存泄漏)
让我们仔细观察上述两个内存泄漏的案例,可以发现不论发送者发送一次还是多次,如果接收者所在 goroutine
不会在接收完 channel
中的数据之前结束,那么就不会造成内存泄漏。
如果接收者需要在 channel
关闭之前提前退出,为防止内存泄漏,在发送者与接收者发送次数是一对一时,应设置 channel
缓冲队列为 1;
在发送者与接收者的发送次数是多对多时,应使用专门的 stop channel
通知发送者关闭相应 channel
。