Go 語言中的數據競爭模式

譯者 | 陳峻

策劃 | 雲昭

本文主要基於在 Uber 的 Go monorepo 中發現的各種數據競爭模式,分析了其背後的原因與分類,希望能夠幫助更多的 Go 開發人員,去關注併發代碼的編寫,考慮不同的語言的特性、以及避免由於自身編程習慣所引發的併發錯誤。

近年來,Uber 已經開始採用 Golang(簡稱 Go)作爲開發微服務的主要編程語言。目前,其 Go monorepo(譯者注:包含多個不同項目的單個倉庫)包含了大約 5,000 萬行代碼,以及大約 2,100 個獨特的 Go 服務。而且,它們都還在持續增長中。

爲了實現併發,我們通常會使用 go 關鍵字,爲函數調用添加前綴,以實現異步式的運行調用。在 Go 中,此類異步函數調用被稱爲 goroutine。開發人員可以通過創建 goroutine(例如,對其他服務的 IO 或 RPC 調用),來隱藏延遲。不同的 goroutine 可以通過消息傳遞,以及共享內存的方式,來傳遞數據。其中,共享內存恰好是 Go 中最常用的數據通信方式之一。

由於 goroutineGo 很容易被程序員創建和使用,因此它被認爲屬於 “輕量級” 。同時,由 Go 編寫的程序通常會比由其他語言編寫的程序具有更強的併發性。例如,通過掃描數十萬個運行在數據中心的微服務實例,我們發現 Go 微服務的併發性可達 Java 微服務的 8 倍。

當然,更高的併發性也意味着更多潛在的併發錯誤。我們常用數據競爭(data race)來描述當兩個或多個 goroutine 訪問相同的數據,而且至少有一個處於寫入狀態時,由於它們之間並沒有排序,因此就會發生併發錯誤。總的來說,根據 Go 自身的相互作用等特點,數據競爭之類的隱蔽錯誤非常容易出現,因此我們應該儘量避免。

最近,我們使用動態數據競爭檢測技術開發了一個系統,專門用來檢測 Uber 的數據競爭。它在上線的六個月時間內,在我們的 Go 代碼庫中,檢測到了大約 2,000 個數據競爭。其中已被開發人員着手修復了的數據競爭約有 1,100 個。下面,我將向您展示我們已發現的各種常見數據競爭模式。

PART 01 Go 在 goroutine 中通過引用來透明地捕獲自由變量

Go 中的嵌套函數(又名 closure)通過引用的方式,透明地捕獲所有自由的變量。程序員通常無需明確指定在 closure 語法中,需要捕獲哪些自由變量。

這種方式是有別於 Java 和 C++ 的。Java 的 lambda 僅會根據數值去捕獲,而且他們會有意識地避免併發缺陷。而 C++ 則要求開發人員明確地指明是使用數值、還是引用的捕獲方式。

當 closure 較大時,開發人員並不知道 closure 內使用的變量是否自由,可否通過引用來捕獲。而由於引用的捕獲、以及 goroutine 都是併發的,因此 Go 程序最終可能會因爲沒能顯式地執行同步,而對自由變量進行無序的訪問。我們可以通過如下三個示例來證明這一點:

示例 1:由循環索引的變量捕獲,而導致數據競爭

圖 1A 中的代碼顯示了迭代 Go 的切片作業,並通過 ProcessJob 函數來處理每個元素的作業。

圖 1A:由循環索引的變量捕獲,而導致數據競爭。

在此,開發人員會將厚重的 ProcessJob 包裝在一個匿名的 goroutine 中。但是,循環索引變量的作業是通過 goroutine 內部被引用捕獲的。當 goroutine 爲首次循環迭代而啓動,並訪問作業的變量時,父 goroutine 中的 for 循環將在切片中更新相同的循環索引變量作業,並指向切片中的第二個元素,這就會導致數據競爭的出現。此類數據競爭可能發生在數值和引用類型上;切片、數組和映射上;以及循環體中的讀和寫的訪問中。爲此,Go 推薦了一種編碼習慣,來隱藏和私有化循環體中循環索引的變量。不過,開發人員並不總是能夠遵循這一點。

示例 2:由 err 變量的捕獲,所導致的數據競爭

圖 1B:由 err 變量的捕獲,所導致的數據競爭。

