首頁 > 軟體

Go語言中的資料競爭模式詳解

2022-07-20 18:02:22

前言

本文主要基於在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個。下面,我將向您展示我們已發現的各種常見資料競爭模式。

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的語句轉換為寫入結果。此時,一旦我們突然對共用的結果變數進行並行讀寫,就會產生資料競爭的情況。

切片會產生難以診斷的資料競爭 

切片(Slices)實際上是一些動態陣列和參照型別。在其內部,切片包含了一個指向底層陣列的指標、它的當前長度、以及底層陣列可以擴充套件的最大容量。為了便於討論,我們將這些變數統稱為切片的元欄位(meta field)。切片上的一種常見操作便是通過追加操作(append operation)來使其增長。當達到其容量限制時,程式碼會進行新的分配(例如,對當前的容量翻倍),並更新其對應的元欄位。而當一個切片被goroutine並行存取時,Go會通過互斥鎖(mutex),來保護對它的存取。

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

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

並行存取Go內建的、不安全的執行緒對映會導致頻繁的資料競爭 

雜湊表(或稱對映)是Go中的內建語言功能。不過,它對於執行緒是不安全的。如果多個goroutine同時存取同一張雜湊表,而且其中至少有一個試圖去修改雜湊表(插入或刪除某項)的話,就會產生資料競爭。開發人員往往認為他們可以同時存取雜湊表中的不同項。而實際上,與陣列或切片不同,對映(雜湊表)是一種稀疏的資料結構,存取某一個元素就可能會導致存取另一個元素,如果在同一過程中發生了另一種插入或刪除,那麼它將會因為修改了稀疏的資料結構,而導致了資料競爭。

我們甚至觀察到了更為複雜的、由並行對映存取產生的資料競爭。其原因是同一個雜湊表被傳遞到了深度呼叫路徑,而開發人員忘記了這些呼叫路徑是通過非同步goroutine去改變雜湊表的事實。圖3便顯示了此類資料競爭的範例。

圖3:由於並行對映存取導致的資料競爭。

雖然導致資料競爭的雜湊表並非Go獨有,但是以下原因會讓Go更容易發生資料競爭:

  • 由於對映是一種內建的語言結構,因此Go開發人員會比其他語言的開發者更頻繁地使用對映。例如,在我們的Java儲存庫中,每MLoC(Millions of Lines Of Code,數百萬行程式碼)裡有4,389個對映結構;而在Go中,每MLoC裡就有5,950個對映,足足高出了1.34倍。
  • 不同於Java的get和put API,雜湊表的存取語法類似陣列存取語法,雖然易於使用,但是也會意外地與隨機存取資料結構相混淆。在Go中,我們可以使用table[key]的語法,輕鬆查詢那些不存在(non-existing)的對映元素。該語法能夠簡單地返回預設值,而不會產生任何錯誤。這種容錯性對於開發者在使用Go的對映時是非常友好的。

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,最終會意外地共用結構相同的內部狀態。而且,呼叫者也不會意識到數值型別在接收者處被透明地轉換為了指標型別。顯然,這都是開發人員所不願發生的。

訊息傳遞(通道)和共用記憶體的混合使用使程式碼變得複雜且易受資料競爭的影響

圖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的相同變數的寫入操作,便形成了競爭。

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()的錯誤放置

延遲語句排序,並導致了資料競爭。

並行執行測試會導致產品或測試程式碼中的資料競爭 

測試是Go的內建功能。在那些字尾為_test.go的檔案裡,任何字首為Test的函數,都可以測試由Go構建的系統。如果測試程式碼呼叫了API--testing.T.Parallel(),那麼它將與其他同類測試並行執行。我們發現此類並行測試有時會在測試程式碼中、有時也會在產品程式碼中產生大量的資料競爭。

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

小結 

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

圖7:資料競爭待分類。

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

到此這篇關於Go語言中的資料競爭模式詳解的文章就介紹到這了,更多相關Go資料競爭模式內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com