原文連結:何曉東 部落格
場景
有個查詢結果集的操作,無可避免的需要在迴圈獲取資料,然後將結果集放到 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)。
參考連結:
文章在夢康群大佬們指點 + github copilot提示下完善的。