Uber 工程師對真實世界併發問題的研究

最近 Uber 工程師放出一篇論文《A Study of Real-World Data Races in Golang[1]》,作者是 Uber 的工程師 Milind Chabbi 和 Murali Krishna Ramanathan,他們負責使用 Go 內建的 data race detector 在 Uber 內的落地,經過 6 個多月的研究分析,他們將 data race detector 成功落地,並基於對多個項目的分析,得出了一些有趣的結論。

我們知道,Go 是 Uber 公司的主打編程語言。他們對 Uber 的 2100 個不同的微服務,4600 萬行 Go 代碼的分析,發現了超過 2000 個有數據競爭的 bug,修復了其中的 1000 多個,剩餘的正在分析修復中。

談起真實世界中的 Go 併發 Bug,其實 2019 年我們華人學者的《Understanding Real-World Concurrency Bugs in Go[2]》論文可以說是開山之作,首次全面系統地分析了幾個流行的大型 Go 項目的併發 bug。

今天談的這一篇呢,是 Uber 工程師針對 Uber 的衆多的 Go 代碼做的分析。我猜他們可能是類似國內工程效能部的同學,所以這篇論文有一半的篇幅介紹 Go data race detector 是怎麼落地的,這個我們就不詳細講了,這篇論文的另一半是基於對 data race 的分析,羅列出了常見的出現 data race 的場景,對我們 Gopher 同學來說,很有學習的意義,所以我好好拜讀了一下這篇論文,做了總結和摘要。

作爲一個大廠,肯定不止一種開發語言,作者對 Uber 線上個編程語言(Go、Java、Nodejs、Python)進行分析,可以看到:

  1. 相比較 Java, 在 Go 語言中會更多的使用併發處理

  2. 同一個進程中,Nodejs 平均會啓動 16 個線程,Python 會啓動 16-32 個線程,Java 進程一般啓動 128-1024 個線程,10% 的 Java 程序啓動 4096 個線程,7% 的 Java 程序啓動 8192 個線程。Go 程序一般啓動 1024-4096 個 goroutine,6% 的 Go 程序啓動 8192 個 goroutine(原文是 8102,我認爲是一個筆誤),最大 13 萬個。

可以看到 Go 程序會比其它語言有更多的併發單元,更多的併發單元意味着存在着更多的併發 bug。Uber 代碼庫中都有哪些類的併發 bug 呢?

下面的介紹會使用數據競爭概念(data race),它是併發編程中常見的概念,有數據競爭,意味着有多個併發單元對同一個數據資源有併發的讀寫,至少有一個寫,有可能會導致併發問題。

1 透明地引用捕獲(Transparent Capture-by-Reference)

直接翻譯過來你可能覺得不知所云。Transparent 是指沒有顯示的聲明或者定義,就直接引用某些變量,很容易導致數據競爭。通過例子更容易理解。這是一大類,我們分成小類逐一介紹。

循環變量的捕獲

不得不說,這也是我最常犯的錯誤。雖然明明知道會有這樣的問題,但是在開發的過程中,總是無意的犯這樣的錯誤。

for _ , job := range jobs {
 go func () {
   ProcessJob ( job )
 }()
 } // end for

比如這個簡單的例子,job 是索引變量,循環中啓動了一個 goroutine 處理這個 job。job 變量就透明地被這個 goroutine 引用。

循環變量是唯一的,意味着啓動的這個 goroutine,有可能處理的都是同一個 job,而並不是期望的沒有一個 job。

這個例子還很明顯,有時候循環體內特別複雜,可能並不像這個例子那麼容易發現。

err 變量被捕獲

下面這個例子,y、z 的賦值時,會對同一個 err 進行寫操作,也可能會導致數據競爭,產生併發問題。

x , err := Foo ()
if err != nil {
...
}
go func () {
var y int
y , err = Bar ()
if err != nil {
...
}
}()
var z string
z , err = Baz ()
if err != nil {
...
}

捕獲命名的返回值

下面這個例子定義了一個命名的返回值 result。可以看到 ... = result(讀操作)和 return 20(寫操作)有數據競爭的問題,雖然 return 20 你並沒有看到對 result 的賦值。

func NamedReturnCallee () ( result int) {
  result = 10
  if ... {
    return // this has the effect of " return 10"
  }
  go func () {
   ... = result // read result
  }()
  return 20 // this is equivalent to result =20
}
func Caller () {
 retVal := NamedReturnCallee ()
}

defer 也會有類似的效果,下面這段代碼對 err 有數據競爭問題。

 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
 }

2 Slice 相關的數據競爭

下面這個例子,safeAppend 使用鎖對 myResults 進行了保護,但是在每次循環調用(uuid,myResults)並沒有讀保護,也會有競爭問題,而且不容易發現。

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
 }
 ...
 }

3 非線程安全的 map

這個很常見了,幾乎每個 Gopher 都曾犯過,犯過才意識到 Go 內建的 map 對象並不是線程安全的,需要加鎖或者使用 sync.Map 等其它併發原語。

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 )
}

4 傳值和傳引用的誤用

Go 標準庫常見併發原語不允許在使用後 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 )
}

5 混用消息傳遞和共享內存兩種併發方式

消息傳遞常用 channel。下面的例子中,如果 context 因爲超時或者主動 cancel 被取消的話,Start 中的 goroutine 中的 f.ch <- 1 可能會被永遠阻塞,導致 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
}

6 併發測試

Go 的 testing.T.Parallel() 爲單元測試提供了併發能力,或者開發者自己寫一些併發的測試程序測試代碼邏輯,在這些併發測試中,也是有可能導致數據競爭的。不要以爲測試不會有數據競爭問題。

7 不正確的鎖調用

爲寫操作申請讀鎖

下面這個例子中,g.ready 是寫操作,可是這個函數調用的是讀鎖。

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 () .
}

其它鎖的問題

你會發現,大家經常犯的一個 “弱智” 的問題,就是 Mutex 只有 Lock 或者只有 Unlock,或者兩個 Lock,這類問題本來你認爲絕不會出現的,在現實中卻經常能看到。

還有使用 atomic 進行原子寫,但是卻沒有原子讀。

8 總結

總結一下,下表列出了基於語言類型統計的數據競爭 bug 數:

整體來看,鎖的誤用是最大的數據競爭的原因。併發訪問 slice 和 map 也是很常見的數據競爭的原因。

相關鏈接:

  1. https://arxiv.org/abs/2204.00764

  2. https://songlh.github.io/paper/go-study.pdf

作者介紹

晁嶽攀,網名鳥窩,前微博技術專家,知名微服務框架 rpcx 的作者,先後在摩托羅拉、Comcast 擔任開發和管理工作,著有《Scala 集合技術手冊》一書,並在臺灣發行了繁體版。

本文源自公衆號專家極客圈,分佈式實驗室已獲完整授權。

分佈式實驗室 關注分佈式相關的開源項目和基礎架構,致力於分析並報道這些新技術是如何以及將會怎樣影響企業的軟件構建方式。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Mwl1aM5e6YzEXSxuT0-RUg