Go 一直提倡函數有多個返回值。圖 1B 展示了一種常見的通過返回實際值和錯誤對象,來指示是否存在錯誤的用法。可見,當且僅當錯誤值爲 nil(空)時,實際的返回值纔會被認爲是有意義的。因此,我們的通常做法是:將返回的錯誤對象,分配給名爲 err 的變量,然後檢查其是否爲空(nilness)。不過,由於我們可以在函數體內調用多個返回錯誤的函數,因此程序每次都會對 err 變量進行多次賦值,然後進行是否爲空的檢查。當開發人員將這個習慣用法與 goroutine 混合使用時,錯誤變量就會在 closure 中被引用捕獲。結果,程序對於 goroutine 中 err 的讀寫訪問,與隨後對封閉函數(或 goroutine 的多個實例)中相同的 err 變量的讀寫操作,就會同時運行。這便導致了數據競爭。

示例 3:由已命名的返回變量捕獲,所導致的數據競爭

圖 1C:由已命名的返回變量捕獲,所導致的數據競爭。

Go 引入了一種被稱爲已命名返回值的語法塊。已命名的返回變量被視爲在函數頂部定義的變量,其作用域超出了函數體。而沒有參數的 return 語句,被稱爲 “裸” 命名返回值。由於 closure 的存在,如果將正常(非裸)的返回與已命名的返回相混合、或在具有命名返回的函數中使用延遲返回,那麼就可能會引發數據競爭。

在上圖 1C 中的 NamedReturnCallee 函數返回了一個整數,而且返回變量被命名爲 result。根據該語法,函數體的其餘部分可以對結果進行直接讀寫,而無需額外聲明。如果函數在第 4 行返回的是一個裸返回,而由於在第 2 行被賦值爲 result=10,那麼第 13 行的調用者將看到其返回值爲 10。編譯器則會安排將結果複製到 retVal。同時,已命名的返回函數也可以使用如第 9 行所示的標準返回語法。該語法會讓編譯器複製 return 語句中的返回值 20,以分配給已命名的返回變量結果。第 6 行創建了一個 goroutine,它會捕獲已命名的返回變量的結果。在設置該 goroutine 時,即使是併發專家也可能認爲讀取第 7 行的結果中是安全的,畢竟不存在對同一變量的寫入,而且第 9 行的語句返回的 20 是一個常量,它似乎並沒有觸及到已命名的返回變量結果。不過,如前所述,代碼在生成的過程中,會將 return 20 的語句轉換爲寫入結果。此時,一旦我們突然對共享的結果變量進行併發讀寫,就會產生數據競爭的情況。

PART 02 切片會產生難以診斷的數據競爭

切片(Slices)實際上是一些動態數組和引用類型。在其內部,切片包含了一個指向底層數組的指針、它的當前長度、以及底層數組可以擴展的最大容量。爲了便於討論,我們將這些變量統稱爲切片的元字段(meta field)。切片上的一種常見操作便是通過追加操作(append operation)來使其增長。當達到其容量限制時,代碼會進行新的分配(例如,對當前的容量翻倍),並更新其對應的元字段。而當一個切片被 goroutine 併發訪問時,Go 會通過互斥鎖(mutex),來保護對它的訪問。

圖 2:即使使用鎖,切片仍會出現數據競爭。

在圖 2 中,開發人員往往以爲已經對第 6 行的切片進行了鎖定保護,便可防止數據競爭的出現。而實際上,當第 14 行將切片作爲參數傳遞給沒有鎖保護的 goroutine 時,就會產生數據競爭。具體而言,goroutine 的調用導致了切片中的元字段從調用處(第 14 行)被複制到被調用者(第 11 行)處。考慮到切片屬於引用類型,我們認爲在將其傳遞(複製)到被調用者時,會導致數據競爭的發生。不過,由於切片與指針類型不同,畢竟元字段是按照數值複製的,因此該數據競爭的發生概率非常低。

PART 03 併發訪問 Go 內置的、不安全的線程映射會導致頻繁的數據競爭

哈希表 (或稱映射) 是 Go 中的內置語言功能。不過,它對於線程是不安全的。如果多個 goroutine 同時訪問同一張哈希表,而且其中至少有一個試圖去修改哈希表(插入或刪除某項)的話,就會產生數據競爭。開發人員往往認爲他們可以同時訪問哈希表中的不同項。而實際上,與數組或切片不同,映射(哈希表)是一種稀疏的數據結構,訪問某一個元素就可能會導致訪問另一個元素,如果在同一過程中發生了另一種插入或刪除,那麼它將會因爲修改了稀疏的數據結構,而導致了數據競爭。

