如何写出内存泄露的程序?

 

不管使用什么语言,内存泄露是经常遇到的一类问题,然而使用Go语言编写内存泄露的代码却不容易,本文将列举几个可能出现内存泄露的场景,从反例中学习如何避免内存泄露

This article was first published in the Medium MPP plan. If you are a Medium user, please follow me on Medium. Thank you very much.

资源泄露

不关闭打开的文件

当你不再需要一个打开的文件,正常你需要调用它的Close方法,如果你不调用Close,就有可能文件描述符达到最大限制,无法在打开新的文件或者连接,程序会报too many open files的错误。比如下面的例子:
Code 1: 文件没关闭导致 耗尽文件描述符。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func main() {  
    files := make([]*os.File, 0)  
    for i := 0; ; i++ {  
       file, err := os.OpenFile("test.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)  
       if err != nil {  
          fmt.Printf("Error at file %d: %v\n", i, err)  
          break  
       } else {  
          _, _ = file.Write([]byte("Hello, World!"))  
          files = append(files, file)  
       }  
    }  
}
----
  memory_leak git:(main)  go run close_file.go 
Error at file 61437: open test.log: too many open files

在我的Mac 电脑上一个进程能打开的文件句柄数量最大是61440,也可以手动设置这个数值。go 程序会默认打开 stderrstdoutstdin 三个文件句柄,一个进程最多能够打开61437 文件,多了就会报错。

http.Response.Body.Close()

Go 语言有一个 比较“知名”的bug,相信您一定看到过:如果我们忘记关闭 http 请求的body 的话,会导致内存泄露,比如下面的代码。
https://gist.github.com/hxzhouh/1e63ef82a1088ac378384e30651b20c9
code 2: http body 没有关闭导致内存泄露。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func makeRequest() {  
    client := &http.Client{}  
    req, err := http.NewRequest(http.MethodGet, "http://localhost:8081", nil)  
    res, err := client.Do(req)  
    if err != nil {  
       fmt.Println(err)  
    }  
    _, err = ioutil.ReadAll(res.Body)  
    // defer res.Body.Close()  
    if err != nil {  
       fmt.Println(err)  
    }  
}

关于这个问题您可以参考下面的文章了解更多信息,后面如果有时间的话,我们从头梳理一下 net/http

字符串/slice 导致内存泄露

虽然 Go spec 并没有说明一个字符串表达式的结果(子)字符串和原来字符串是否共享一个内存块 但编译器确实让它们共享一个内存块,而且很多标准库包的函数原型设计也默认了这一点。这是一个好的设计,它不仅节省内存,而且还减少了CPU消耗。 但是有时候它会造成暂时性的内存泄露。
Code 3: 字符串导致内存泄露。
https://gist.github.com/hxzhouh/e09587195e2d7aa2d5f6676777c6cb16

![[Pasted image 20240925174837.png]]
为防止createStringWithLengthOnHeap 临时性内存泄露,我们可以使用strings.Clone()
Code 4 : 使用strings.Clone() 避免临时内存泄露。

1
2
3
4
5
6
func Demo1() {  
    for i := 0; i < 10; i++ {  
       s := createStringWithLengthOnHeap(1 << 20) //1M  
       packageStr1 = append(packageStr1, strings.Clone(s[:50]))  
    }  
}

这样就不会导致临时性内存泄露了。

goroutine leak

goroutine handler

绝大部分内存泄露的原因是因为goroutine泄露,比如下面的例子,很快将会内存耗尽 而导致OOM
Code 5: goroutine handler

1
2
3
4
5
6
for {  
       go func() {  
          time.Sleep(1 * time.Hour)  
       }()  
    }  
}

channel

channel 的使用错误也很容易导致 goroutine 泄露,
对于无缓冲的channel,必须要等到生产者和消费者全部就绪后,才能往channel写数据,否则将会阻塞。下面的例子因为Example 提前退出导致协程泄露。
Code 6: 不合理使用无缓冲channel 导致goroutine泄露

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func Example() {
    a := 1
    c := make(chan error)
    go func() {
        c <- err
        return
    }()
    // Example 在这里退出,导致协程泄露。
    if a > 0 {
        return
    }
    err := <-c
}

只需要改成有缓冲的channel 就能解决这个问题 c:= make(chan error,1)
还有一个典型的例子就是channel range 误用。
Code 7: 不合理使用range 导致goroutine泄露

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func main() {  
    wg := &sync.WaitGroup{}  
    c := make(chan any, 1)  
    items := []int{1, 2, 3, 4, 5}  
    for _, i := range items {  
       wg.Add(1)  
       go func() {  
          c <- i  
       }()  
    }  
    go func() {  
       for data := range c {  
          fmt.Println(data)  
          wg.Done()  
       }  
       fmt.Println("close")  
    }()  
    wg.Wait()  
    time.Sleep(1 * time.Second)  
}

channel 可以使用 range 迭代 .但是一旦读取不到内容,range 就会等待 channel 的写入,而 range 如果正好在 goroutine 内部,这个 goroutine 就会被阻塞,造成泄露。正确的做法是: 在wg.Wait() 后面 close channel.

runtime.SetFinalizer误用

如果两个对象都设置了 runtime.SetFinalizer 并且他们之间存在 “循环引⽤” ,那么这两个对象将会泄露,即时他们不再使用,GC 也不会回收他们。
关于 runtime.SetFinalizer 的更多内容,可以参考我的另外一篇文章

time.Ticker

这是go 1.23 版本之前的问题了, 如果我们不调用ticker.Stop().go 1.23 已经不会造成泄露了 https://go.dev/doc/go1.23#timer-changes

defer

我们一般习惯在defer 中释放资源 defer 函数本身不会导致内存泄露。但是它的两个机制可能会导致内存临时性泄露。

  1. 执行时间,defer 总是在函数结束的运行。如果您的函数运行时间过长,或者永远不会结束,那么您在defer 中释放的资源可能,很久都不会被释放,或者永远都不被释放。
  2. defer 本身也需要占用内存,每个 defer 都会在内存中添加一个调用点。如果您在循环中使用defer,有可能会导致临时性的内存泄露。
    Code 8: defer 导致 内存临时泄露
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func ReadFile(files []string) {  
    for _, file := range files {  
	  f, err := os.Open(file)  
	  if err != nil {  
		 fmt.Println(err)  
		 return  
	  }  
	  // do something  
	  defer f.Close()  
    }  
}

比如上面的代码,不仅仅可能会导致 defer 临时内存泄露,还可能会导致too many open files
不要痴迷于使用defer 除非你觉得代码你的代码可能会panic ,否则及时关闭文件是一个更好的选择。

总结

本文列举了几种可能会导致go 内存泄露的行为,同时 Goroutine 内存泄漏是 Go 语言最容易发生的内存泄漏情况,它通常伴随着错误地使用 goroutine 和 channel等。而 channel 的特殊用法如 select 和 range 又让 channel 阻塞变得更加隐蔽不易发现,进而增加排查内存泄漏的难度。
遇到内存泄露问题,我们可以通过 pprof 帮助我们快速的定位问题,希望我们每个人都能写出健壮的代码。

参考资料

https://go101.org/article/memory-leaking.html

Licensed under CC BY-NC-SA 4.0
最后更新于 Sep 04, 2024 14:39 CST
使用 Hugo 构建
主题 StackJimmy 设计