Golang OOM


什么是内存泄漏

内存泄漏是指程序运行过程中,内存因为某些原因无法释放或没有释放。简单来讲就是,有代码占着茅坑不拉屎,让内存资源造成了浪费。如果泄漏的内存越堆越多,就会占用程序正常运行的内存。
比较轻的影响是程序开始运行越来越缓慢;严重的话,可能导致大量泄漏的内存堆积,最终导致程序没有内存可以运行,最终导致 OOM (Out Of Memory,即内存溢出)。
但通常来讲,内存泄漏都是极其不易发现的,所以为了保证程序的健康运行,我们需要重视如何避免写出内存泄漏的代码。

Go中垃圾回收器采用的是“并发三色标记清除”算法

理论上,垃圾回收(gc)算法能够对堆内存进行有效的清理,这个是没什么可质疑的。但是要理解,垃圾回收能够正常运行的前提是,程序中必须解除对内存的引用,这样垃圾回收才会将其判定为可回收内存并回收

实际情况是,编码中确实存在一些场景,会造成“临时性”或者“永久性”内存泄露,是需要开发人员加深对编程语言设计实现、编译器特性的理解之后才能优化掉的

临时性泄露,指的是该释放的内存资源没有及时释放,对应的内存资源仍然有机会在更晚些时候被释放,即便如此在内存资源紧张情况下,也会是个问题。这类主要是stringslice底层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 与旧 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] 地址是完全一样的,可以印证 ab 底层用的是同一个数组。当 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 占用内存会非常高,可以得出结论,这样确实会造成内存泄漏。

另外,需要注意的是,如果使用 Tickerstop 了,却又尝试使用 <-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) 处有缓存,但是因为发送 channelfor 循环中,缓存队列很快就会被占满,阻塞在第 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

内存泄露排查

借助pprof排查

借助bcc排查

借助pmap/gdb排查


文章作者: 江湖义气
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 江湖义气 !
  目录