優步爆Go語言容易發生的資料併發爭奪問題

banq發表於2022-06-11

Uber已經採用Golang(簡稱Go)作為開發微服務的主要程式語言。我們的Go monorepo由大約5000萬行程式碼組成(還在增長),包含大約2100個獨特的Go服務(還在增長)。

Go使併發性成為一流的公民;在函式呼叫前加上go關鍵字,就可以非同步執行呼叫。Go中的這些非同步函式呼叫被稱為goroutines。開發人員通過建立goroutines來隱藏延遲(例如,對其他服務的IO或RPC呼叫)。兩個或多個goroutines可以通過訊息傳遞(通道)或共享記憶體進行資料通訊。共享記憶體正好是Go中最常用的資料通訊方式。

goroutines被認為是 "輕量級的",由於它們很容易建立,Go程式設計師們大量使用goroutines。因此,我們注意到,用Go編寫的程式,通常比用其他語言編寫的程式暴露出明顯的併發性。例如,通過掃描執行在我們資料中心的數十萬個微服務例項,我們發現Go微服務暴露的併發性比Java微服務高8倍。
更高的併發性也意味著可能出現更多的併發性錯誤。資料併發爭奪是一種併發錯誤,它發生在兩個或更多的goroutine訪問同一個資料,其中至少有一個是寫的,而且它們之間沒有排序。
資料併發爭奪是陰險的bug,必須不惜一切代價加以避免。

我們開發了一個系統,使用動態資料爭奪檢測技術檢測Uber的資料爭奪。這個系統在6個月的時間裡,在我們的Go程式碼庫中檢測到大約2000個資料爭奪,其中我們的開發人員已經修復了大約1100個資料爭奪。

在這篇部落格中,我們將展示我們在Go程式中發現的各種資料爭奪模式。這項研究是通過分析210名獨特的開發人員在6個月內修復的1100多個資料爭奪來進行的。總的來說,我們注意到,由於某些語言設計的選擇,Go更容易引入資料爭奪。語言特性和資料爭奪之間存在著複雜的相互作用。

1. Go 在 goroutine 中通過引用透明地捕獲自由變數的設計選擇是資料競爭的祕訣
Go 中的巢狀函式(又名閉包)通過引用透明地捕獲所有自由變數。程式設計師沒有明確指定在閉包語法中捕獲哪些自由變數。

這種使用方式不同於Java和C++。Java lambda僅按值捕獲,並且他們有意識地採用這種設計選擇來避免併發錯誤 [ 1 , 2 ]。C++ 要求開發人員明確指定按值或按引用捕獲。

開發人員通常不知道閉包內使用的變數是自由變數並通過引用捕獲,尤其是當閉包很大時。Go 開發人員通常使用閉包作為 goroutine。由於引用捕獲和 goroutine 併發性,除非執行顯式同步,否則 Go 程式最終可能會對自由變數進行無序訪問。

2. 切片slice是令人困惑的型別,會產生微妙且難以診斷的資料競爭

切片是動態陣列和引用型別。在內部,切片包含一個指向底層陣列的指標、它的當前長度以及底層陣列可以擴充套件的最大容量。為了便於討論,我們將這些變數稱為切片的元欄位。切片上的一個常見操作是通過追加操作來增長它。當大小達到容量時,進行新的分配(例如,當前大小的兩倍),並更新元欄位。當一個切片被 goroutines 併發訪問時,很自然地通過互斥鎖來保護對它的訪問。

3. 併發訪問 Go 內建的、執行緒不安全的對映會導致頻繁的資料競爭 
雜湊表 ( map ) 是 Go 中的內建語言功能,不是執行緒安全的。如果多個 goroutine 同時訪問同一個雜湊表,其中至少有一個試圖修改雜湊表(插入或刪除一個專案),就會發生資料競爭。

雖然導致資料競爭的雜湊表並不是 Go 獨有的,但以下原因使其更容易在 Go 中發生資料競爭:

  1. Go 開發人員比其他語言的開發人員更頻繁地使用map,因為map是一種內建的語言結構。例如,在我們的 Java 儲存庫中,我們發現每個 MLoC 有 4,389 個對映結構,而 Go 相同,每個 MLoC 有 5,950 個,高出 1.34 倍。 
  2. 雜湊表訪問語法就像陣列訪問語法(與 Java 的 get/put API 不同),使其易於使用,因此意外地與隨機訪問資料結構混淆。在 Go 中,可以使用table[key]語法輕鬆查詢不存在的 map 元素,該語法簡單地返回預設值而不會產生任何錯誤。這種容錯性讓開發者在使用 Go map 時沾沾自喜。


4. Go 開發人員經常在傳遞值(或方法超過值)方面犯錯,這可能導致非平凡的資料競爭

Go 中推薦使用按值傳遞語義,因為它簡化了逃逸分析,併為變數提供了更好的在堆疊上分配的機會,從而減少了垃圾收集器的壓力。 
與所有物件都是引用型別的 Java 不同,在 Go 中,物件可以是值型別(結構)或引用型別(介面)。沒有語法差異,這會導致同步構造的錯誤使用,例如sync.Mutex和sync.RWMutex ,它們是 Go 中的值型別(結構)。

如果一個函式建立了一個互斥體結構並通過值傳遞給多個 goroutine 呼叫,那麼這些 goroutines 的併發執行對不同的互斥物件進行操作,這些互斥物件不共享內部狀態。這會破壞對受保護的共享記憶體區域的互斥訪問。

5. 訊息傳遞(channels)和共享記憶體的混合使用使程式碼變得複雜並且容易受到資料競爭的影響

6. Go在其群組同步結構sync.WaitGroup中提供了更多的迴旋餘地,但是Add/Done方法的不正確位置導致了資料競爭。

7. 為 Go 的表驅動測試套件習語並行執行測試通常會導致產品或測試程式碼中的資料競爭

測試是 Go 的內建功能。字尾為_test.go的檔案中的任何字首為Test的函式都可以通過 Go 構建系統作為測試執行。如果測試程式碼呼叫 API testing.T.Parallel() ,它將與其他此類測試同時執行。我們發現由於這樣的併發測試執行,會發生一大類資料競爭。這些資料競爭的根本原因有時在測試程式碼中,有時在產品程式碼中。

Go 推薦一個表驅動的測試套件習語編寫和執行測試套件。我們的開發人員在一個測試中廣泛編寫了數十或數百個子測試,我們的系統並行執行這些子測試。這個習慣用法成為測試套件問題的根源,開發人員要麼假設序列測試執行,要麼在大型複雜測試套件中忘記使用共享物件。當產品 API 編寫時沒有執行緒安全(可能是因為不需要它),但被並行呼叫時,也會出現問題,這違反了假設。

總之,基於觀察到的(包括固定的)資料爭奪,我們詳細闡述了 Go 語言正規化,使在 Go 程式中引入爭奪變得容易。我們希望我們在 Go 中資料競爭的經驗能夠幫助 Go 開發人員更加關注編寫併發程式碼的微妙之處。未來的程式語言設計者應該仔細權衡不同的語言特性和編碼習慣與它們建立常見或神祕的併發錯誤的潛力。 

 

相關文章