1。前言在使用channel进行goroutines之间通信的时候,有时候场景会变得很复杂,以至于你偶尔会写出一些难以检测和定位的bug,往往上线后就跑的很好。直到有一天深夜,收到了服务挂起、OOM等告警……本文将梳理一下使用通道时常见的三个陷阱:panic、死锁、内存泄露,做到防患于未然。2.deadlock初学go语言的人在编译的时候很容易遇到这种死锁问题:fatalerror:allgoroutinesareasleep-deadlock!这就是流行的“死锁”……在操作系统中,我们了解到,“死锁”就是两个线程互相等待,在那里度过,最后程序不得不终止。Go语言中的“死锁”类似。两个goroutine互相等待,导致程序耗尽,无法继续运行。看了很多死锁案例,channel引起的死锁可以归纳为以下几种情况(先讨论unbufferedchannel的情况):2.1只有生产者没有消费者,或者反过来,channel的生产者和消费者must成对出现,少了一个会造成死锁,例如://只有生产者,没有消费者funcf1(){ch:=make(chanint)ch<-1}或者://只有消费者或者,noproducerfuncf2(){ch:=make(chanint)<-ch}2.2生产者和消费者出现在同一个goroutine中,除了成对出现,还需要出现在不同的goroutine中,例如://生产者和消费者都出现在同一个goroutine中funcf3(){ch:=make(chanint)ch<-1//由于消费者还没有被执行,所以会一直阻塞在这里<-ch}对于bufferedchannel:2.3bufferedchannel满了,bufferedchannel会把接收到的元素存放在hchanstr的ringbuffer中先构造,再构造。而当发生阻塞时,如果主goroutine被阻塞,也会发生死锁。所以在实际使用中,建议尽量使用bufferedchannel,这样使用起来会更安全,在下面的“内存泄漏”相关内容中会提到。3.内存泄漏内存泄漏一般是通过OOM(OutofMemory)告警或发布过程中的内存观察发现的。服务内存往往会缓慢增加,直到内存被系统OOM清除,然后再重复。在go语言中,通道的不正确使用会导致goroutine泄漏,进而导致内存泄漏。3.1如何实现goroutineleak?不能修复bug,我就不能写bug吗?让goroutineleak的核心是:producer/consumer所在的goroutine已经退出,consumer/producer所在的对应goroutine会一直阻塞,直到进程退出。3.2Producer阻塞导致泄漏我们一般使用channels来做一些超时控制,比如下面这个例子:funcleak1(){ch:=make(chanint)//g1gofunc(){time.Sleep(2*time.Second)//模拟io操作ch<-100//模拟返回结果}()//g2//阻塞直到超时或返回select{case<-time.After(500*time.Millisecond):fmt.println("timeout!exit...")caseresult:=<-ch:fmt.Printf("result:%d\n",result)}}这里我们使用goroutineg1模拟io操作,maingoroutineg2模拟customer结束处理逻辑。假设客户端超时500ms,实际请求时间2s,select会走到超时逻辑,此时g2退出,channelch没有消费者,会处于等待状态,输出如下:Goroutinenum:1timeout!exit...Goroutinenum:2如果这是在服务端代码中,请求处理完成后,g1会挂起泄漏,等待OOM=。=。假设客户端超时调整为5000ms,实际请求耗时2s,则select会进入分支获取结果,输出如下:Goroutinenum:1timeout!exit...goroutinenum:23.3消费者阻塞导致泄漏如果生产者不继续生产,消费者所在的goroutine也会被阻塞,不会退出,例如:funcleak2(){ch:=make(chanint)//消费者g1gofunc(){forresult:=rangech{fmt.Printf("result:%d\n",result)}}()//生产者g2ch<-1ch<-2time.Sleep(time.Second)//模拟耗时fmt.Println("maingoroutineg2done...")}这种情况只需要加上close(ch)操作即可。for-range操作在收到close信号后会退出,goroutine不会再阻塞,可以回收。3.4如何防止内存泄漏?防止goroutine泄漏的核心是:在创建goroutine的时候,需要知道什么时候会被回收。具体到执行层面,包括:当一个goroutine退出时,需要考虑它使用的channel是否有可能阻塞对应的producer和consumergoroutine。尽量使用bufferedchannel使用bufferedchannel可以减少阻塞的发生,即使忽略一些极端情况,也可以降低goroutine泄露的概率。4.恐慌恐慌更让人恼火。一般测试时不会发现,但上线后偶尔会出现,程序挂了,服务出现超时故障后触发告警。通道引起的panic一般有以下几种原因:4.1继续向关闭的通道发送数据举个简单的例子:funcp1(){ch:=make(chanint,1)close(ch)ch<-1}//panic:sendonclosedchannel实际开发过程中,在处理多个goroutine之间的协作时,可能有一个goroutine已经关闭了channel,另一个不知道。如果你关闭它,它会恐慌。例如:funcp1(){ch:=make(chanint,1)done:=make(chanstruct{},1)gofunc(){<-time.After(2*time.Second)println("close2")close(ch)close(done)}()gofunc(){<-time.After(1*time.Second)println("close1")ch<-1close(ch)}()<-done}万恶之源在于,在go语言中,你无法知道一个channel是否已经被关闭,所以在尝试做一个close操作时,你要做好恐慌的准备……4.2多次关闭同一个channel上面,在尝试向通道发送数据时应该考虑。这个频道关闭了吗?该通道何时以及在哪个goroutine中关闭?谁来关闭它?或者干脆把它关掉?5.如何优雅的关闭channel5.1是否需要检查channel是否关闭?第一次遇到上面说的panic问题的时候,我也试着找了一个内置的关闭功能来查看关机状态,结果发现没有这个功能。。。那么,如果有这个功能,真的可以吗?彻底解决恐慌问题??答案是不。因为通道是在并发环境下进行发送和接收操作,即使执行closed(ch)的结果为false,也不能直接关闭。例如,来自yy的以下代码:if!closed(ch){//returnfalse//中间有一只飞蛾!close(ch)//stillpanic...}遵循少即是多的原则,这个关闭的函数是认真的。5.2是否需要关闭?为什么?结论:除非必须,否则不要关闭chan。关闭chan最优雅的方式就是不关闭chan~。当一个chan没有sender和receiver,也就是不再被使用时,GC会在一段时间后标记清理这个chan。那么什么时候必须关闭chan呢?更常见的是使用close作为通知机制,尤其是当生产者和消费者的关系是1:M时,通过close告诉下游:我完了,停止阅读。5.3谁来检查?chan关闭的原则:不要从receiver端关闭一个channel不要在consumer端关闭chan。如果通道有多个并发发送者,请不要关闭通道当有多个同时写入的生产者时,请不要关闭。只要遵循这两个原则,就可以避免两种panic场景,即:发送数据到一个关闭的chan,或者关闭一个关闭的chan。根据生产者和消费者的关系,可以拆解为以下几类:一写一读:生产者可以关闭。Write-once-read-many:生产者可以关闭,关闭时所有下游消费者都能收到通知。多写多读:需要在多个生产者之间引入协调通道来处理信号。多写多读:和3类似,核心思想是引入一个中间层,使用try-send例程来处理非阻塞写,例如:funcmain(){rand.Seed(time.Now().UnixNano())log.SetFlags(0)constMax=100000constNumReceivers=10constNumSenders=1000wgReceivers:=sync.WaitGroup{}wgReceivers.Add(NumReceivers)dataCh:=make(chanint)stopCh:=make(chanstruct{})//stopCh是引入的额外信号通道。//它的生产者就是下面的toStop通道,//消费者就是上面dataCh的生产者和消费者toStop:=make(chanstring,1)//toStop是用来关闭stopCh的,由dataCh的生产者和消费者写的//由以下匿名中介函数(主持人)使用//注意这必须是一个缓冲通道(否则它不能用于尝试发送处理)varstoppedBystring//moderatorgofunc(){stoppedBy=<-toStopclose(stopCh)}()//i的发送者:=0;我
