• Go如何避免内存泄漏
  • 发布于 2个月前
  • 109 热度
    0 评论

Golang 作为自带垃圾回收(Garbage Collection,GC)机制的语言,可以自动管理内存。但在实际开发中代码编写不当的话也会出现内存泄漏的情况。


什么是内存泄漏
内存泄漏并不是指物理上的内存消失,而是指程序在申请内存后,未能及时释放不再使用的内存空间,导致这部分内存无法被再次使用,随着时间的推移,程序占用的内存不断增长,最终导致系统资源耗尽或程序崩溃。;短期内的内存泄漏可能看不出什么影响,但是当时间长了之后,日积月累,浪费的内存越来越多,导致可用的内存空间减少,轻则影响程序性能,严重可导致正在运行的程序突然崩溃。

备注:一般一个进程结束之后,内存会自动回收,同时也会自动回收那些被泄露的内存,当进程重新启动后,这些内存又可以重新被分配使用。但是正常情况下企业的程序是不会经常重启的,所以最好的办法就是从源头上解决内存泄漏的问题。

go虽然是自动GC类型的语言,但在书写过程中如果不注意,很容易造成内存泄漏的问题。比较常见的是发生在 slice、time.Ticker、goroutine 等的使用过程中。
哪些情况会内存泄漏
不常见情况
全局变量
全局变量在整个程序运行期间都一直存在,如果不断向全局变量中添加数据而不进行清理,将会占用越来越多的内存。为了避免这样的情况,应尽可能减少全局变量的使用,而是尽量采用参数传递、返回值等方式在函数之间共享数据。当必须使用全局变量时,应确保及时清理不再需要的数据,或者使用适当的数据结构(如固定大小的缓存)保证内存使用的上限。


不恰当的内存池使用

内存池(如 sync.Pool)如果使用不当,可能会导致内存泄漏。例如,如果池中的对象持有对其他大型数据结构的引用,这些数据结构可能不会被及时回收。要避免这种情况,一种做法是在把对象放回 Pool 之前,确保清除该对象的所有外部引用。但是,这需要程序员非常理解自己代码的行为和 sync.Pool 的工作方式,否则就很容易出错。


另一种做法是尽量避免池化持有大型数据结构的对象,或者更频繁地清理 Pool。总之,使用 sync.Pool 需要非常小心,并确保理解它应如何以及何时使用。
slice 引起的内存泄漏
var a []int 

func test(b []int) {
    a = b[:3]        
    return
}
如果传入的slice b很大,全局量a引用了b的一小部分,这样新、旧slice指向的都是同一片内存地址,那么只要全局量a在,b就不会被回收,从而造成了所谓的内存泄漏。一般我们不会写这种代码,定义一个全局切片本身就不是一个好的设计。
最好的做法是避免全局变量或者复制需要的数据到一个新的 slice,而不是直接引用。
例如,可以将上述的 test 函数修改为如下形式:
func test(b []int) {
    a = make([]int, 3)
    copy(a, b[:3])
    return
}

这样,a 将持有 b 前三个元素的一个副本,而不是直接引用 b,所以即使 a 依然存在,b 也可以被垃圾回收器正常回收。


常见情况
select阻塞

使用select时如果有case没有覆盖完全的情况且没有default分支进行处理,最终会导致内存泄漏。具体说来,如果你在 select 语句中等待来自某个 channel 的数据,但是没有更多的数据发送到这个 channel 上,或者没有其他协程来从这个 channel 接收数据,那么使用 select 的协程就会永远阻塞。因为这个协程无法结束,所以它占用的所有资源(包括内存)都无法被垃圾回收器释放,从而导致内存泄露。


要避免这种情况,一种做法是使用带有超时机制的 select。你可以通过 time.After 函数创建一个定时 channel,当超过指定时间后,这个 channel 就可以接收到一个数据,这样就可以防止 select 永久阻塞。


此外,还需要确保每个打开的 channel 最终都会正确关闭,这样接收方就可以得到一个零值以及一个标志,表示 channel 已经没有更多数据。对于发送方,如果知道没有更多接收器,应该关闭 channel,否则发送操作可能会永远阻塞。


time.After使用不当
我们先来看下time.After的源码:
// https://github.com/golang/go/blob/go1.20.4/src/time/sleep.go#LL150C1-L158C2  
  
