你的 golang 程序正在悄悄内存泄漏

3,120 阅读7分钟

什么是内存泄漏?

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

目录

  • slicestring 误用造成内存泄漏
  • time.Ticker 误用造成内存泄漏
  • channel 误用造成内存泄漏

slicestring 误用造成内存泄漏

一般来说我们经常会使用 a[1:3] 这种形式对一个 slice 进行切片,但你可能不知道,如果不注意,这个操作还会导致内存泄漏。毕竟这是个“连 golang 官方都会踩的坑”呢。

内存泄漏分析

slice 的结构

这里使用一张《Go 入门指南》的图:

Image.png

正如上图 x 的结构所示,slice 的结构实际上只记录了如下三个部分:

  • 数组指针。指向了真正的数组地址。
  • slice 的长度
  • slice 的容量

当我们对 slice 切片时,实际上新创建的 slice 的数组指针也是指向的旧 slice 指向的底层数组,只是可能指向的位置不同。也就是说,使用切片时,新产生的 slice 与旧 slice 共用一个底层数组。

正常情况下,如果没有 y 这个切片,当 x 不再使用了,由于 x 和其指向的数组都不存在任何引用,它们会被垃圾回收机制回收。如果 x 存在切片,比如上图的 y,当 x 不再使用时,x 可以被回收,但由于 y 仍在引用底层数组,垃圾回收机制不会把底层数组回收。这就造成底层数组索引为 0 的位置的内存发生了泄漏(谁也访问不到了)。

验证一下

让我们使用代码验证一下:

func TestSlice(t *testing.T) {
	var a []int
	for i := 0; i < 100; i++ {
		a = append(a, i)
	}

	var b = a[:10]
	println(&a, &b)
	println(&a[0], &b[0])
}

运行后,输出如下:

0xc000038748 0xc000038730
0xc000148400 0xc000148400

我们可以发现,a[0]b[0] 地址是完全一样的,可以印证 ab 底层用的是同一个数组。当 a 不再使用时,b 就会只引用 a 指向的底层数组的一部分。假设 a 是一个大数组,而 b 只引用了一小部分,这就造成了底层数组其他未被引用的部分内存泄漏。即便 a 是一个小数组,如果内存中有很多类似 b 引用 a 这样代码,积少成多,也会导致大量内存泄漏。

需要注意的是:由于 string 切片时也会共用底层数组,所以使用不当也会造成内存泄漏。

其他语言中类似的情况

Python 和 Java 都有类似 slice 的概念,它们底层都大差不差,因此也会因为类似的原因导致内存泄漏。

比如 Python,也有切片这个概念,看下面这个代码:

>>> a=[1,2,4,5]
>>> b=tab[:3]
>>> id(a[0])
140700163291672
>>> id(b[0])
140700163291672

可以发现 a[0]b[0] 的地址是相同的。

又比如 Java,Java 中这个结构被称为 SubListSubList 和原 List 底层也是共用的一个数组,所以如果使用不当,也会造成内存泄漏。

解决方案

解决问题的核心是:如果我们需要使用切片时,尽量保证切片只作为局部变量使用,不会被传到方法外,这样在局部变量使用完后,该切片就会被回收。比如上文 TestSlice 中的代码,执行完后,切片就会被回收,即便对切片进行切片也不会造成内存泄漏(上述代码只是用于演示会造成内存泄漏)。但如果切片被传递到其他方法中,很可能被其他方法切片,导致原有的切片出现内存泄漏。

如果我们不能保证将切片作为局部变量使用且不传递,则应该对需要的切片数据进行拷贝,防止内存泄漏。如下所示的两种方式均可:

func TestSliceSolution(t *testing.T) {
	var a, b []int
	for i := 0; i < 100; i++ {
		a = append(a, i)
	}

	b = append(b, a[:10]...)
	println(&a[0], &b[0])
}

//0xc000014800 0xc000020230
func TestSliceSolution2(t *testing.T) {
	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])
}

//0xc000014800 0xc00003e6d0

time.Ticker 误用造成内存泄漏

注意:TickerTimer 是不同的。Timer 只会定时一次,而 Ticker 如果不 Stop,就会一直发送定时。

可能有些人对 Ticker 并不熟悉,这里给出一个使用示例:

func TestTickerNormal(t *testing.T) {
	ticker := time.NewTicker(time.Second)
	defer ticker.Stop()
	go func() {
		for {
			fmt.Println(<-ticker.C)
		}
	}()

	time.Sleep(time.Second * 3)
	fmt.Println("finish")
}

//2022-03-17 12:01:06.279504 +0800 CST m=+1.000922333
//2022-03-17 12:01:07.281379 +0800 CST m=+2.002815014
//finish
//2022-03-17 12:01:08.280861 +0800 CST m=+3.002314240

内存泄漏分析

这里我们分别对使用和不使用 Stop 方法进行测试。

使用 Stop 方法停止 Ticker

