Go: 併發訪問 Map — Part III
在上一篇文章 “Go: 通過源碼研究 Map 的設計 [1]” 中,我們講述了 map 的內部實現。
Go blog[2] 中專門講解 map 的文章明確地表明:
map 是非併發安全的 [3]:併發讀寫 map 時,map 的行爲是未知的。如果你需要使用併發執行的 Goroutine 同時讀寫 map,必須使用某種同步機制來協調訪問。
然而,正如 FAQ[4] 中解釋的,Google 提供了一些幫助:
作爲一種糾正 map 使用方式的輔助手段,語言的某些實現包含了特殊的檢查,當運行時的 map 被不安全地併發修改時,它會自動報告。
數據爭用檢測
我們可以從 Go 獲得的第一個幫助就是數據爭用檢測。使用 -race
標記來運行你的程序或測試會讓你瞭解潛在的數據爭用。讓我們看一個例子:
func main() {
m := make(map[string]int, 1)
m[`foo`] = 1
var wg sync.WaitGroup
wg.Add(2)
Go func() {
for i := 0; i < 1000; i++ {
m[`foo`]++
}
}()
Go func() {
for i := 0; i < 1000; i++ {
m[`foo`]++
}
}()
wg.Wait()
}
在這個例子中,我們清晰地看到,在某一時刻,兩個 Goroutine 嘗試同時寫入一個新值。下面是爭用檢測器的輸出:
==================
WARNING: DATA RACE
Read at 0x00c00008e000 by Goroutine 6:
runtime.mapaccess1_faststr()
/usr/local/go/src/runtime/map_faststr.go:12 +0x0
main.main.func2()
main.go:19 +0x69
Previous write at 0x00c00008e000 by Goroutine 5:
runtime.mapassign_faststr()
/usr/local/go/src/runtime/map_faststr.go:202 +0x0
main.main.func1()
main.go:14 +0xb8
爭用檢測器解釋道,當第二個 Goroutine 正在讀變量時,第一個 Goroutine 正在向同一個內存地址寫一個新值。如果你想要了解更多,我建議你閱讀我的一篇關於數據爭用檢測器 [5] 的文章。
併發寫入檢測
Go 提供的另一個幫助是併發寫入檢測。讓我們使用之前看到的那個例子。運行這個程序時,我們將看到一個錯誤:
fatal error: concurrent map writes
在 map 結構的內部標誌 flags
的幫助下,Go 處理了這次併發。當代碼嘗試修改 map 時(賦值,刪除值或者清空 map),flags
的某一位會被置爲 1:
func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
[...]
h.flags ^= hashWriting
值爲 4 的 hashWriting
會將相關的位置爲 1。
^ 是一個異或操作,如果兩個操作數的某一位的值不同,^ 將該位置爲 1:
img
當操作結束時,該標誌會被重置:
func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
[...]
h.flags &^= hashWriting
}
既然每個修改 map 的操作都設置了一個控制標誌,那麼通過檢查這個標誌的狀態,就可以防止併發寫入。這裏是該標誌的生命週期的例子:
func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
[...]
// if another process is currently writing, throw error
if h.flags&hashWriting != 0 {
throw("concurrent map writes")
}
[...]
// no one is writing, we can set now the flag
h.flags ^= hashWriting
[...]
// flag reset
h.flags &^= hashWriting
}
sync.Map vs Map with lock
sync
包也提供了併發安全的 map。不過,正如文檔 [6] 中解釋的,你應該謹慎的選擇你使用的 map:
sync
包中的 map 類型是專業的。大多數代碼應該使用原生的 Go map,附加上鎖或者其他協調方式,這樣類型安全更有保障,而且更容易維護其他的不變量和 map 的內容。
實際上,正如我的文章 “Go: 通過源碼研究 Map 的設計 [7]” 中所解釋的,map 根據我們處理的具體類型提供了不同的方法。
讓我們運行一個簡單的基準測試,比較帶有鎖的常規 map 和 sync
包的 map。一個基準測試併發寫入 map,另一個僅僅讀取 map 中的值:
MapWithLockWithWriteOnlyInConcurrentEnc-8 68.2 µ s ± 2%
SyncMapWithWriteOnlyInConcurrentEnc-8 192 µ s ± 2%
MapWithLockWithReadOnlyInConcurrentEnc-8 76.8 µ s ± 3%
SyncMapWithReadOnlyInConcurrentEnc-8 55.7 µ s ± 4%
我們可以看到,兩種 map 各有千秋。我們可以根據具體的情況選擇其中之一。文檔 [8] 中很好地解釋了這些情況:
map 類型針對兩種常見使用場景做了優化:(1) 指定 key 的 entry 僅寫入一次,但多次讀取,比如只增長的緩存;(2) 多個 Goroutine 讀取、寫入、覆蓋不相交的 key 的集合指向的 entry。
Map vs sync.Map
FAQ[9] 中也解釋了他們做出了默認情況下 map 非併發安全這個決定的原因:
因此,要求所有的 map 操作都獲取互斥鎖,會拖慢大多數程序,但只爲很少的程序增加了安全性
讓我們運行一個不使用併發 Goroutine 的基準測試,來理解當你不需要併發但標準庫默認提供併發安全的 map 時,可能帶來的影響:
MapWithWriteOnly-8 11.1ns ± 3%
SyncMapWithWriteOnly-8 121ns ± 6%
MapWithReadOnly-8 4.87ns ± 7%
SyncMapWithReadOnly-8 29.2ns ± 4%
簡單的 map 快 7 到 10 倍。顯然,在非併發模式下,這聽起來更合理,巨大的差異也清楚的解釋了爲什麼默認非併發安全的 map 是更好的選擇。如果你不需要處理併發狀況,爲什麼要讓程序運行的更慢呢?
via: https://medium.com/@blanchon.vincent/go-concurrency-access-with-maps-part-iii-8c0a0e4eb27e
作者:blanchon.vincent[10] 譯者:DoubleLuck[11] 校對:dingdingzhou[12]
本文由 GCTT[13] 原創編譯,Go 中文網 [14] 榮譽推出,發佈在 Go 語言中文網公衆號,轉載請聯繫我們授權。
參考資料
[1]
Go: 通過源碼研究 Map 的設計: https://studygolang.com/articles/22777
[2]
Go blog: https://blog.golang.org/go-maps-in-action
[3]
map 是非併發安全的: https://golang.org/doc/faq#atomic_maps
[4]
FAQ: https://golang.org/doc/faq#atomic_maps
[5]
數據爭用檢測器: https://medium.com/@blanchon.vincent/go-race-detector-with-threadsanitizer-8e497f9e42db
[6]
文檔: https://golang.org/pkg/sync/
[7]
Go: 通過源碼研究 Map 的設計: https://studygolang.com/articles/22777
[8]
文檔: https://golang.org/pkg/sync/#Map
[9]
FAQ: https://golang.org/doc/faq#atomic_maps
[10]
blanchon.vincent: https://medium.com/@blanchon.vincent
[11]
DoubleLuck: https://github.com/DoubleLuck
[12]
dingdingzhou: https://github.com/dingdingzhou
[13]
GCTT: https://github.com/studygolang/GCTT
[14]
Go 中文網: https://studygolang.com/
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Kao_r70gDl0-y4_KfmexWg