// After waits for the duration to elapse and then sends the current time  
// on the returned channel.   
// It is equivalent to NewTimer(d).C.  
// The underlying Timer is not recovered by the garbage collector  
// until the timer fires. If efficiency is a concern, use NewTimer  
// instead and call Timer.Stop if the timer is no longer needed.  
func After(d Duration) <-chan Time {  
 return NewTimer(d).C  
}
代码注释已经描述的很清楚了,每次time.After(duration x)会产生NewTimer(), 在duration x到期之前,新创建的timer不会被GC,到期之后才会GC。随着时间推移,尤其是duration x很大的话,会产生内存泄露的问题。如果担心效率问题,可以使用 NewTimer 代替,如果不需要定时器可以调用 Timer.Stop 停止定时器。
package main  
  
import (  
 "fmt"  
 "net/http"  
 _ "net/http/pprof"  
 "time"  
)  
  
func main() {  
 fmt.Println("start...")  
 ch1 := make(chan string, 120)  
  
 go func() {  
  // time.Sleep(time.Second * 1)  
  i := 0  
  for {  
   i++  
   ch1 <- fmt.Sprintf("%s %d", "hello", i)  
  }  
  
 }()  
  
 go func() {  
  // http 监听8080, 开启 pprof  
  if err := http.ListenAndServe(":8080", nil); err != nil {  
   fmt.Println("listen failed")  
  }  
 }()  
  
 for {  
  select {  
  case _ = <-ch1:  
   // fmt.Println(res)  
  case <-time.After(time.Minute * 3):  
   fmt.Println("timeout")  
  }  
 }  
}
在上面的程序中,time.After(time.Minute * 3) 设置了 3 分钟,也就是说 3 分钟后才会执行定时器任务。而这期间会不断被 for 循环调用 time.After,导致它不断创建和申请内存,内存就会一直往上涨。
使用time.NewTimer修改后的代码如下:
package main  
  
import (  
 "fmt"  
 "net/http"  
 _ "net/http/pprof"  
 "time"  
)  
  
func main() {  
 fmt.Println("start...")  
 ch1 := make(chan string, 120)  
  
 go func() {  
  // time.Sleep(time.Second * 1)  
  i := 0  
  for {  
   i++  
   ch1 <- fmt.Sprintf("%s %d", "hello", i)  
  }  
  
 }()  
  
 go func() {  
  // http 监听8080, 开启 pprof  
  if err := http.ListenAndServe(":8080", nil); err != nil {  
   fmt.Println("listen failed")  
  }  
 }()  
  
 duration := time.Minute * 2  
 timer := time.NewTimer(duration)  
 defer timer.Stop()  
 for {  
  timer.Reset(duration) // 这里加上 Reset()  
  select {  
  case _ = <-ch1:  
   // fmt.Println(res)  
  case <-timer.C:  
   fmt.Println("timeout")  
   return  
  }  
 }  
}
channel阻塞
对声明未初始化的channel读写都会阻塞
func channelTest() {
    //声明未初始化的channel读写都会阻塞
    var c chan int
    //向channel中写数据
    go func() {
        c <- 1
        fmt.Println("g1 send succeed")
        time.Sleep(1 * time.Second)
    }()
    //从channel中读数据
    go func() {
        <-c
        fmt.Println("g2 receive succeed")
        time.Sleep(1 * time.Second)
    }()
    time.Sleep(10 * time.Second)
}
为避免这种情况,在声明 channel 类型的变量后,别忘了使用 make 函数进行初始化。
写channel阻塞
写channel阻塞一般存在两种情况:
无缓冲channel的阻塞通常是写操作因为没有读而阻塞
func channelTest() {
    var c = make(chan int)
    // 堆代码 duidaima.com
    //10个协程向channel中写数据
    for i := 0; i < 10; i++ {
        go func() {
            c <- 1
            fmt.Println("g1 receive succeed")
            time.Sleep(1 * time.Second)
        }()
    }
    //1个协程从channel读数据
    go func() {
        <- c 
        fmt.Println("g2 send succeed")
        time.Sleep(1 * time.Second)
    }()
    //会有写的9个协程阻塞得不到释放
    time.Sleep(10 * time.Second)
}
通常我们不会编写像上面这样使多个 goroutine 阻塞的代码。对于无缓冲的 channel,一般来说,最佳做法是使一个 goroutine 发送数据,另一个 goroutine 接收数据,以确保数据的顺序性和同步性。

