Go语言中内存泄漏的常见案例与解决方法
作者:大熊全栈技术分享
Go虽然是自动GC类型的语言,但在编码过程中如果不注意,很容易造成内存泄漏的问题。比较常见的是发生在 slice、time.Ticker、goroutine 等的使用过程中,这里结合我们日常中经常遇到的,以及网上搜集到一些Case进行系统性的总结一下,希望对你的日常工作有所帮助。
slice 类型引起内存泄漏
传入的参数被切片返回,导致局部变量不能被释放
Golang是自带GC的,如果资源一直被占用,是不会被自动释放的,比如下面的代码,如果传入的slice b是很大的,然后引用很小部分给全局量a,那么b未被引用的部分就不会被释放,造成了所谓的内存泄漏。
var a []int func test(b []int) { a = b[:3] return }
想要理解这个内存泄漏,主要就是理解上面的a = b[:3]是一个引用,其实新、旧slice指向的都是同一片内存地址,那么只要全局量a在,b就不会被回收。
如果想避免这个问题,可以使用append方法的实现,如果append的目标slice空间不够,会重新申请一个array来放需要append的内容,所以&b[0]和&a[0]的值是不一样的,而&a[0]和&c[0]地址是一致的:
time.Sleep(time.Second * 5) fmt.Println("main func") var b []int var c []int // 现在,如果再没有其它值引用着承载着a元素的内存块, // 则此内存块可以被回收了。 func test(a []int) { c = a[:1] b = append(a[:0:0], a[:1]...) // 秀操作而已,也可以使用nil fmt.Println(&a[0], &c[0], &b[0]) //0xc0000aa030 0xc0000aa030 0xc0000b2038 }
也可以使用 copy()函数来实现引用类型的深拷贝。copy(dst[], src[])
切片容量导致内存泄漏
假如我们从网络中接受了很大的数据,该协议使用前5个字节标识消息类型。
func consumeMessages() { msg := receiveMessage() // a storeMessageType(getMessageType(msg)) //b // 其他的逻辑处理 } // 然后msg作为一个参数 func getMessageType(msg []byte) []byte { //c return msg[:5] }
我们只想存储每个消息的前5字节代表的消息类型,但同时我们将每条消息的整个容量的数据也存储在了内存中。
解决方式可以使用copy方法,来替代对msg进行切分:
func getMessageType(msg []byte) []byte { msgType := make([]byte, 5) copy(msgType, msg) return msgType }
数组值传递
由于数组是Golang的基本数据类型,每个数组占用不同的内存空间,生命周期互不干扰,很难出现内存泄漏的情况,但是数组作为形参传输时,遵循的是值拷贝,如果函数被多个goroutine调用且数组过大时,则会导致内存使用激增。
因此对于大数组放在形参场景下通常使用切片或者指针进行传递,避免短时间的内存使用激增。
goroutine导致内存泄漏
Go内存泄露,大部分都是goroutine泄露导致的。 虽然每个goroutine仅占用少量(栈)内存,但当大量goroutine被创建却不会释放时(即发生了goroutine泄露),也会消耗大量内存,造成内存泄露。
另外,如果goroutine里还有在堆上申请空间的操作,则这部分堆内存也不能被垃圾回收器回收。
Go 10次内存泄漏,8次goroutine泄漏,1次是真正内存泄漏,还有1次是cgo导致的内存泄漏 (“才高八斗”的既视感..)
在Go中大概单个goroutine占用2.6k左右的内存空间。
Goroutine 内存泄漏的原因
Go 语言的内存泄漏通常因为错误地使用 goroutine 和 channel。例如以下几种情况:
- 在 goroutine 里打开一个连接(如 gRPC)但是忘记 close。
- 在 goroutine 里的全局变量对象没有释放。
- 在 goroutine 里读 channel, 但是没有写入端,而被阻塞。
- 在 goroutine 里写入无缓冲的 channel,但是由于 channel 的读端被其他协程关闭而阻塞。
- 在 goroutine 里写入有缓冲的 channel,但是 channel 缓冲已满。
- select操作在所有case上都阻塞,造成内存泄漏
其实本质上还是channel问题, 因为 select..case只能处理 channel类型, 即每个 case 必须是一个通信操作, 要么是发送要么是接收,select 将随机执行一个可运行 case, 如果没有 case 可运行,它将阻塞,直到有 case 可运行。 有个独立 goroutine去做某些操作的场景下,为了能在外部结束它,通常有两种方法:
同时传入一个用于控制goroutine退出的 quit channel,配合 select,当需要退出时close 这个 quit channel,该 goroutine 就可以退出
使用context
包的WithCancel,可参考context.WithCancel()
的使用
I/O问题,I/O连接未设置超时时间,导致goroutine一直在等待,代码会一直阻塞。
互斥锁未释放,goroutine无法获取到锁资源,导致goroutine阻塞
//协程拿到锁未释放,其他协程获取锁会阻塞 func mutexTest() { mutex := sync.Mutex{} for i := 0; i < 10; i++ { go func() { mutex.Lock() fmt.Printf("%d goroutine get mutex", i) //模拟实际开发中的操作耗时 time.Sleep(100 * time.Millisecond) }() } time.Sleep(10 * time.Second) }
死锁,当程序死锁时其他goroutine也会阻塞
func mutexTest() { m1, m2 := sync.Mutex{}, sync.RWMutex{} //g1得到锁1去获取锁2 go func() { m1.Lock() fmt.Println("g1 get m1") time.Sleep(1 * time.Second) m2.Lock() fmt.Println("g1 get m2") }() //g2得到锁2去获取锁1 go func() { m2.Lock() fmt.Println("g2 get m2") time.Sleep(1 * time.Second) m1.Lock() fmt.Println("g2 get m1") }() //其余协程获取锁都会失败 go func() { m1.Lock() fmt.Println("g3 get m1") }() time.Sleep(10 * time.Second) }
waitgroup使用不当。
waitgroup的Add、Done和wait数量不匹配会导致wait一直在等待。
上面列的情况,在日常开发过程中不容易发现,因此会经常带来一些线上的问题。
select-case误用导致的内存泄露
func TestLeakOfMemory(t *testing.T) { fmt.Println("NumGoroutine:", runtime.NumGoroutine()) chanLeakOfMemory() time.Sleep(time.Second * 3) // 等待 goroutine 执行,防止过早输出结果 fmt.Println("NumGoroutine:", runtime.NumGoroutine()) } func chanLeakOfMemory() { errCh := make(chan error) // 1 go func() { // (5) time.Sleep(2 * time.Second) errCh <- errors.New("chan error") // 2 fmt.Println("finish sending") }() var err error select { case <-time.After(time.Second): // 3 大家也经常在这里使用 <-ctx.Done() fmt.Println("超时") case err = <-errCh: // 4 if err != nil { fmt.Println(err) } else { fmt.Println(nil) } } }
输出结果如下:
NumGoroutine: 2
超时
NumGoroutine: 3
这是 go channel 导致内存泄漏的经典场景。 根据输出结果(开始有两个 goroutine,结束时有三个 goroutine),我们可以知道,直到测试函数结束前,仍有一个 goroutine 没有退出。
原因是由于 1 处创建的 errCh 是不含缓存队列的 channel,如果 channel 只有发送方发送,那么发送方会阻塞;如果 channel 只有接收方,那么接收方会阻塞。
可以看到由于没有发送方往 errCh 发送数据,所以 4 处代码一直阻塞。
直到 3 处超时后,打印“超时”,函数退出,4 处代码都未接收成功。
而 2 处的所在的 goroutine 在“超时”被打印后,才开始发送。
由于外部的 goroutine 已经退出了,errCh 没有接收者,导致 2 处一直阻塞。
因此 2 处代码所在的协程一直未退出,造成了内存泄漏。
如果代码中有许多类似的代码,或在 for 循环中使用了上述形式的代码,随着时间的增长会造成多个未退出的 gorouting,最终导致程序 OOM。
这种情况其实还比较简单。我们只需要为 channel 增加一个缓存队列。即把 (1) 处代码改为 errCh := make(chan error, 1) 即可。修改后输出如下所示,可知我们创建的 goroutine 已经退出了。
NumGoroutine: 2
超时
NumGoroutine: 2
可能会有人想要使用 defer close(errCh) 关闭 channel。比如把 1 处代码改为如下形式(错误):
errCh := make(chan error) defer close(errCh)
由于 2 处代码没有接收者,所以一直阻塞。直到 close(errCh) 运行,2 处仍在阻塞。这导致关闭 channel 时,仍有 goroutine 在向 errCh 发送。然而在 golang 中,在向 channel 发送时不能关闭 channel,否则会 panic。因此这种方式是错误的。
又或在 5 处 goroutine 的第一句加上 defer close(errCh)。由于 2 处阻塞, defer close(errCh) 会一直得不到执行。因此也是错误的。 即便对调 2 处和 4 处的发送者和接收者,也会因为 channel 关闭,导致输出无意义的零值。
for range 导致的协程泄漏
func leakOfMemory_1(nums ...int) { out := make(chan int) // sender go func() { defer close(out) for _, n := range nums { // c. out <- n fmt.Printf("sender success: %v\n", n) time.Sleep(time.Second) } }() // receiver go func() { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() for n := range out { //b. if ctx.Err() != nil { //a. fmt.Println("ctx timeout ") return } fmt.Println(n) } }() } // 单测文件中执行 func TestLeakOfMemory(t *testing.T) { fmt.Println("NumGoroutine:", runtime.NumGoroutine()) leakOfMemory_1(1, 2, 3, 4, 5, 6, 7) time.Sleep(3 * time.Second) fmt.Println("main exit...") fmt.Println("NumGoroutine:", runtime.NumGoroutine()) }
执行结果如下:
=== RUN TestLeakOfMemory
NumGoroutine: 2
1
sender success: 1
sender success: 2
2
ctx timeout
sender success: 3
main exit...
NumGoroutine: 3
--- PASS: TestLeakOfMemory (3.00s)
PASS
理论上,是不是最开始只有2个goruntine ,实际上执行完出现了3个gorountine。
说明 leakOfMemory_1 里面起码有一个协程没有退出。 因为时间到了,在 a 处,程序就准备退出了,也就是说 b 这个就退出了,没有接收者继续接受 chan 中的数据了。c处往chan 写数据就阻塞了,因此协程一直没有退出,就造成了泄漏。
如何解决上面说的协程泄漏问题? 可以加个管道通知来防止内存泄漏。
goruntine 中 map 并发
map 是引用类型,函数值传值是调用,参数副本依然指向m,因为值传递的是引用,对于共享变量,资源并发读写会产生竞争。 下面的场景在工作中经常遇到(测的时候不容易发现)。
func TestConcurrencyMap(t *testing.T) { m := make(map[int]int) go func() { for { m[3] = 3 } }() go func() { for { m[2] = 2 } }() //select {} time.Sleep(10 * time.Second) }
time.Ticker** 误用造成内存泄漏**
注意:Ticker 和 Timer 是不同的。Timer 只会定时一次,而 Ticker 如果不 Stop,就会一直发送定时。
func TestTickerNormal(t *testing.T) { ticker := time.NewTicker(time.Second) defer ticker.Stop() // stop一定不能漏了 go func() { for { fmt.Println(<-ticker.C) } }() time.Sleep(time.Second * 3) fmt.Println("finish") }
time.After()使用注意事项
看下面的例子:
func TestTimeAfter(t *testing.T) { defer func() { fmt.Println(runtime.NumGoroutine()) }() go func() { ticker := time.NewTicker(time.Second * 1) for { select { case <-ticker.C: fmt.Println("hello world") case <-time.After(time.Second * 3): fmt.Println("exit") return } } }() time.Sleep(time.Second * 5) fmt.Println("main func") } // 输出结果如下 === RUN TestTimeAfter hello world hello world hello world hello world hello world main func 3 --- PASS: TestTimeAfter (5.00s) PASS
从输出结果看,程序根本没有打印exit, 也证明了goroutine不是由time.After() 退出,而是函数执行结果退出。
看下关于time.After() 实现原理:After底层是用NewTimer实现, NewTimer(d).C 每次都是 return 了一个新的对象。
func After(d Duration) <-chan Time { return NewTimer(d).C } func NewTimer(d Duration) *Timer { c := make(chan Time, 1) t := &Timer{ C: c, r: runtimeTimer{ when: when(d), f: sendTime, arg: c, }, } startTimer(&t.r) return t }
可以进行如下的修改
func TestTimeAfter(t *testing.T) { defer func() { fmt.Println(runtime.NumGoroutine()) }() idleDuration := time.After(time.Second * 3) ticker := time.NewTicker(time.Second * 1) defer ticker.Stop() for { select { case <-ticker.C: fmt.Println("hello world") case <-idleDuration: fmt.Println("exit") return } } time.Sleep(time.Second * 5) fmt.Println("main func") }
下面的这个例子,是经常遇到的一定要注意: 定时器定义位置
func main() { chi := make(chan int) go func() { for { // 定时器都是新创建的,那么就会造成永久性的泄露。 timer := time.After(10 * time.Second) select { case <-ch: fmt.Println("get it") case <-timer: fmt.Println("end") } } }() for i:= 1; i< 1000000; i++ { chi <- i time.sleep(time.Millisecond) } }
到此这篇关于Go语言中内存泄漏的常见案例与解决方法的文章就介绍到这了,更多相关Go内存泄漏内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!