Go 防止 goroutine 泄露的方法
概述
Go的并发模型与其他语言不同,虽说它简化了并发程序的开发难度,但如果不了解使用方法,常常会遇到goroutine泄露的问题。虽然goroutine是轻量级的线程,占用资源很少,但如果一直得不到释放并且还在不断创建新协程,毫无疑问是有问题的,并且是要在程序运行几天,甚至更长的时间才能发现的问题。
对于上面描述的问题,我觉得可以从两方面入手解决,如下:
一是预防,要做到预防,我们就需要了解什么样的代码会产生泄露,以及了解如何写出正确的代码;
二是监控,虽说预防减少了泄露产生的概率,但没有人敢说自己不犯错,因而,通常我们还需要一些监控手段进一步保证程序的健壮性;
接下来,我将会分两篇文章分别从这两个角度进行介绍,今天先谈第一点。
如何监控泄露
本文主要集中在第一点上,但为了更好的演示效果,可以先介绍一个最简单的监控方式。通过runtime.NumGoroutine()获取当前运行中的goroutine数量,通过它确认是否发生泄漏。它的使用非常简单,就不为它专门写个例子了。
一个简单的例子
语言级别的并发支持是Go的一大优势,但这个优势也很容易被滥用。通常我们在开始Go并发学习时,常常听别人说,Go的并发非常简单,在调用函数前加上go关键词便可启动goroutine,即一个并发单元,但很多人可能只听到了这句话,然后就出现了类似下面的代码:
packagemain import( "fmt" "runtime" "time" ) funcsayHello(){ for{ fmt.Println("Hellogorotine") time.Sleep(time.Second) } } funcmain(){ deferfunc(){ fmt.Println("thenumberofgoroutines:",runtime.NumGoroutine()) }() gosayHello() fmt.Println("Hellomain") }
对Go比较熟悉的话,很容易发现这段代码的问题,sayHello是个死循环,没有如何退出机制,因此也就没有任何办法释放创建的goroutine。我们通过在main函数最前面的defer实现在函数退出时打印当前运行中的goroutine数量,毫无意外,它的输出如下:
thenumberofgoroutines:2
不过,因为上面的程序并非常驻,有泄露问题也不大,程序退出后系统会自动回收运行时资源。但如果这段代码在常驻服务中执行,比如httpserver,每接收到一个请求,便会启动一次sayHello,时间流逝,每次启动的goroutine都得不到释放,你的服务将会离奔溃越来越近。
这个例子比较简单,我相信,对Go的并发稍微有点了解的朋友都不会犯这个错。
泄露情况分类
前面介绍的例子由于在goroutine运行死循环导致的泄露。接下来,我会按照并发的数据同步方式对泄露的各种情况进行分析。简单可归于两类,即:
- channel导致的泄露
- 传统同步机制导致的泄露
传统同步机制主要指面向共享内存的同步机制,比如排它锁、共享锁等。这两种情况导致的泄露还是比较常见的。go由于defer的存在,第二类情况,一般情况下还是比较容易避免的。
chanel引起的泄露
先说channel,如果之前读过官方的那篇并发的文章[1],翻译版[2],你会发现channel的使用,一个不小心就泄露了。我们来具体总结下那些情况下可能导致。
发送不接收
我们知道,发送者一般都会配有相应的接收者。理想情况下,我们希望接收者总能接收完所有发送的数据,这样就不会有任何问题。但现实是,一旦接收者发生异常退出,停止继续接收上游数据,发送者就会被阻塞。这个情况在前面说的文章[3]中有非常细致的介绍。
示例代码:
packagemain import"time" funcgen(nums...int)<-chanint{ out:=make(chanint) gofunc(){ for_,n:=rangenums{ out<-n } close(out) }() returnout } funcmain(){ deferfunc(){ fmt.Println("thenumberofgoroutines:",runtime.NumGoroutine()) }() //Setupthepipeline. out:=gen(2,3) forn:=rangeout{ fmt.Println(n)//2 time.Sleep(5*time.Second)//donething,可能异常中断接收 iftrue{//iferr!=nil break } } }
例子中,发送者通过outchan向下游发送数据,main函数接收数据,接收者通常会依据接收到的数据做一些具体的处理,这里用Sleep代替。如果这期间发生异常,导致处理中断,退出循环。gen函数中启动的goroutine并不会退出。
如何解决?
此处的主要问题在于,当接收者停止工作,发送者并不知道,还在傻傻地向下游发送数据。故而,我们需要一种机制去通知发送者。我直接说答案吧,就不循渐进了。Go可以通过channel的关闭向所有的接收者发送广播信息。
修改后的代码:
packagemain import"time" funcgen(donechanstruct{},nums...int)<-chanint{ out:=make(chanint) gofunc(){ deferclose(out) for_,n:=rangenums{ select{ caseout<-n: case<-done: return } } }() returnout } funcmain(){ deferfunc(){ time.Sleep(time.Second) fmt.Println("thenumberofgoroutines:",runtime.NumGoroutine()) }() //Setupthepipeline. done:=make(chanstruct{}) deferclose(done) out:=gen(done,2,3) forn:=rangeout{ fmt.Println(n)//2 time.Sleep(5*time.Second)//donething,可能异常中断接收 iftrue{//iferr!=nil break } } }
函数gen中通过select实现2个channel的同时处理。当异常发生时,将进入<-done分支,实现goroutine退出。这里为了演示效果,保证资源顺利释放,退出时等待了几秒保证释放完成。
执行后的输出如下:
thenumberofgoroutines: 1
现在只有主goroutine存在。
接收不发送
发送不接收会导致发送者阻塞,反之,接收不发送也会导致接收者阻塞。直接看示例代码,如下:
packagemain funcmain(){ deferfunc(){ time.Sleep(time.Second) fmt.Println("thenumberofgoroutines:",runtime.NumGoroutine()) }() varchchanstruct{} gofunc(){ ch<-struct{}{} }() }
运行结果显示:
thenumberofgoroutines: 2
当然,我们正常不会遇到这么傻的情况发生,现实工作中的案例更多可能是发送已完成,但是发送者并没有关闭channel,接收者自然也无法知道发送完毕,阻塞因此就发生了。
解决方案是什么?那当然就是,发送完成后一定要记得关闭channel。
nilchannel
向nilchannel发送和接收数据都将会导致阻塞。这种情况可能在我们定义channel时忘记初始化的时候发生。
示例代码:
funcmain(){ deferfunc(){ time.Sleep(time.Second) fmt.Println("thenumberofgoroutines:",runtime.NumGoroutine()) }() varchchanint gofunc(){ <-ch //ch<- }() }
两种写法:<-ch和ch<-1,分别表示接收与发送,都将会导致阻塞。如果想实现阻塞,通过nilchannel和donechannel结合实现阻止main函数的退出,这或许是可以一试的方法。
funcmain(){ deferfunc(){ time.Sleep(time.Second) fmt.Println("thenumberofgoroutines:",runtime.NumGoroutine()) }() done:=make(chanstruct{}) varchchanint gofunc(){ deferclose(done) }() select{ case<-ch: case<-done: return } }
在goroutine执行完成,检测到done关闭,main函数退出。
真实的场景
真实的场景肯定不会像案例中的简单,可能涉及多阶段goroutine之间的协作,某个goroutine可能即使接收者又是发送者。但归根到底,无论什么使用模式。都是把基础知识组织在一起的合理运用。
传统同步机制
虽然,一般推荐Go并发数据的传递,但有些场景下,显然还是使用传统同步机制更合适。Go中提供传统同步机制主要在sync和atomic两个包。接下来,我主要介绍的是锁和WaitGroup可能导致goroutine的泄露。
Mutex
和其他语言类似,Go中存在两种锁,排它锁和共享锁,关于它们的使用就不作介绍了。我们以排它锁为例进行分析。
示例如下:
funcmain(){ total:=0 deferfunc(){ time.Sleep(time.Second) fmt.Println("total:",total) fmt.Println("thenumberofgoroutines:",runtime.NumGoroutine()) }() varmutexsync.Mutex fori:=0;i<2;i++{ gofunc(){ mutex.Lock() total+=1 }() } }
执行结果如下:
total:1
thenumberofgoroutines:2
这段代码通过启动两个goroutine对total进行加法操作,为防止出现数据竞争,对计算部分做了加锁保护,但并没有及时的解锁,导致i=1的goroutine一直阻塞等待i=0的goroutine释放锁。可以看到,退出时有2个goroutine存在,出现了泄露,total的值为1。
怎么解决?因为Go有defer的存在,这个问题还是非常容易解决的,只要记得在Lock的时候,记住deferUnlock即可。
示例如下:
mutex.Lock() defermutext.Unlock()
其他的锁与这里其实都是类似的。
WaitGroup
WaitGroup和锁有所差别,它类似Linux中的信号量,可以实现一组goroutine操作的等待。使用的时候,如果设置了错误的任务数,也可能会导致阻塞,导致泄露发生。
一个例子,我们在开发一个后端接口时需要访问多个数据表,由于数据间没有依赖关系,我们可以并发访问,示例如下:
packagemain import( "fmt" "runtime" "sync" "time" ) funchandle(){ varwgsync.WaitGroup wg.Add(4) gofunc(){ fmt.Println("访问表1") wg.Done() }() gofunc(){ fmt.Println("访问表2") wg.Done() }() gofunc(){ fmt.Println("访问表3") wg.Done() }() wg.Wait() } funcmain(){ deferfunc(){ time.Sleep(time.Second) fmt.Println("thenumberofgoroutines:",runtime.NumGoroutine()) }() gohandle() time.Sleep(time.Second) }
执行结果如下:
thenumberofgoroutines:2
出现了泄露。再看代码,它的开始部分定义了类型为sync.WaitGroup的变量wg,设置并发任务数为4,但是从例子中可以看出只有3个并发任务。故最后的wg.Wait()等待退出条件将永远无法满足,handle将会一直阻塞。
怎么防止这类情况发生?
我个人的建议是,尽量不要一次设置全部任务数,即使数量非常明确的情况。因为在开始多个并发任务之间或许也可能出现被阻断的情况发生。最好是尽量在任务启动时通过wg.Add(1)的方式增加。
示例如下:
... wg.Add(1) gofunc(){ fmt.Println("访问表1") wg.Done() }() wg.Add(1) gofunc(){ fmt.Println("访问表2") wg.Done() }() wg.Add(1) gofunc(){ fmt.Println("访问表3") wg.Done() }() ...
总结
大概介绍完了我认为的所有可能导致goroutine泄露的情况。总结下来,其实无论是死循环、channel阻塞、锁等待,只要是会造成阻塞的写法都可能产生泄露。因而,如何防止goroutine泄露就变成了如何防止发生阻塞。为进一步防止泄露,有些实现中会加入超时处理,主动释放处理时间太长的goroutine。
以上所述是小编给大家介绍的Go防止goroutine泄露的方法,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对毛票票网站的支持!
如果你觉得本文对你有帮助,欢迎转载,烦请注明出处,谢谢!