我們甚至觀察到了更爲複雜的、由併發映射訪問產生的數據競爭。其原因是同一個哈希表被傳遞到了深度調用路徑,而開發人員忘記了這些調用路徑是通過異步 goroutine 去改變哈希表的事實。圖 3 便顯示了此類數據競爭的示例。

圖 3:由於併發映射訪問導致的數據競爭。

雖然導致數據競爭的哈希表並非 Go 獨有,但是以下原因會讓 Go 更容易發生數據競爭:

PART 04 Go 開發人員常在 pass-by-value 時犯錯並導致 non-trivial 的數據競爭

Go 建議使用 pass-by-value 的語義,以簡化逃逸分析,併爲變量提供更好的棧上分配的機會,進而減少垃圾收集器的壓力。

與所有對象皆爲引用類型的 Java 不同,在 Go 中,對象可以是數值類型(如:結構),也可以是引用類型(如:接口)。由於沒有了語法差異,這會導致諸如:sync.Mutex 和 sync.RWMutex 等數值類型,在同步構造中被錯誤地使用。如果一個函數創建了一個互斥體結構,並通過數值傳遞(pass-by-value)給多個 goroutine 調用,那麼這些 goroutine 在併發執行時,不同的互斥對象是不會在操作過程中共享內部狀態的。這也就破壞了對於受保護的共享內存區域的互斥訪問特性。請參見如下圖 4 所示的代碼。

圖 4A:

由 by-reference 或 by-pointer 的方法調用所引起的數據競爭

圖 4B:sync.Mutex 的 Lock/Unlock 簽名。

由於 Go 語法在指針和數值上調用方法是相同的,因此開發人員往往會忽視 m.Lock() 正在處理互斥鎖的副本並非指針這一問題。調用者仍然可以在互斥的數值上調用這些 API。而且編譯器也會透明地安排傳遞數值的地址。相反,如果沒有此類透明度,該錯誤就能夠會被檢測到,並認定爲編譯器類型不匹配的錯誤。

據此,當開發人員意外地實現了一個方法,其中的接收者是指向結構的指針,而不是結構的數值或副本時,那麼就會發生與此相反的情況。也就是說,調用該方法的多個 goroutine,最終會意外地共享結構相同的內部狀態。而且,調用者也不會意識到數值類型在接收者處被透明地轉換爲了指針類型。顯然,這都是開發人員所不願發生的。

PART 05 消息傳遞(通道)和共享內存的混合使用使代碼變得複雜且易受數據競爭的影響

圖 5:將消息傳遞與共享內存混合時的數據競爭。

圖 5 展示了開發人員使用一個專門爲信號和等待準備的通道,通過 Future 來實現的示例。我們可以通過調用 Start() 方法來啓動 Future,並通過調用 Future 的 Wait() 方法,來阻止 Future 的完成。Start() 方法會創建一個 goroutine,以執行一個註冊到 Future 的函數,並記錄其返回值(如:response 和 err)。如第 6 行所示,goroutine 通過在通道 ch 上發送一條消息,以向 Wait() 方法發出 Future 完成的信號。對稱地,如第 11 行所示,Wait() 方法塊會從通道中獲取相應的消息。

在 Go 中,上下文攜帶了跨越 API 邊界和進程之間的截止日期、取消信號和其他請求範圍的數值。這是在微服務中爲任務設置時間線的常見模式。由此,Wait() 阻止了被取消(第 13 行)的上下文、或已完成的 Future(第 11 行)。此外,Wait() 被包裝在一個 select 語句(第 10 行)中,並處於阻止狀態,直到至少有一個選擇 arm 準備就緒。

如果上下文超時,則相應的案例將 Future 的 err 字段,在第 14 行上記錄爲 ErrCancelled。此時,對於 err 的寫入與第 5 行對 Future 的相同變量的寫入操作,便形成了競爭。

PART 06 Add 和 Done 方法的錯誤放置會導致數據競爭

sync.WaitGroup 結構是 Go 的組同步結構。與 C++ 的 barrier 的 barrier、以及 latch 的構造不同,WaitGroup 中參與者的數量不是在構造時被確定的,而是動態更新的。在 WaitGroup 對象上,Go 允許進行 Add(int)、Done() 和 Wait() 三種操作。其中,Add() 會增加參與者的計數,而 Wait() 會處於阻止狀態,直到 Done() 被調用爲 count 的次數(通常每個參與者一次)。由於在 Go 中,組同步的使用程度比 Java 高出 1.9 倍,因此 WaitGroup 在 Go 中常被廣泛地使用。

