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阻塞一般存在两种情况:
- 无缓冲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 接收数据,以确保数据的顺序性和同步性。
- 有缓冲的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 结构添加超时机制来避免死锁。
读阻塞
和写阻塞一样,读阻塞也分无缓冲和有缓存两种情况:
- 无缓冲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)
}
- 有缓冲的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泄漏:
- 申请过多的goroutine: 例如在for循环中申请过多的goroutine来不及释放导致内存泄漏
- 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,避免产生意外的内存泄漏。
如何检测内存泄漏
针对如何检测内存泄露会在下一篇文章中详细阐述