Uber工程師對(duì)真實(shí)世界并發(fā)問(wèn)題的研究
今天Uber工程師放出一篇論文(A Study of Real-World Data Races in Golang]( https://arxiv.org/abs/2204.00764)),作者是Uber的工程師Milind Chabbi和Murali Krishna Ramanathan,他們負(fù)責(zé)使用Go內(nèi)建的data race detector在Uber內(nèi)的落地,經(jīng)過(guò)6個(gè)多月的研究分析,他們將data race detector成功落地,并基于對(duì)多個(gè)項(xiàng)目的分析,得出了一些有趣的結(jié)論。
我們知道,Go是Uber公司的主打編程語(yǔ)言。他們對(duì)Uber的2100個(gè)不同的微服務(wù),4600萬(wàn)行Go代碼的分析,發(fā)現(xiàn)了超過(guò)2000個(gè)的有數(shù)據(jù)競(jìng)爭(zhēng)的bug, 修復(fù)了其中的1000多個(gè),剩余的正在分析修復(fù)中。
談起真實(shí)世界中的Go并發(fā)Bug,其實(shí)2019年我們?nèi)A人學(xué)者的 Understanding Real-World Concurrency Bugs in Go 論文可以說(shuō)是開(kāi)山之作,首次全面系統(tǒng)地分析了幾個(gè)流行的大型Go項(xiàng)目的并發(fā)bug。今天談的這一篇呢,是Uber工程師針對(duì)Uber的眾多的Go代碼做的分析。我猜他們可能是類似國(guó)內(nèi)工程效能部的同學(xué),所以這篇論文有一半的篇幅介紹Go data race detector是怎么落地的,這個(gè)我們就不詳細(xì)講了,這篇論文的另一半是基于對(duì)data race的分析,羅列出了常見(jiàn)的出現(xiàn)data race的場(chǎng)景,對(duì)我們Gopher同學(xué)來(lái)說(shuō),很有學(xué)習(xí)的意義,所以我晚上好好拜讀了一下這篇論文,做一總結(jié)和摘要。
作為一個(gè)大廠,肯定不止一種開(kāi)發(fā)語(yǔ)言,作者對(duì)Uber線上個(gè)編程語(yǔ)言(go、java、nodejs、python)進(jìn)行分析,可以看到:
- 相比較Java, 在Go語(yǔ)言中會(huì)更多的使用并發(fā)處理
- 同一個(gè)進(jìn)程中,nodejs平均會(huì)啟動(dòng)16個(gè)線程,python會(huì)啟動(dòng)16-32個(gè)線程,java進(jìn)程一般啟動(dòng)128-1024個(gè)線程,10%的Java程序啟動(dòng)4096個(gè)線程,7%的java程序啟動(dòng)8192個(gè)線程。Go程序一般啟動(dòng)1024-4096個(gè)goroutine,6%的Go程序啟動(dòng)8192個(gè)goroutine(原文是8102,我認(rèn)為是一個(gè)筆誤),最大13萬(wàn)個(gè)。
可以看到Go程序會(huì)比其它語(yǔ)言有更多的并發(fā)單元,更多的并發(fā)單元意味著存在著更多的并發(fā)bug。Uber代碼庫(kù)中都有哪些類的并發(fā)bug呢?
下面的介紹會(huì)很多的使用數(shù)據(jù)競(jìng)爭(zhēng)概念(data race),它是并發(fā)編程中常見(jiàn)的概念,有數(shù)據(jù)競(jìng)爭(zhēng),意味著有多個(gè)并發(fā)單元對(duì)同一個(gè)數(shù)據(jù)資源有并發(fā)的讀寫(xiě),至少有一個(gè)寫(xiě),有可能會(huì)導(dǎo)致并發(fā)問(wèn)題。
透明地引用捕獲 (Transparent Capture-by-Reference)
直接翻譯過(guò)來(lái)你可能覺(jué)得不知所云。Transparent是指沒(méi)有顯示的聲明或者定義,就直接引用某些變量,很容易導(dǎo)致數(shù)據(jù)競(jìng)爭(zhēng)。通過(guò)例子更容易理解。這是一大類,我們分成小類逐一介紹。
循環(huán)變量的捕獲
不得不說(shuō),這也是我最常犯的錯(cuò)誤。雖然明明知道會(huì)有這樣的問(wèn)題,但是在開(kāi)發(fā)的過(guò)程中,總是無(wú)意的犯這樣的錯(cuò)誤。
for _ , job := range jobs {
go func () {
ProcessJob ( job )
}()
} // end for
比如這個(gè)簡(jiǎn)單的例子,job是索引變量,循環(huán)中啟動(dòng)了一個(gè)goroutine處理這個(gè)job。job變量就透明地被這個(gè)goroutine引用。
循環(huán)變量是唯一的,意味著啟動(dòng)的這個(gè)goroutine,有可能處理的都是同一個(gè)job,而并不是期望的沒(méi)有一個(gè)job。
這個(gè)例子還很明顯,有時(shí)候循環(huán)體內(nèi)特別復(fù)雜,可能并不像這個(gè)例子那么容易發(fā)現(xiàn)。
err變量被捕獲
Go允許返回值賦值給多個(gè)變量,通常其中一個(gè)變量是error。 x, err := m, n 意味著聲明和定義left hand side(LHS)變量,如果變量還沒(méi)有聲明過(guò)的話,那就是定義了一個(gè)新的變量,但是如果變量已聲明過(guò)得話,那就是對(duì)已有變量的重新賦值。
下面這個(gè)例子,y,z的賦值時(shí),會(huì)對(duì)同一個(gè)err進(jìn)行寫(xiě)操作,也可能會(huì)導(dǎo)致數(shù)據(jù)競(jìng)爭(zhēng),產(chǎn)生并發(fā)問(wèn)題。
x , err := Foo ()
if err != nil {
}
go func () {
y , err := Bar ()
if err != nil {
}
}()
z , err := Baz ()
if err != nil {
}
捕獲命名的返回值
下面這個(gè)例子定義了一個(gè)命名的返回值 result ??梢钥吹?span> ... = result (讀操作)和 return 20 (寫(xiě)操作)有數(shù)據(jù)競(jìng)爭(zhēng)的問(wèn)題,雖然 return 20 你并沒(méi)有看到對(duì)result的賦值。
func NamedReturnCallee () ( result int) {
result =10
if {
return // this has the effect of " return 10"
}
go func () {
= result // read result
}()
return20 // this is equivalent to result =20
}
func Caller () {
retVal := NamedReturnCallee ()
}
defer 也會(huì)有類似的效果,下面這段代碼對(duì)err有數(shù)據(jù)競(jìng)爭(zhēng)問(wèn)題。
func Redeem ( request Entity ) ( resp Response , err error )
{
defer func () {
resp , err = c . Foo ( request , err )
}()
err = CheckRequest ( request )
// err check but no return
go func () {
ProcessRequest ( request , err != nil )
}()
return // the defer function runs after here
}
Slice相關(guān)的數(shù)據(jù)競(jìng)爭(zhēng)
下面這個(gè)例子, safeAppend 使用鎖對(duì) myResults 進(jìn)行了保護(hù),但是在每次循環(huán)調(diào)用 (uuid, myResults) 并沒(méi)有讀保護(hù),也會(huì)有競(jìng)爭(zhēng)問(wèn)題,而且不容易發(fā)現(xiàn)。
func ProcessAll ( uuids [] string ) {
var myResults [] string
var mutex sync . Mutex
safeAppend := func ( res string ) {
mutex.Lock ()
myResults = append ( myResults , res )
mutex.Unlock ()
}
for _ , uuid := range uuids {
go func ( id string , results [] string ) {
res := Foo ( id )
safeAppend ( res )
}( uuid , myResults ) // slice read without holding lock
}
}
非線程安全的map
這個(gè)很常見(jiàn)了,幾乎每個(gè)Gopher都曾犯過(guò),犯過(guò)才意識(shí)到Go內(nèi)建的map對(duì)象并不是線程安全的,需要加鎖或者使用sync.Map等其它并發(fā)原語(yǔ)。
func processOrders ( uuids [] string ) error {
var errMap = make ( map [ string ] error )
for _ , uuid := range uuids {
go func ( uuid string ) {
orderHandle , err := GetOrder ( uuid )
if err != nil {
? errMap [ uuid ] = err
return
}
}( uuid )
return combineErrors ( errMap )
}
傳值和傳引用的誤用
Go標(biāo)準(zhǔn)庫(kù)常見(jiàn)并發(fā)原語(yǔ)不允許在使用后Copy, go vet也能檢查出來(lái)。比如下面的代碼,兩個(gè)goroutine想共享mutex,需要傳遞 &mutex ,而不是 mutex 。
var a int
// CriticalSection receives a copy of mutex .
func CriticalSection ( m sync . Mutex ) {
m.Lock ()
a ++
m.Unlock ()
}
func main () {
mutex := sync . Mutex {}
// passes a copy of m to A .
go CriticalSection ( mutex )
go CriticalSection ( mutex )
}
混用消息傳遞和共享內(nèi)存兩種并發(fā)方式
消息傳遞常用channel。下面的例子中,如果context因?yàn)槌瑫r(shí)或者主動(dòng)cancel被取消的話,Start中的goroutine中的 f.ch <- 1 可能會(huì)被永遠(yuǎn)阻塞,導(dǎo)致goroutine泄露。
func ( f * Future ) Start () {
go func () {
resp , err := f.f () // invoke a registered function
f.response = resp
f.err = err
f.ch <-1 // may block forever !
}()
}
func ( f * Future ) Wait ( ctx context . Context ) error {
select {
case <-f.ch :
return nil
case <- ctx.Done () :
f.err = ErrCancelled
return ErrCancelled
}
并發(fā)測(cè)試
Go的 testing.T.Parallel() 為單元測(cè)試提供了并發(fā)能力,或者開(kāi)發(fā)者自己寫(xiě)一些并發(fā)的測(cè)試程序測(cè)試代碼邏輯,在這些并發(fā)測(cè)試中,也是有可能導(dǎo)致數(shù)據(jù)競(jìng)爭(zhēng)的。不要以為測(cè)試不會(huì)有數(shù)據(jù)競(jìng)爭(zhēng)問(wèn)題。
不正確的鎖調(diào)用
為寫(xiě)操作申請(qǐng)讀鎖
下面這個(gè)例子中, g.ready 是寫(xiě)操作,可是這個(gè)函數(shù)調(diào)用的是讀鎖。
func ( g * HealthGate ) updateGate () {
g.mutex.RLock ()
defer g.mutex.RUnlock ()
// ... several read - only operations ...
if {
g.ready = true // Concurrent writes .
g.gate.Accept () // More than one Accept () .
}
其它鎖的問(wèn)題
你會(huì)發(fā)現(xiàn),大家經(jīng)常犯的一個(gè)“弱智”的問(wèn)題,就是Mutex只有Lock或者只有Unlock,或者兩個(gè)Lock,這類問(wèn)題本來(lái)你認(rèn)為絕不會(huì)出現(xiàn)的,在現(xiàn)實(shí)中卻經(jīng)常能看到。
還有使用 atomic 進(jìn)行原子寫(xiě),但是卻沒(méi)有原子讀。
我認(rèn)為這里Uber工程師并沒(méi)有全面詳細(xì)的介紹使用鎖常見(jiàn)的一些陷阱,推薦你學(xué)習(xí)極客時(shí)間中的 Go 并發(fā)編程實(shí)戰(zhàn)課 課程,此課程詳細(xì)介紹了每個(gè)并發(fā)原語(yǔ)的陷阱和死鎖情況。
總結(jié)
總結(jié)一下,下表列出了基于語(yǔ)言類型統(tǒng)計(jì)的數(shù)據(jù)競(jìng)爭(zhēng)bug數(shù):
整體來(lái)看,鎖的誤用是最大的數(shù)據(jù)競(jìng)爭(zhēng)的原因。并發(fā)訪問(wèn)slice和map也是很常見(jiàn)的數(shù)據(jù)競(jìng)爭(zhēng)的原因。