go 協程操作map導致的資料競爭及解決方法

hxd_發表於2023-05-04

原文連結:何曉東 部落格

場景

有個查詢結果集的操作,無可避免的需要在迴圈獲取資料,然後將結果集放到 map 中,這個操作在壓測的時候,沒出現問題,釋出到生產環境之後,開始偶現 fatal error: concurrent map read and map write 錯誤,導致容器重啟了。

原因

多個協程同時對 map 進行讀寫操作,導致資料競爭
測試環境壓測未復現是因為單個 pod 常規時間只有一個 CPU,資源不夠用了才會使用兩個 CPU,單核的情況下,協程是序列執行的,所以沒有出現資料競爭的問題。
同時也沒開著資料競爭檢測,也沒有檢測出來這個問題

除錯

在本機多核CPU情況下,執行 go run --race main.go 啟動專案,呼叫方法,會有提示 data race,再開始對應解決問題。
出現以下資料競爭告警時,就是需要解決問題,無則是其他情況,需具體分析。

==================
WARNING: DATA RACE
Write at 0x00c00008e000 by goroutine 7:
 main.main.func2()
Previous write at 0x00c00008e000 by goroutine 6:
 main.main.func1()
==================
解決方案

① 使用 sync.Mutex/sync.RWMutex 加鎖
互斥鎖:
Mutex是互斥鎖的意思,也叫排他鎖,同一時刻一段程式碼只能被一個執行緒執行,使用只需要關注方法Lock(加鎖)和Unlock(解鎖)即可。
在Lock()和Unlock()之間的程式碼段稱為資源的臨界區(critical section),是執行緒安全的,任何一個時間點都只能有一個goroutine執行這段區間的程式碼。
Mutex在大量併發的情況下,會造成鎖等待,對效能的影響比較大。

讀寫鎖:
讀寫鎖的讀鎖可以重入,在已經有讀鎖的情況下,可以任意加讀鎖。
在讀鎖沒有全部解鎖的情況下,寫操作會阻塞直到所有讀鎖解鎖。
寫鎖定的情況下,其他協程的讀寫都會被阻塞,直到寫鎖解鎖。

根據業務場景,按需進行加鎖,儘量減少鎖的粒度,提高效能。

② 使用 sync.Map
go 原生的 map 不是執行緒安全的,sync.Map 是執行緒安全的,讀取,插入,刪除也都保持著常數級的時間複雜度。並且它透過空間換時間的方式,使用 read 和 dirty 兩個 map 來進行讀寫分離,降低鎖時間來提高效率。

sync.Map 適用於讀多寫少的場景,如果併發寫多的場景,還是需要加鎖的對於寫多的場景,會導致 read map 快取失效,需要加鎖,導致衝突變多;而且由於未命中 read map 次數過多,導致 dirty map 提升為 read map,這是一個 O(N) 的操作,會進一步降低效能。

③ 使用 channel 通道傳遞資料
channel 是 goroutine 之間的通訊方式,可以用來傳遞資料,也可以用來傳遞訊號,比如結束訊號,超時訊號等。
go 的一個原則也是:透過通訊來共享記憶體,而不是透過共享記憶體來通訊。channel 也是執行緒安全的,可以用來解決資料競爭的問題。

額外原則: 如果有資料傳遞後,繼續有進行處理,可以使用 channel,如果僅是賦值,無其他操作,直接加鎖或者 sync.Map 簡單易理解

額外筆記

資料競爭 (data race) 的發生條件是:當多個協程同時訪問一個相同記憶體地址,並且至少有一個在進行寫操作時,資料競爭意味著不確定的行為。
而不存在資料競爭不代表結果就是確定的。實際上,一個應用程式即使不存在資料競爭,但它的行為肯依賴於不可控的發生時間或執行順序,這就是競爭條件 (race condition)。

參考連結:

  1. 深度解密go之 sync.Map
  2. 【Go基礎篇】 徹底搞懂 RWMutex 實現原理
  3. Go語言併發--傳統鎖與channel的選擇
  4. Go 併發 | 資料競爭及競爭條件

文章在夢康群大佬們指點 + github copilot提示下完善的。

相關文章