Golang 内存泄漏

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

什么是内存泄漏

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

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

go虽然是自动GC类型的语言,但在书写过程中如果不注意,很容易造成内存泄漏的问题。比较常见的是发生在 slice、time.Ticker、goroutine 等的使用过程中。

哪些情况会内存泄漏

不常见情况

全局变量

全局变量在整个程序运行期间都一直存在,如果不断向全局变量中添加数据而不进行清理,将会占用越来越多的内存。

为了避免这样的情况,应尽可能减少全局变量的使用,而是尽量采用参数传递、返回值等方式在函数之间共享数据。当必须使用全局变量时,应确保及时清理不再需要的数据,或者使用适当的数据结构(如固定大小的缓存)保证内存使用的上限。

不恰当的内存池使用

内存池(如 sync.Pool)如果使用不当,可能会导致内存泄漏。例如,如果池中的对象持有对其他大型数据结构的引用,这些数据结构可能不会被及时回收。

要避免这种情况,一种做法是在把对象放回 Pool 之前,确保清除该对象的所有外部引用。但是,这需要程序员非常理解自己代码的行为和 sync.Pool 的工作方式,否则就很容易出错。 另一种做法是尽量避免池化持有大型数据结构的对象,或者更频繁地清理 Pool。总之,使用 sync.Pool 需要非常小心,并确保理解它应如何以及何时使用。

slice 引起的内存泄漏

go 复制代码
var a []int 

func test(b []int) {
    a = b[:3]        
    return
}

如果传入的slice b很大,全局量a引用了b的一小部分,这样新、旧slice指向的都是同一片内存地址,那么只要全局量a在,b就不会被回收,从而造成了所谓的内存泄漏。一般我们不会写这种代码,定义一个全局切片本身就不是一个好的设计。

最好的做法是避免全局变量或者复制需要的数据到一个新的 slice,而不是直接引用。 例如,可以将上述的 test 函数修改为如下形式:

css 复制代码
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的源码:

vbscript 复制代码
// 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 停止定时器。

go 复制代码
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修改后的代码如下:

go 复制代码
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读写都会阻塞

go 复制代码
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阻塞一般存在两种情况:

  1. 无缓冲channel的阻塞通常是写操作因为没有读而阻塞
go 复制代码
func channelTest() {
    var c = make(chan int)
    //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 接收数据,以确保数据的顺序性和同步性。

  1. 有缓冲的channel因为缓冲区满了,写操作阻塞
go 复制代码
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 复制代码
go func() {
    for v := range c {
        fmt.Printf("Received: %d\n", v)
    }
}()

或者,在处理不能保证发送方总是比接收方快的场合,可以通过 select 结构添加超时机制来避免死锁。

读阻塞

和写阻塞一样,读阻塞也分无缓冲和有缓存两种情况:

  1. 无缓冲channel的阻塞通常是读操作因为没有写而阻塞
go 复制代码
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)
}
  1. 有缓冲的channel因为缓冲区空了,读操作阻塞
go 复制代码
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 时,要限制其数量并确保它们能在完成任务后正确结束。

如何避免内存泄漏

  • 及时释放不再使用的内存: 在 Go 中,不能直接释放内存,这是由垃圾回收器自动完成的。通过将指针设置为 nil,可以消除对原对象的引用,使其成为垃圾回收的目标。
  • 注意闭包的使用: 如果闭包长期持有外部变量的引用,可能会造成内存泄漏。在闭包中最小化所需状态,并在完成后尽快使引用失效。
  • 限制全局变量的使用: 全局变量在程序运行过程中一直占用内存,而且如果它们是 map、slice 或 channel 等可增长的数据结构,可能会导致内存无限增长。
  • 注意 goroutine 的使用: 确保每个 goroutine 都有明确的退出条件,并且不会被无限期阻塞。可以使用 context 包或 select 语句来控制 goroutine 的生命周期。
  • 使用 defer 确保资源被释放: 在打开文件、获取数据库连接等操作后,立即使用 defer 来确保在函数结束时这些资源被关闭或释放。
  • 正确使用channel: 确保发送和接收操作是平衡的,避免 goroutine 因等待未来不会发生的事件而被阻塞。确保在所有数据发送完毕后,关闭 channel。
  • 合理使用缓存: 如果你使用缓存来提高性能,使用某种策略(如 LRU)来淘汰旧的或不常用的项,以限制缓存的大小。
  • 正确使用 sync.Pool: sync.Pool 可以重用对象,但是它有自己的垃圾回收机制,对于长期不使用的对象也会进行回收。所以需要正确理解和使用 sync.Pool,避免产生意外的内存泄漏。

如何检测内存泄漏

针对如何检测内存泄露会在下一篇文章中详细阐述

相关推荐
代码之光_19801 小时前
保障性住房管理:SpringBoot技术优势分析
java·spring boot·后端
ajsbxi1 小时前
苍穹外卖学习记录
java·笔记·后端·学习·nginx·spring·servlet
颜淡慕潇2 小时前
【K8S问题系列 |1 】Kubernetes 中 NodePort 类型的 Service 无法访问【已解决】
后端·云原生·容器·kubernetes·问题解决
尘浮生3 小时前
Java项目实战II基于Spring Boot的光影视频平台(开发文档+数据库+源码)
java·开发语言·数据库·spring boot·后端·maven·intellij-idea
尚学教辅学习资料3 小时前
基于SpringBoot的医药管理系统+LW示例参考
java·spring boot·后端·java毕业设计·医药管理
monkey_meng4 小时前
【Rust中的迭代器】
开发语言·后端·rust
余衫马4 小时前
Rust-Trait 特征编程
开发语言·后端·rust
monkey_meng4 小时前
【Rust中多线程同步机制】
开发语言·redis·后端·rust
paopaokaka_luck9 小时前
【360】基于springboot的志愿服务管理系统
java·spring boot·后端·spring·毕业设计
码农小旋风10 小时前
详解K8S--声明式API
后端