func TestTickerUsingStop(t *testing.T) {
	for i := 0; i < 100_0000; 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 得到输出如下:

Dropped 11 nodes (cum <= 2.09MB)
      flat  flat%   sum%        cum   cum%
  402.16MB 96.08% 96.08%   402.16MB 96.08%  runtime.malg
    8.67MB  2.07% 98.15%     8.67MB  2.07%  runtime.allgadd
    6.23MB  1.49% 99.64%     6.23MB  1.49%  time.startTimer
         0     0% 99.64%     6.23MB  1.49%  demo.TestTickerUsingStop.func1
         0     0% 99.64%   410.83MB 98.15%  runtime.newproc.func1
         0     0% 99.64%   410.83MB 98.15%  runtime.newproc1
         0     0% 99.64%   410.83MB 98.15%  runtime.systemstack
         0     0% 99.64%     6.23MB  1.49%  time.NewTicker

不使用 Stop 停止 Ticker

func TestTickerWithoutUsingStop(t *testing.T) {
	for i := 0; i < 100_0000; 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")
}

操作同上,得到输出如下:

Dropped 10 nodes (cum <= 3.04MB)
      flat  flat%   sum%        cum   cum%
  378.65MB 62.21% 62.21%   378.65MB 62.21%  runtime.malg
  210.02MB 34.51% 96.72%   219.83MB 36.12%  time.NewTicker
    9.81MB  1.61% 98.33%     9.81MB  1.61%  time.startTimer
    8.67MB  1.42% 99.75%     8.67MB  1.42%  runtime.allgadd
         0     0% 99.75%   219.83MB 36.12%  demo.TestTickerWithoutUsingStop.func1
         0     0% 99.75%   387.32MB 63.64%  runtime.newproc.func1
         0     0% 99.75%   387.32MB 63.64%  runtime.newproc1
         0     0% 99.75%   387.32MB 63.64%  runtime.systemstack
  • flat表示此函数分配的内存并由该函数持有
  • cum表示内存是由这个函数或它调用堆栈的函数分配的

可以看到不使用 Stop 方法时,time.NewTicker 占用内存会非常高,可以得出结论,这样确实会造成内存泄漏。

另外,需要注意的是,如果使用 Ticker 后 stop 了,却又尝试使用 <-ticker.C,会造成 go routine 阻塞,从而导致内存泄漏。如下代码所示:

func TestTicker(t *testing.T) {
	fmt.Println("NumGoroutine:", runtime.NumGoroutine())
	go func() {
		ticker := time.NewTicker(time.Second)
		ticker.Stop() // 注意,这里先 stop 了
		for i := 0; i < 3; i++ {
			<-ticker.C
		}
		fmt.Println("ticker finish")
	}()

	time.Sleep(5 * time.Second)
	fmt.Println("NumGoroutine:", runtime.NumGoroutine())
}

// Output:
// NumGoroutine: 2
// NumGoroutine: 3

channel 误用造成内存泄漏

都说 golang 10 次内存泄漏,9 次是 go routine 泄漏。可见 go channel 内存泄漏的常见性。go channel 内存泄漏主要分两种情况,我在《老手也常误用!详解 Go channel 内存泄漏问题》这篇文章有详细讲述。这里简单说一下造成内存泄漏的代码、原因。

情景一: select-case 造成的内存泄漏

func TestLeakOfMemory(t *testing.T) {
   fmt.Println("NumGoroutine:", runtime.NumGoroutine())
   chanLeakOfMemory()
   time.Sleep(time.Second * 3) // 等待 goroutine 执行,防止过早输出结果
   fmt.Println("NumGoroutine:", runtime.NumGoroutine())
}

func chanLeakOfMemory() {
   errCh := make(chan error) 
   go func() { 
      time.Sleep(2 * time.Second)
      errCh <- errors.New("chan error") // (1)
      fmt.Println("finish sending")
   }()

   var err error
   select {
   case <-time.After(time.Second): // (2) 大家也经常在这里使用 <-ctx.Done()
      fmt.Println("超时")
   case err = <-errCh: 
      if err != nil {
         fmt.Println(err)
      } else {
         fmt.Println(nil)
      }
   }
}

由于 go channel 在没有缓冲队列的时候,读取 channel 默认是阻塞的,所以 (1) 处代码会阻塞,(2) 处超时后,由于没有 go routine 读取 channel ,(1) 会一直阻塞。因此输出:

NumGoroutine: 2
超时
NumGoroutine: 3

情景二: for-range 造成的内存泄漏

func TestLeakOfMemory2(t *testing.T) {
   fmt.Println("NumGoroutine:", runtime.NumGoroutine())
   chanLeakOfMemory2()
   time.Sleep(time.Second * 3) // 等待 goroutine 执行,防止过早输出结果
   fmt.Println("NumGoroutine:", runtime.NumGoroutine())
}

func chanLeakOfMemory2() {
   ich := make(chan int, 100)
   // sender
   go func() {
      defer close(ich)
      for i := 0; i < 10000; i++ {
         ich <- i // (2)
         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 { // (1)
            fmt.Println(ctx.Err())
            return
         }
         fmt.Println(i)
      }
   }()
}

// Output:
// NumGoroutine: 2
// 0
// 1
// ...(省略)...
// 789
// context deadline exceeded
// NumGoroutine: 3

注意 (1) 处代码,如果检测到 ctx.Err() != nil,程序会立刻退出,但此时 ich 仍在发送,这就导致代码可能阻塞在 (2) 处,造成了内存泄漏。

解决方案

如果接收者需要在 channel 关闭之前提前退出,为防止内存泄漏,在发送者与接收者发送次数是一对一时,应设置 channel 缓冲队列为 1;在发送者与接收者的发送次数是多对多时,应使用专门的 stop channel 通知发送者关闭相应 channel。

由于篇幅限制,更详细的内容可以看《老手也常误用!详解 Go channel 内存泄漏问题》这篇文章。

总结

以上造成内存泄漏的示例看起来似乎都是小问题,单个示例泄漏的内存不多。但要注意,我们的上述代码可能被写在一个 go routine 中,如果每次访问,都是用一个 go routine 处理(比如后端中,每有一个请求,就会创建一个 go routine 来处理),那么是不是访问的次数越多,泄漏的内存越多。内存泄漏正是由这种看似不起眼的小问题造成的。如果放任不管或不重视,最终造成的结果就是业务频繁宕机、卡顿等。所以我们在业务中应该极其重视。

参考文章

历史文章

阅读原文