concurrent-map 和 sync.Map,我該選擇哪個?
官方的map并不是執行緒安全的,如果我們在多執行緒中并發對一個map進行讀寫操作,是會引發panic的,解決方案除了使用鎖來對map進行保護外,還有兩種方式:
一,開源專案 concurrent-map 提供了可以用來做并發安全的map
二,Go1.9之后,標準庫提供了一個sync.Map
這兩種并發安全的map,我們應該怎么選擇呢?
在concurrent-map我看到這么一段話:
標準庫中的sync.Map是專為append-only場景設計的,因此,如果您想將Map用于一個類似記憶體資料庫,那么使用我們的版本可能會受益,你可以在golang repo上讀到更多,這里 and 這里 譯注:sync.Map在讀多寫少性能比較好,否則并發性能很差
concurrent-map為什么會有這種表述呢?這篇文章就來庖丁解牛下,
concurrent-map
concurrent-map是Golang中一個流行的并發安全的哈希表庫,它允許多個goroutine同時對哈希表進行讀寫操作,而不需要使用顯式的鎖或同步原語,
該庫的核心原理是使用分片鎖,將哈希表分成多個小的哈希表片段,并為每個片段分配一個獨立的鎖,當多個goroutine嘗試同時讀寫同一個片段時,只有該片段上的鎖會被鎖住,而其他片段的鎖則不受影響,從而避免了整個哈希表被鎖住的情況,
當進行寫操作時,只需要鎖住要寫入的片段的鎖,以確保原子性操作,當進行讀操作時,則不需要鎖住片段的鎖,只需要對該片段上的讀取操作進行同步即可,
此外,concurrent-map庫還使用了一些優化策略,如快取哈希值和桶的地址,以減少計算和查找時間,從而提高并發讀寫性能,
總之,concurrent-map庫的原理是基于分片鎖和其他優化策略來實作高效的并發安全哈希表,
我們先看它的使用方式:
// 創建一個新的 map.
m := cmap.New[string]()
// 設定變數m一個鍵為“foo”值為“bar”鍵值對
m.Set("foo", "bar")
// 從m中獲取指定鍵值.
bar, ok := m.Get("foo")
// 洗掉鍵為“foo”的項
m.Remove("foo")
它的New方法創建了一個ConcurrentMap結構
type ConcurrentMap[K comparable, V any] struct {
shards []*ConcurrentMapShared[K, V]
sharding func(key K) uint32
}
我們看ConcurrentMap結構中的shards,是用來代表map分片之后的這些存盤分片ConcurrentMapShared,
而sharing這個匿名函式代表的是分配的hash函式,
而存盤分片是一個基礎的,帶有互斥鎖的map
type ConcurrentMapShared[K comparable, V any] struct {
items map[K]V
sync.RWMutex
}
所以看到這里我們其實心里明白了個七七八八了,再看下它的New/Set/Get的流程如下:
flowchart LR cmap.New --> 創建一個ConcurrentMap --> 初始化ConcurrentMapShared cmap.Set --> 根據需要設定的key查找對應的ConcurrentMapShared --> 加鎖寫分片中的map cmap.Get --> 根據需要查找的key找出對應分片ConcurrentMapShared --> 加讀鎖讀取分片中的map是的,基本原理就是如上圖所示,concurrent-map就是將一個大map拆分成若干個小map,然后用若干個小mutex 對這些小map進行保護,這樣,通過降低鎖的粒度提升并發程度,畢竟嘛,一個諸葛亮不如十個臭皮匠,
sync.Map
sync.Map是Golang標準庫中提供的一個并發安全的哈希表,它與常規的map相比,可以在多個goroutine并發訪問時,保證資料的安全性和一致性,
理解sync.Map,最關鍵就是理解Map結構,
type Map struct {
mu Mutex //互斥鎖,用于鎖定dirty map
//優先讀map,支持原子操作,注釋中有readOnly不是說read是只讀,而是它的結構體,read實際上有寫的操作
read atomic.Value // readOnly
// dirty是一個當前最新的map,允許讀寫
dirty map[any]*entry
// 主要記錄read讀取不到資料加鎖讀取read map以及dirty map的次數,當misses等于dirty的長度時,會將dirty復制到read
misses int
}
這里的sync.Map的邏輯還是比較復雜的,我們再看它的Store函式和Load函式,
func (m *Map) Store(key, value any)
func (m *Map) Load(key any) (value any, ok bool)
我們先把Store的代碼流程圖畫出來
flowchart TD Store-->判斷read中是否有key{判斷read中是否有key} 判斷read中是否有key{判斷read中是否有key}--有key-->在read中tryStore-->CompareAndSwapPointer-->原子替換read中對應指標 判斷read中是否有key{判斷read中是否有key}--沒有key-->加鎖-->判斷key的位置 判斷key的位置--在read中存在-->dirty中存入這對keyvalue-->read中原子替換指標-->解鎖 判斷key的位置--在read中不存在\n在dirty中存在-->dirty中原子替換指標-->解鎖 判斷key的位置--在read中不存在\n在dirty中不存在-->read中所有元素復制到dirty一份-->read中增加這個keyvalue-->dirty中增加這個keyvalue-->解鎖我們看下,這里面有幾個步驟是非常有細節的,
首先,第一次判斷read中是否有key的時候是沒有加鎖的,所以當第一次判斷結束后,一旦明確read中沒有key,要做后續的操作之前,先做一次加鎖操作,做完加鎖操作之后,又判斷了一次key是否在read中,這是為什么呢?其實是由于在加鎖這個操作的前后,map還是有可能有變化的,人不可能兩次踏入同一個河流,map也不可能在加鎖前后兩次都不變,所以這里必須進行二次判斷,這里可以說是非常細節了,
其次,在判斷read或者dirty中已經有key的時候,Store做的操作不是復制一份value到目標結構,而是使用原子替換atomic.StorePointer 來將目標map中key對應的value指標替換為引數value,為什么呢? - 這是極致的性能優化寫法,原子替換能減少一次值拷貝操作,做一次指標賦值就能替換拷貝記憶體操作,從這里我們也能理解為什么這個并發map會放在atomic包中,因為它的實作大量依賴atomic的原子操作,
同樣,我們將Load的代碼轉化為流程圖如下,
flowchart TD Load --> 判斷read中是否有key{判斷read中是否有key} 判斷read中是否有key{判斷read中是否有key}--有key-->直接回傳對應的value 判斷read中是否有key{判斷read中是否有key}--沒有key-->加鎖-->再次判斷read中是否有key{再次判斷read中是否有key} 再次判斷read中是否有key{再次判斷read中是否有key} --有key-->直接回傳對應的value 再次判斷read中是否有key{再次判斷read中是否有key} --沒有key-->回傳dirty中是否有key-->標記map的miss值加一-->如果miss值大于dirty的個數-->將dirty中的map通過指標切換到read-->dirty置空-->標記map的miss值為0從Load中我們大致能看出sync.Map的思路,
sync.Map內部使用兩個map,read和dirty,其實read的map的作用是擋在讀寫操作的第一個屏障,如果讀寫在這個read中能直接操作的話,我們就直接在read中讀寫,那么就可以完全避免使用鎖,性能自然就提升了,
而dirty的作用就相當于是一個緩沖區,一旦要寫的key在read中找不到,我們就會先寫dirty中,這個好處是什么?也是不去影響讀read的操作,不會出現并發讀寫一個資料結構的情況,
而什么時候dirty的快取清空同步到read中呢?就是“當map的miss標記大于dirty的個數的時候”,
這里我讀的時候也確實有這個疑問,為什么是“當miss標記個數大于dirty個數”,而不是當miss標記個數大于某個值呢?我是這么理解,miss是代表讀操作在read中失效的數量,而dirty個數代表寫操作在read中失效的數量,如果使用固定值來比對miss個數,那么這個固定值是不好定的,比如一個有10個key的map和一個有10000個key的map如果都是一樣的固定值,那是明顯不合適的,所以就找了這么個“浮動閾值”,
concurrent-map和sync.map的比較
我們再回到最開始的那一段話:
標準庫中的sync.Map是專為append-only場景設計的,因此,如果您想將Map用于一個類似記憶體資料庫,那么使用我們的版本可能會受益,你可以在golang repo上讀到更多,這里 and 這里 譯注:sync.Map在讀多寫少性能比較好,否則并發性能很差
通過以上的代碼分析,我們看出sync.Map的這個機制,是一個想追求無鎖讀寫的結構,它最好的運行方式是讀永遠都命中read,寫只命中dirty,這用能不用任何鎖機制就能做到map讀寫,而它最差的運行狀態是read和dirty不斷做替換和清理動作,性能就無法達到預期,而什么時候可能出現最差運行狀態呢?- 大量的寫操作和大量的讀操作,大量讀寫會導致“map的miss標記大于dirty的個數”, 這個時候sync.Map中第一層屏障會失效,dirty就會頻繁變動,
而current-map就相當于是一個比較中等中規中矩的方案,它的每次讀寫都會用到鎖,只是這個鎖的粒度比較小,它的最優運行方式是我們的所有并發讀寫都是分散在不同的hash切片中,它的最差運行方式就是我們所有的并發讀寫都集中在一個hash切片,但是按照實際運行邏輯,這兩種極端情況都不會發生,
所以總結下來,concurrent-map 的這段話確實沒有騙我們:
sync.Map在讀多寫少性能比較好,而concurrent-map 在key的hash度高的情況下性能比較好,
在無法確定讀寫比的情況下,建議使用 concurrent-map,
最后說一句:世上本沒有煩惱,選擇多了,便有了幸福的煩惱,
參考
https://segmentfault.com/a/1190000015242373
實時了解作者更多技術文章,技術心得,請關注微信公眾號“軒脈刃的刀光劍影”
本文基于署名-非商業性使用 3.0許可協議發布,歡迎轉載,演繹,但是必須保留本文的署名葉劍峰(包含鏈接http://www.cnblogs.com/yjf512/),且不得用于商業目的,如您有任何疑問或者授權方面的協商,請與我聯系,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/544540.html
標籤:Go
