今天Uber工程师发布了一篇论文(AStudyofReal-WorldDataRacesinGolang](https://arxiv.org/abs/2204.00764)),作者是Uber工程师MilindChabbi和MuraliKrishnaRamanathan负责在优步中使用Go构建的数据竞争检测器的实现。经过6个多月的研究和分析,他们成功实现了数据竞争检测器。基于对多个项目的分析,他们得出了一些有趣的结论。我们知道Go是Uber的主要编程语言。他们分析了Uber的2100种不同的微服务和4600万行Go代码,发现了2000多个数据竞争错误,修复了1000多个,其余正在分析和修复。说到现实世界中的Go并发bug,其实我们中国学者在2019年的论文《UnderstandingReal-WorldConcurrencyBugsinGo》可以说是开山之作。第一次全面系统地分析几个流行的大型Go项目的并发bug。今天这篇文章是优步工程师对优步众多Go代码的分析。我猜他们可能是国内工程效率系的学生,所以这篇论文的一半介绍了Godataracedetector是如何实现的。我们不会详细介绍这一点。这篇论文的另一半是基于数据竞争,论文的分析列出了数据竞争发生的常见场景。对于我们Gopher同学来说,学习是非常有意义的,所以晚上仔细看了这篇论文,做了总结和提要。作为大厂,开发语言肯定不止一种。笔者分析了Uber的在线编程语言(go、java、nodejs、python),可以看出与Java相比,Go语言会使用更多的并发。同一个进程,nodejs平均启动16个线程,python启动16-32个线程,java进程一般启动128-1024个线程,10%的Java程序启动4096个线程,7%的java程序启动8192个线程。Go程序一般启动1024-4096个goroutines,6%的Go程序启动8192个goroutines(原文是8102,我觉得打错了),最多130000个。可以看出Go程序的并发单元比其他语言多,并发单元多就意味着并发bug多。Uber代码库中存在哪些类型的并发错误?下面的介绍会大量用到datarace的概念,这是并发编程中比较常见的一个概念。数据竞争是指有多个并发单元对同一个数据资源进行并发读写,并且至少有一个写,这可能会导致并发问题。TransparentCapture-by-Reference(透明引用捕获)直接翻译给你,你可能会觉得看不懂。透明是指某些变量没有显式声明或定义就直接引用,容易导致数据竞争。结合例子更容易理解。这是一个大类,我们将在小类中一一介绍。循环变量的捕获不得不说,这也是我最常犯的错误。虽然我们明明知道会出现这样的问题,但是在开发过程中总是无意中犯这样的错误。for_,job:=rangejobs{gofunc(){ProcessJob(job)}()}//endfor比如这个简单的例子,job是一个索引变量,在循环中启动一个goroutine来处理工作。作业变量由goroutine透明地引用。循环变量是唯一的,这意味着启动的goroutine可能会处理相同的作业,而不是期望没有作业。这个例子也很明显,有时候循环体特别复杂,可能不像这个例子那么好找。err变量被捕获Go允许返回值赋值给多个变量,通常其中一个是error。x,err:=m,n表示声明和定义左手边(LHS)变量,如果变量没有被声明,就是定义一个新的变量,但是如果变量已经被声明了,就是对右重新赋值现有变量。在下面的例子中,y和z赋值时,会写同样的err,也可能会造成数据竞争和并发问题。x,err:=Foo()iferr!=nil{...}gofunc(){y,err:=Bar()iferr!=nil{...}}()z,err:=Baz()iferr!=nil{...}捕获命名返回值下面的例子定义了一个命名返回值result。可以看出...=result(读操作)和return20(写操作)存在数据竞争问题,虽然return20你看不到result的赋值。funcNamedReturnCallee()(resultint){result=10if...{return//这具有“return10”的效果}gofunc(){...=result//读取结果}()return20//这相当于result=20}funcCaller(){retVal:=NamedReturnCallee()}defer也会有类似的效果,下面的代码对于err有数据竞争问题。funcRedeem(requestEntity)(respResponse,errerror){deferfunc(){resp,err=c.Foo(request,err)}()err=CheckRequest(request)...//错误检查但没有返回gofunc(){ProcessRequest(request,err!=nil)}()return//defer函数在这里}Slice相关的数据竞争下面的例子中,safeAppend使用了锁来保护myResults,但是在每次循环中调用(uuid,myResults)是没有读保护的,会出现竞争条件,不容易发现。funcProcessAll(uuids[]string){varmyResults[]stringvar互斥同步。MutexsafeAppend:=func(resstring){mutex.Lock()myResults=append(myResults,res)mutex.Unlock()}for_,uuid:=rangeuuids{gofunc(idstring,results[]string){res:=Foo(id)safeAppend(res)}(uuid,myResults)//slicereadwithoutholdinglock}...}非线程安全map很常见,几乎每个Gopher都在意识到构建之前提交了它-Go中的map对象不是线程安全的,需要加锁或者使用sync.Map等其他并发原语。funcprocessOrders(uuids[]string)error{varerrMap=make(map[string]error)for_,uuid:=rangeuuids{gofunc(uuidstring){orderHandle,err:=GetOrder(uuid)iferr!=nil{?errMap[uuid]=errreturn}...}(uuid)returncombineErrors(errMap)}值传递和引用传递的滥用Go标准库中常见的并发原语不允许使用后复制,去兽医也可以检查出来。例如,在下面的代码中,如果两个goroutine想要共享一个mutex,则需要传递&mutex而不是mutex。varaint//CriticalSection接收互斥量的副本.funcCriticalSection(msync.Mutex){m.Lock()a++m.Unlock()}funcmain(){mutex:=sync.Mutex{}//passingacopyofmtoA.goCriticalSection(mutex)goCriticalSection(mutex)}混合消息传递和共享内存两种并发消息传递方式常用的通道。在下面的例子中,如果上下文因超时或主动取消而被取消,Start中goroutine中的f.ch<-1可能会永远阻塞,导致goroutine泄漏。func(f*Future)Start(){gofunc(){resp,err:=f.f()//调用注册函数f.response=respf.err=errf.ch<-1//可能永远阻塞!}()}func(f*Future)Wait(ctxcontext.Context)error{select{case<-f.ch:returnnilcase<-ctx.Done():f.err=ErrCancelledreturnErrCancelled}并发测试Gotesting.T.Parallel()为单元测试提供并发能力,或者开发者可以编写一些并发测试程序来测试代码逻辑。在这些并发测试中,也可能会出现数据竞争。不要假设测试不会有数据竞争问题。写操作申请读锁错误的锁调用下面的例子中,g.ready是写操作,但是这个函数调用了读锁。func(g*HealthGate)updateGate(){g.mutex.RLock()deferg.mutex.RUnlock()//...几个只读操作...if...{g.ready=true//并发写入。g.gate.Accept()//不止一个Accept().}其他锁问题大家会发现大家经常犯的一个“弱智”问题就是Mutex只有Lock或者只有Unlock,或者两个Lock,这种您认为永远不会出现的问题,但实际上您经常会看到它。也有使用atomic进行原子写入,但没有原子读取。我认为Uber工程师并没有对一些使用锁的常见陷阱进行全面详细的介绍。推荐大家学习极客时间的Go并发编程实战教程。本课程详细介绍了每个并发原语的陷阱和死锁。总结一下,下表根据语言类型统计列出了数据竞争错误的数量:总体而言,锁的误用是数据竞争的最大原因。对切??片和映射的并发访问也是数据竞争的一个非常常见的原因。