有缓冲的channel因为缓冲区满了,写操作阻塞
func channelTest() {
    var c = make(chan int, 8)
    //10个协程向channel中写数据
    for i := 0; i < 10; i++ {
        go func() {
            c <- 1
            fmt.Println("g2 send succeed")
            time.Sleep(1 * time.Second)
        }()
    }
    
    go func() {
        <- c
        fmt.Println("g1 receive succeed")
        time.Sleep(1 * time.Second)
    }()

    //会有写的几个协程阻塞写不进去
    time.Sleep(10 * time.Second)
}
实际编程中,我们往往不会编写这样可能导致大量 goroutine 阻塞的代码。通常情况下,读操作是在一个循环中进行的,以便持续消费 channel 中的数据。例如,我们可以使用 range 来迭代一个 channel,这将持续获取 channel 直到它关闭。
go func() {
    for v := range c {
        fmt.Printf("Received: %d\n", v)
    }
}()
或者,在处理不能保证发送方总是比接收方快的场合,可以通过 select 结构添加超时机制来避免死锁。
读阻塞
和写阻塞一样,读阻塞也分无缓冲和有缓存两种情况:
无缓冲channel的阻塞通常是读操作因为没有写而阻塞
func channelTest() {
    var c = make(chan int)
    //10个协程从channel中读数据
    for i := 0; i < 10; i++ {
        go func() {
            <- c
            fmt.Println("g1 receive succeed")
            time.Sleep(1 * time.Second)
        }()
    }
    
    //1个协程向channel写数据
    go func() {
        c <- 1
        fmt.Println("g2 send succeed")
        time.Sleep(1 * time.Second)
    }()
    //会有写的9个协程阻塞得不到释放
    time.Sleep(10 * time.Second)
}
有缓冲的channel因为缓冲区空了,读操作阻塞
func channelTest() {
    var c = make(chan int, 8)

    for i := 0; i < 10; i++ {
        go func() {
            <- c
            fmt.Println("g1 receive succeed")
            time.Sleep(1 * time.Second)
        }()
    }

    go func() {
        c <- 1
        fmt.Println("g2 send succeed")
        time.Sleep(1 * time.Second)
    }()
    //会有读的几个协程阻塞
    time.Sleep(10 * time.Second)
}

读阻塞都可以通过一个方法来解决,那就是让负责写入的goroutine去关闭channel。当channel被关闭时,任何尝试从中读取数据的goroutine都将停止阻塞并得到通知,因此不会再等待更多的数据。这就是为什么我们在编程时,需要始终保持警惕并在适当的时机关闭channel,以避免可能出现的阻塞问题。


goroutine导致的内存泄漏
goroutine在以下几种情况下会出现goroutine泄漏:
1.申请过多的goroutine: 例如在for循环中申请过多的goroutine来不及释放导致内存泄漏
2.goroutine阻塞
  I/O问题: I/O连接未设置超时时间,导致goroutine一直在等待,代码会一直阻塞
  互斥锁未释放: goroutine无法获取到锁资源,导致goroutine阻塞



在实际编程中,我们需要注意以上所有可能引起 goroutine 泄漏的情况,并采用适当的设计和编程技巧来避免这些问题。例如,可以设定 I/O 操作的超时时间,以防止 goroutine 永远阻塞;在使用互斥锁时,要确保在所有路径(包括错误路径和异常路径)上都能正确释放锁;在创建 goroutine 时,要限制其数量并确保它们能在完成任务后正确结束。


如何避免内存泄漏

1.及时释放不再使用的内存: 在 Go 中,不能直接释放内存,这是由垃圾回收器自动完成的。通过将指针设置为 nil,可以消除对原对象的引用,使其成为垃圾回收的目标。

2.注意闭包的使用: 如果闭包长期持有外部变量的引用,可能会造成内存泄漏。在闭包中最小化所需状态,并在完成后尽快使引用失效。

限制全局变量的使用: 全局变量在程序运行过程中一直占用内存,而且如果它们是 map、slice 或 channel 等可增长的数据结构,可能会导致内存无限增长。

3.注意 goroutine 的使用: 确保每个 goroutine 都有明确的退出条件,并且不会被无限期阻塞。可以使用 context 包或 select 语句来控制 goroutine 的生命周期。

4.使用 defer 确保资源被释放: 在打开文件、获取数据库连接等操作后,立即使用 defer 来确保在函数结束时这些资源被关闭或释放。

5.正确使用channel: 确保发送和接收操作是平衡的,避免 goroutine 因等待未来不会发生的事件而被阻塞。确保在所有数据发送完毕后,关闭 channel。

6.合理使用缓存: 如果你使用缓存来提高性能,使用某种策略(如 LRU)来淘汰旧的或不常用的项,以限制缓存的大小。
7.正确使用 sync.Pool: sync.Pool 可以重用对象,但是它有自己的垃圾回收机制,对于长期不使用的对象也会进行回收。所以需要正确理解和使用 sync.Pool,避免产生意外的内存泄漏。
用户评论