Uber工程師對真實世界并發(fā)問題的研究
今天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)過6個多月的研究分析,他們將data race detector成功落地,并基于對多個項目的分析,得出了一些有趣的結(jié)論。
我們知道,Go是Uber公司的主打編程語言。他們對Uber的2100個不同的微服務(wù),4600萬行Go代碼的分析,發(fā)現(xiàn)了超過2000個的有數(shù)據(jù)競爭的bug, 修復(fù)了其中的1000多個,剩余的正在分析修復(fù)中。
談起真實世界中的Go并發(fā)Bug,其實2019年我們?nèi)A人學(xué)者的 Understanding Real-World Concurrency Bugs in Go 論文可以說是開山之作,首次全面系統(tǒng)地分析了幾個流行的大型Go項目的并發(fā)bug。今天談的這一篇呢,是Uber工程師針對Uber的眾多的Go代碼做的分析。我猜他們可能是類似國內(nèi)工程效能部的同學(xué),所以這篇論文有一半的篇幅介紹Go data race detector是怎么落地的,這個我們就不詳細(xì)講了,這篇論文的另一半是基于對data race的分析,羅列出了常見的出現(xiàn)data race的場景,對我們Gopher同學(xué)來說,很有學(xué)習(xí)的意義,所以我晚上好好拜讀了一下這篇論文,做一總結(jié)和摘要。
作為一個大廠,肯定不止一種開發(fā)語言,作者對Uber線上個編程語言(go、java、nodejs、python)進(jìn)行分析,可以看到:
- 相比較Java, 在Go語言中會更多的使用并發(fā)處理
- 同一個進(jìn)程中,nodejs平均會啟動16個線程,python會啟動16-32個線程,java進(jìn)程一般啟動128-1024個線程,10%的Java程序啟動4096個線程,7%的java程序啟動8192個線程。Go程序一般啟動1024-4096個goroutine,6%的Go程序啟動8192個goroutine(原文是8102,我認(rèn)為是一個筆誤),最大13萬個。
可以看到Go程序會比其它語言有更多的并發(fā)單元,更多的并發(fā)單元意味著存在著更多的并發(fā)bug。Uber代碼庫中都有哪些類的并發(fā)bug呢?
下面的介紹會很多的使用數(shù)據(jù)競爭概念(data race),它是并發(fā)編程中常見的概念,有數(shù)據(jù)競爭,意味著有多個并發(fā)單元對同一個數(shù)據(jù)資源有并發(fā)的讀寫,至少有一個寫,有可能會導(dǎo)致并發(fā)問題。
透明地引用捕獲 (Transparent Capture-by-Reference)
直接翻譯過來你可能覺得不知所云。Transparent是指沒有顯示的聲明或者定義,就直接引用某些變量,很容易導(dǎo)致數(shù)據(jù)競爭。通過例子更容易理解。這是一大類,我們分成小類逐一介紹。
循環(huán)變量的捕獲
不得不說,這也是我最常犯的錯誤。雖然明明知道會有這樣的問題,但是在開發(fā)的過程中,總是無意的犯這樣的錯誤。
for _ , job := range jobs {
go func () {
ProcessJob ( job )
}()
} // end for
比如這個簡單的例子,job是索引變量,循環(huán)中啟動了一個goroutine處理這個job。job變量就透明地被這個goroutine引用。
循環(huán)變量是唯一的,意味著啟動的這個goroutine,有可能處理的都是同一個job,而并不是期望的沒有一個job。
這個例子還很明顯,有時候循環(huán)體內(nèi)特別復(fù)雜,可能并不像這個例子那么容易發(fā)現(xiàn)。
err變量被捕獲
Go允許返回值賦值給多個變量,通常其中一個變量是error。 x, err := m, n 意味著聲明和定義left hand side(LHS)變量,如果變量還沒有聲明過的話,那就是定義了一個新的變量,但是如果變量已聲明過得話,那就是對已有變量的重新賦值。
下面這個例子,y,z的賦值時,會對同一個err進(jìn)行寫操作,也可能會導(dǎo)致數(shù)據(jù)競爭,產(chǎn)生并發(fā)問題。
x , err := Foo ()
if err != nil {
}
go func () {
y , err := Bar ()
if err != nil {
}
}()
z , err := Baz ()
if err != nil {
}
捕獲命名的返回值
下面這個例子定義了一個命名的返回值 result ??梢钥吹?span> ... = result (讀操作)和 return 20 (寫操作)有數(shù)據(jù)競爭的問題,雖然 return 20 你并沒有看到對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 也會有類似的效果,下面這段代碼對err有數(shù)據(jù)競爭問題。
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ù)競爭
下面這個例子, safeAppend 使用鎖對 myResults 進(jìn)行了保護(hù),但是在每次循環(huán)調(diào)用 (uuid, myResults) 并沒有讀保護(hù),也會有競爭問題,而且不容易發(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
這個很常見了,幾乎每個Gopher都曾犯過,犯過才意識到Go內(nèi)建的map對象并不是線程安全的,需要加鎖或者使用sync.Map等其它并發(fā)原語。
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)庫常見并發(fā)原語不允許在使用后Copy, go vet也能檢查出來。比如下面的代碼,兩個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因為超時或者主動cancel被取消的話,Start中的goroutine中的 f.ch <- 1 可能會被永遠(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ā)測試
Go的 testing.T.Parallel() 為單元測試提供了并發(fā)能力,或者開發(fā)者自己寫一些并發(fā)的測試程序測試代碼邏輯,在這些并發(fā)測試中,也是有可能導(dǎo)致數(shù)據(jù)競爭的。不要以為測試不會有數(shù)據(jù)競爭問題。
不正確的鎖調(diào)用
為寫操作申請讀鎖
下面這個例子中, g.ready 是寫操作,可是這個函數(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 () .
}
其它鎖的問題
你會發(fā)現(xiàn),大家經(jīng)常犯的一個“弱智”的問題,就是Mutex只有Lock或者只有Unlock,或者兩個Lock,這類問題本來你認(rèn)為絕不會出現(xiàn)的,在現(xiàn)實中卻經(jīng)常能看到。
還有使用 atomic 進(jìn)行原子寫,但是卻沒有原子讀。
我認(rèn)為這里Uber工程師并沒有全面詳細(xì)的介紹使用鎖常見的一些陷阱,推薦你學(xué)習(xí)極客時間中的 Go 并發(fā)編程實戰(zhàn)課 課程,此課程詳細(xì)介紹了每個并發(fā)原語的陷阱和死鎖情況。
總結(jié)
總結(jié)一下,下表列出了基于語言類型統(tǒng)計的數(shù)據(jù)競爭bug數(shù):
整體來看,鎖的誤用是最大的數(shù)據(jù)競爭的原因。并發(fā)訪問slice和map也是很常見的數(shù)據(jù)競爭的原因。