淺談 Golang 中資料的併發同步問題(三)
寫在前面
過去 Web 開發的工作比較少涉及到併發的問題,每個使用者請求在獨立的執行緒裡面進行,偶爾涉及到非同步任務但是執行緒間資料同步模型非常簡單,因此並未深入探究過併發這一塊。最近在寫遊戲相關的服務端程式碼時發現數據的併發同步場景非常多,因此花了一點時間來探索。這是一個系列文章,本文為第三篇。
本文簡單介紹 Golang 中 map 型別的安全使用。
Golang 中 map 的使用
在業務邏輯中儲存key-value
是一個非常普遍的需求,因此Map
的使用場景非常多。
不允許併發讀寫的 map
在 Golang 原始碼實現中對 map 的要求比較高(見《Go maps in action
》):Maps are not safe for concurrent use: it's not defined what happens when you read and write to them simultaneously
(當併發使用時 Maps 是不安全的,當併發地讀寫 map 的時候無法預知會發生啥 )。
如果不加保護地在不同的執行緒中讀寫map
型別的資料,程式碼會直接崩潰並異常退出。比如下面的程式碼:
package main func main() { m := make(map[int]int) go func() { for { _ = m[1] } }() go func() { for { m[2] = 1 } }() select {} }
執行上面的程式碼可以得到下面類似的結果:
go run map/main.go # fatal error: concurrent map read and map write # ....(省略異常堆疊)
從輸出結果來看,Golang 執行時明確禁止map
的併發讀寫,且在檢測到這種情況後直接異常退出。這不同於其他資料型別,比如int
、string
等,對比下面的程式碼(說明:下面的程式碼存在隱形的併發問題,具體參考《淺談 Golang 中資料的併發同步問題(二)
》):
// 執行下面的程式碼並不會異常退出,不同於上面 map 型別的 m 的使用 // go run main.go package main func main() { var m int go func() { for { _ = m } }() go func() { for { m = 1 } }() select {} }
再次需要說明
,雖然上面的程式碼在不同的執行緒中訪問int
型別的資料並未直接異常退出,但是這種不加任何安全措施的併發讀寫是存在安全風險的,具體參考《淺談 Golang 中資料的併發同步問題(二)
》。
安全使用 map——顯而易見地加鎖
既然 Golang 在執行時不允許對 map 的併發讀寫,當需要在多個執行緒中讀寫 map 時,顯而易見的方式是加鎖 (如《淺談 Golang 中資料的併發同步問題(一) 》所描述的)。
下面的程式碼把map
型別的m
封裝在一個匿名的struct
中,同時整個匿名的struct
繼承了sync.RWMutex
結構,因此擁有了加讀寫鎖
的功能,從而安全地實現了多個執行緒對map
的 “併發讀寫”:
package main import ( "sync" ) func main() { var counter = struct { sync.RWMutex m map[string]int }{m: make(map[string]int)} go func() { for { counter.RLock() _ = counter.m["some_key"] counter.RUnlock() } }() go func() { for { counter.Lock() counter.m["some_key"]++ counter.Unlock() } }() select {} }
為什麼 map 併發讀寫時會在執行時異常退出
最後提一下這個問題:為什麼 int、string、slice 等變數在多個執行緒讀寫時執行正常,而 map 在多個執行緒併發讀寫時會執行時異常退出?
其實這個涉及到map
的具體實現(我知道這是一句廢話 +_+)。
簡單來講,可以從Go 原始碼中map 執行時相關的部分
窺見一些依據:map
的增改刪查可以分別對應到func mapassign()
、func mapaccess1()
、func mapdelete()
這幾個函式,每個函式都有非常長的執行邏輯;如果多個執行緒併發讀寫同一個map
,大概率會出現 ①mapassign
函式(增加某個key
的值)執行到一半的時候mapaccess1
讀取到一個相應的零值,②mapaccess1
函式(讀取某個key
的值)執行到一半的時候mapdelete
已經刪除了對應的key
,等等。
同時考慮到增刪資料時底層資料的改變(比如擴容重分配,這一塊還沒深入研究,可以自行檢視原始碼=。=),因此保持 map 的單純變得很重要;為避免出現難以 debug 的異常,執行時環境顯式地併發異常退出也就可以理解了。
小結
Golang 的執行時會在 map 的增改刪查過程中檢測是否有併發讀寫的情況,當發現併發讀寫時直接異常退出 。相對於其他資料型別(比如 int、string、slice 等),map 的併發使用是比較嚴苛的(安全&效能的折中);可以認為 map 的這種嚴苛很大程度上降低了詭異 bug 的產生,增加程式碼的魯棒性。
最後,當提到 map 的併發使用時,很多時候會提到sync.Map
的使用,不過由於它大量使用了interface{}
型別,使用起來並不是那麼方便;目前為止,我更喜歡加讀寫鎖的方式
來使用 map 而不是使用執行緒安全的sync.Map
:laughing:
參考
- 淺談 Golang 中資料的併發同步問題(一) 介紹通過加鎖的方式保證執行緒安全
- 淺談 Golang 中資料的併發同步問題(二) 介紹了 atomic 包的使用及其侷限性
- Go maps in action - The Go Blog 官方文件中介紹 map 的部落格(需要自備梯子)
- go語言坑之併發訪問map 淺顯易懂地介紹了 map 的坑及併發訪問方法
- 疫苗:Java HashMap的死迴圈 瞭解 Java 中 HashMap 的實現機制,可以知道 Map 的執行緒不安全的實現是一個普遍現象
- src/runtime/map.go Go 原始碼中 map 相關的部分(執行時)
-
sync - The Go Programming Language
Golang 官方 sync 包中包含執行緒安全的
sync.Map
-
go map的執行緒安全使用
介紹了
sync.Map
的使用