从Bug中学习:六个开源项目告诉你Go并发编程的陷阱并发编程模型会出现与以往不同的Bug。从bug中学习,《Understanding Real-World Concurrency Bugs in Go》本文在分析了六大开源项目中与并发相关的bug后,为我们总结了go并发编程中常见的陷阱。不跳坑,编程更美。在go中,创建一个goroutine非常简单。在函数调用前加上go关键字,函数调用将在单独的goroutine中执行;go支持匿名函数,这使得创建goroutine的操作更加简洁。另外,在并发编程模型中,Go不仅支持传统的共享内存通信方式,还提倡通过通道传递消息:不要通过共享内存进行通信;相反,通过通信共享内存。这种新的并发编程模型会带来新类型的bug,从bug中吸取教训,《Understanding Real-World Concurrency Bugs in Go》本文搜索“race”,“deadlock”,“Synchronization”,“concurrency”,“lock”,“mutex”,“atomic””、“compete”、“context”、“once”、“goroutineleak”等关键词,找出这六个项目中与并发相关的bug,然后对这些bug进行归类,总结出go并发编程中一些常见的陷阱。通过学习这些陷阱,我们可以在以后的项目中避免类似的错误,或者在遇到类似问题时帮助指导快速定位和排查问题。unbufferedchannel由于接收方的退出导致发送方的阻塞就像下面的错误示例:funcfinishReq(timeouttime.Duration)ob{ch:=make(chanob)gofunc(){result:=fn()ch<-result//block}()select{caseresult=<-ch:returnresultcase<-time.After(timeout):returnnil}}意在在调用fn()时添加一个超时函数。如果fn()在超时时间内没有返回,则返回nil。但是当超时的时候,对于代码第二行创建的ch,由于没有receiver,所以第五行会阻塞,导致这个goroutine永远不会退出。如果容量为零或不存在,则通道是无缓冲的,只有当发送方和接收方都准备就绪时,通信才会成功。否则,如果缓冲区未满(发送)或不为空(接收),通道将被缓冲并且通信成功而不会阻塞。修复这个bug的方法也很简单,把unbufferedchannel改成bufferedchannel。funcfinishReq(timeouttime.Duration)ob{ch:=make(chanob,1)gofunc(){result:=fn()ch<-result//block}()select{caseresult=<-ch:returnresultcase<-time.After(timeout):returnnil}}思考:在上面的例子中,虽然这样不会阻塞,但是channel还没有关闭,channel不关闭会不会造成资源泄露呢?WaitGroup误用导致阻塞以下是WaitGroup误用导致阻塞的错误示例:https://github.com/moby/moby/pull/25384vargroupsync.WaitGroupgroup.Add(len(pm.plugins))for_,p:=rangepm.plugins{gofunc(p*plugin){defergroup.Done()}(p)group.Wait()}当len(pm.plugins)大于等于2时,第7行会卡住,因为此时只启动一个异步goroutine,group.Done()只会被调用一次,group.Wait()会永远阻塞。修复如下:vargroupsync.WaitGroupgroup.Add(len(pm.plugins))for_,p:=rangepm.plugins{gofunc(p*plugin){defergroup.Done()}(p)}group.Wait()contextmisuse资源泄漏如下代码所示:hctx,hcancel:=context.WithCancel(ctx)iftimeout>0{hctx,hcancel=context.WithTimeout(ctx,timeout)}第一行context.WithCancel(ctx)可能是创建一个goroutine等待ctxDone,如果parent的ctx.Done(),取消child的context。也就是说,hcancel是绑定了一定的资源,不能直接覆盖。取消此上下文会释放与其关联的资源,因此代码应在此上下文中运行的操作完成后立即调用取消。修复这个bug的方法是:varhctxcontext.Contextvarhcancelcontext.CancelFunciftimeout>0{hctx,hcancel=context.WithTimeout(ctx,timeout)}else{hctx,hcancel=context.WithCancel(ctx)}或者hctx,hcancel:=context。WithCancel(ctx)iftimeout>0{hcancel.Cancel()hctx,hcancel=context.WithTimeout(ctx,timeout)}多个goroutine同时读写共享变量导致的bug如下:fori:=17;i<=21;i++{//writegofunc(){/*Createanewgoroutine*/apiVersion:=fmt.Sprintf("v1.%d",i)//read}()}第二行匿名函数形成闭包(closure),而定义在外面的变量在闭包内部是可以访问的,如上例,第一行在写变量i,第三行读变量i,这里的关键问题是读和同一个变量的写入在两个goroutine中同时进行,因此是不安全的。函数字面量是封闭的:它们可以引用周围函数中定义的变量。然后这些变量在周围函数和函数文字之间共享,只要它们可访问,它们就会存在。可以修改为:fori:=17;i<=21;i++{//writegofunc(iint){/*Createanewgoroutine*/apiVersion:=fmt.Sprintf("v1.%d",i)//read}(i)}通过值传递避免了并发读写的问题。通道被多次关闭导致的bughttps://github.com/moby/moby/pull/24007/fileselect{case<-c.closed:default:close(c.closed)}上面的代码可能是多个goroutine同时执行。这段代码的逻辑是case分支判断关闭的通道是否关闭。如果关闭,则什么也不做;如果closed不是closed,则执行默认分支关闭通道。当两个goroutine并发执行时,可能会导致关闭的channel被多次关闭。对于一个通道c,内置函数close(c)记录了通道上不会再发送任何值了。如果c是一个只接收通道,这是一个错误。发送或关闭一个关闭的通道会导致运行时恐慌。修复这个bug的方法是:Once.Do(func(){close(c.closed)})把整个select语句块换成Once.Do保证通道只关闭一次。定时器误用导致的bug如下:timer:=time.NewTimer(0)ifdur>0{timer=time.NewTimer(dur)}select{case<-timer.C:case<-ctx.Done()的:returnnil}的初衷是在dur大于0时设置定时器超时,但是timer:=time.NewTimer(0)导致timer.C立即触发。修复后:vartimeout<-chantime.Timeifdur>0{timeout=time.NewTimer(dur).C}select{case<-timeout:case<-ctx.Done():returnnil}nil通道永远无法进行通信。上面代码中,第一种情况分支超时可能是nil通道,在nil通道上select,不会触发这个分支,所以不会有问题。错误使用读写锁导致的buggo语言中的RWMutex,写锁具有更高的优先级:如果一个goroutine持有一个RWMutex用于读,而另一个goroutine可能会调用Lock,那么任何一个goroutine都应该期望能够获得读锁,直到释放初始读锁。特别是,这禁止递归读取锁定。这是为了确保锁最终可用;阻塞的Lock调用会阻止新读者获取锁。如果一个goroutine拿到readlock,那么另一个Agoroutine调用Lock,第一个goroutine再次调用readlock时会死锁,应该避免。
