原文鏈接:何曉東 博客
場景
有個查詢結果集的操作,無可避免的需要在循環獲取數據,然後將結果集放到 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)。
參考鏈接:
- 深度解密go之 sync.Map
- 【Go基礎篇】 徹底搞懂 RWMutex 實現原理
- Go語言併發--傳統鎖與channel的選擇
- Go 併發 | 數據競爭及競爭條件
文章在夢康羣大佬們指點 + github copilot提示下完善的。