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,避免产生意外的内存泄漏。

如何检测内存泄漏

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

相关推荐
舒一笑4 分钟前
大模型时代的程序员成长悖论:如何在AI辅助下不失去竞争力
后端·程序员·掘金技术征文
lang201509286 分钟前
Spring Boot优雅关闭全解析
java·spring boot·后端
小羊在睡觉1 小时前
golang定时器
开发语言·后端·golang
用户21411832636021 小时前
手把手教你在魔搭跑通 DeepSeek-OCR!光学压缩 + MoE 解码,97% 精度还省 10-20 倍 token
后端
追逐时光者1 小时前
一个基于 .NET 开源、功能强大的分布式微服务开发框架
后端·.net
刘一说1 小时前
Spring Boot 启动慢?启动过程深度解析与优化策略
java·spring boot·后端
壹佰大多2 小时前
【spring如何扫描一个路径下被注解修饰的类】
java·后端·spring
间彧2 小时前
Java双亲委派模型的具体实现原理是什么?
后端
间彧2 小时前
Java类的加载过程
后端
DokiDoki之父2 小时前
Spring—注解开发
java·后端·spring