在下圖 6 中,開發人員打算創建與切片 itemId 裏的元素數量相同的 goroutine,且併發處理它們。每個 goroutine 在不同索引的結果切片、以及在第 12 行對父功能塊中,記錄其成功或失敗的狀態,直到所有的 goroutine 已完成。接着,它會依次訪問結果中的所有元素,以計算出被成功處理的數量。

圖 6A:

由於 WaitGroup.Add() 的錯誤放置,導致了數據競爭

爲了使該代碼能夠正常工作,我們需要在第 12 行調用 Wait() 時,保證 wg.Add(1) 在調用 wg.Wait() 之前所執行的次數,也就是註冊參與者的數量,必須等於 itemIds 的長度。這就意味着 wg.Add(1) 應該在每個 goroutine 之前被放置在第 5 行調用。但是,如果開發人員在第 7 行錯誤地將 wg.Add(1) 放置在了 goroutine 的主體中,它就無法保證在外部函數 WaitGrpExample 調用 Wait() 時,完整地執行。據此,在調用 Wait() 時,被註冊到 WaitGroup 的 itemId 的長度就可能會變短。正是出於該原因,Wait() 會被提前解除阻止。據此,WaitGrpExample 函數則可以從切片結果中開始讀取(即:第 13 行),而一些 goroutine 則開始併發寫入同一個切片。

此外,我們還發現過早地在 Waitgroup 上調用 wg.Done(),也會導致數據競爭。下圖 6B 展示了 wg.Done()與 Go 的 defer 語句交互的結果。當遇到多個 defer 語句時,代碼會按照 “後進先出” 的順序去執行。其中,第 9 行的 wg.Wait()會在 doCleanup()運行之前完成。即,父 goroutine 會在第 10 行去訪問 locationErr,而子 goroutine 可能仍然在延遲的 doCleanup()函數內寫入 locationErr(爲簡潔起見,在此並未顯示)。

圖 6B:由於 WaitGroup.Done() 的錯誤放置

延遲語句排序,並導致了數據競爭。

PART 07 併發運行測試會導致產品或測試代碼中的數據競爭

測試是 Go 的內置功能。在那些後綴爲_test.go 的文件裏,任何前綴爲 Test 的函數,都可以測試由 Go 構建的系統。如果測試代碼調用了 API--testing.T.Parallel(),那麼它將與其他同類測試併發運行。我們發現此類併發測試有時會在測試代碼中、有時也會在產品代碼中產生大量的數據競爭。

此外,在單個以 Test 爲前綴的函數中,Go 開發人員經常會編寫許多子測試,並通過由 Go 提供的套件包去執行它們。Go 推薦開發人員通過表驅動的測試套件習語(table-driven test suite idiom)去編寫和運行測試套件。據此,我們的開發人員在同一個測試中就編寫了數十、甚至數百個可供系統併發運行的子測試。開發人員以爲代碼會執行串行測試,而忘記了在大型複雜測試套件中使用共享對象。此外,當產品級 API 在缺少線程安全(可能是因爲沒有需要)的情況下,被併發調用時,情況就會更加惡化。

PART 08 小結

在上文中,我們分析了 Go 語言裏的各種數據競爭模式,並對其背後的原因進行了分類。當然,不同的原因也可能會相互作用與影響。下表是對各種問題的彙總。

圖 7:數據競爭待分類。

上面討論的主要是基於我們在 Uber 的 Go monorepo 中發現的各種數據競爭模式,難免有些掛一漏萬。其實,代碼的交錯覆蓋也可能產生數據競爭模式。希望上述提到的各種經驗能夠幫助更多的 Go 開發人員,去關注併發代碼的編寫,考慮不同的語言的特性、以及避免由於自身編程習慣所引發的併發錯誤。

原文鏈接:

https://eng.uber.com/data-race-patterns-in-go/

譯者介紹

陳峻 (Julian Chen),51CTO 社區編輯,具有十多年的 IT 項目實施經驗,善於對內外部資源與風險實施管控,專注傳播網絡與信息安全知識與經驗;持續以博文、專題和譯文等形式,分享前沿技術與新知;經常以線上、線下等方式,開展信息安全類培訓與授課。

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