concurrent-map 和 sync.Map,我該選擇哪個(gè)?
官方的map并不是線程安全的,如果我們?cè)诙嗑€程中并發(fā)對(duì)一個(gè)map進(jìn)行讀寫操作,是會(huì)引發(fā)panic的。解決方案除了使用鎖來對(duì)map進(jìn)行保護(hù)外,還有兩種方式:
一,開源項(xiàng)目 concurrent-map 提供了可以用來做并發(fā)安全的map
二,Go1.9之后,標(biāo)準(zhǔn)庫提供了一個(gè)sync.Map
這兩種并發(fā)安全的map,我們應(yīng)該怎么選擇呢?
在concurrent-map我看到這么一段話:
標(biāo)準(zhǔn)庫中的sync.Map是專為append-only場(chǎng)景設(shè)計(jì)的。因此,如果您想將Map用于一個(gè)類似內(nèi)存數(shù)據(jù)庫,那么使用我們的版本可能會(huì)受益。你可以在golang repo上讀到更多,這里 and 這里 譯注:sync.Map在讀多寫少性能比較好,否則并發(fā)性能很差
concurrent-map為什么會(huì)有這種表述呢?這篇文章就來庖丁解牛下。
concurrent-map
concurrent-map
是Golang中一個(gè)流行的并發(fā)安全的哈希表庫,它允許多個(gè)goroutine同時(shí)對(duì)哈希表進(jìn)行讀寫操作,而不需要使用顯式的鎖或同步原語。
該庫的核心原理是使用分片鎖,將哈希表分成多個(gè)小的哈希表片段,并為每個(gè)片段分配一個(gè)獨(dú)立的鎖。當(dāng)多個(gè)goroutine嘗試同時(shí)讀寫同一個(gè)片段時(shí),只有該片段上的鎖會(huì)被鎖住,而其他片段的鎖則不受影響,從而避免了整個(gè)哈希表被鎖住的情況。
當(dāng)進(jìn)行寫操作時(shí),只需要鎖住要寫入的片段的鎖,以確保原子性操作。當(dāng)進(jìn)行讀操作時(shí),則不需要鎖住片段的鎖,只需要對(duì)該片段上的讀取操作進(jìn)行同步即可。
此外,concurrent-map
庫還使用了一些優(yōu)化策略,如緩存哈希值和桶的地址,以減少計(jì)算和查找時(shí)間,從而提高并發(fā)讀寫性能。
總之,concurrent-map
庫的原理是基于分片鎖和其他優(yōu)化策略來實(shí)現(xiàn)高效的并發(fā)安全哈希表。
我們先看它的使用方式:
// 創(chuàng)建一個(gè)新的 map.
m := cmap.New[string]() // 設(shè)置變量m一個(gè)鍵為“foo”值為“bar”鍵值對(duì)
m.Set("foo", "bar") // 從m中獲取指定鍵值.
bar, ok := m.Get("foo") // 刪除鍵為“foo”的項(xiàng)
m.Remove("foo")
它的New方法創(chuàng)建了一個(gè)ConcurrentMap結(jié)構(gòu)
type ConcurrentMap[K comparable, V any] struct { shards ? []*ConcurrentMapShared[K, V] sharding func(key K) uint32}
我們看ConcurrentMap結(jié)構(gòu)中的shards,是用來代表map分片之后的這些存儲(chǔ)分片ConcurrentMapShared。
而sharing這個(gè)匿名函數(shù)代表的是分配的hash函數(shù)。
而存儲(chǔ)分片是一個(gè)基礎(chǔ)的,帶有互斥鎖的map
type ConcurrentMapShared[K comparable, V any] struct { ? items ? ? ? ?map[K]V
? sync.RWMutex }
所以看到這里我們其實(shí)心里明白了個(gè)七七八八了,再看下它的New/Set/Get的流程如下:
cmap.New
創(chuàng)建一個(gè)ConcurrentMap
初始化ConcurrentMapShared
cmap.Set
根據(jù)需要設(shè)置的key查找對(duì)應(yīng)的ConcurrentMapShared
加鎖寫分片中的map
cmap.Get
根據(jù)需要查找的key找出對(duì)應(yīng)分片ConcurrentMapShared
加讀鎖讀取分片中的map
是的,基本原理就是如上圖所示。concurrent-map就是將一個(gè)大map拆分成若干個(gè)小map,然后用若干個(gè)小mutex 對(duì)這些小map進(jìn)行保護(hù)。這樣,通過降低鎖的粒度提升并發(fā)程度。畢竟嘛,一個(gè)諸葛亮不如十個(gè)臭皮匠。
sync.Map
sync.Map
是Golang標(biāo)準(zhǔn)庫中提供的一個(gè)并發(fā)安全的哈希表,它與常規(guī)的map相比,可以在多個(gè)goroutine并發(fā)訪問時(shí),保證數(shù)據(jù)的安全性和一致性。
理解sync.Map,最關(guān)鍵就是理解Map結(jié)構(gòu)。
type Map struct {
mu Mutex //互斥鎖,用于鎖定dirty map
//優(yōu)先讀map,支持原子操作,注釋中有readOnly不是說read是只讀,而是它的結(jié)構(gòu)體。read實(shí)際上有寫的操作 read atomic.Value // readOnly
// dirty是一個(gè)當(dāng)前最新的map,允許讀寫
dirty map[any]*entry
// 主要記錄read讀取不到數(shù)據(jù)加鎖讀取read map以及dirty map的次數(shù),當(dāng)misses等于dirty的長(zhǎng)度時(shí),會(huì)將dirty復(fù)制到read
misses int}
這里的sync.Map的邏輯還是比較復(fù)雜的。我們?cè)倏此腟tore函數(shù)和Load函數(shù)。
func (m *Map) Store(key, value any)
func (m *Map) Load(key any) (value any, ok bool)
我們先把Store的代碼流程圖畫出來
有key
沒有key
在read中存在
在read中不存在
在dirty中存在
在read中不存在
在dirty中不存在
Store
判斷read中是否有key
在read中tryStore
CompareAndSwapPointer
原子替換read中對(duì)應(yīng)指針
加鎖
判斷key的位置
dirty中存入這對(duì)keyvalue
read中原子替換指針
解鎖
dirty中原子替換指針
read中所有元素復(fù)制到dirty一份
read中增加這個(gè)keyvalue
dirty中增加這個(gè)keyvalue
我們看下,這里面有幾個(gè)步驟是非常有細(xì)節(jié)的。
首先,第一次判斷read中是否有key的時(shí)候是沒有加鎖的,所以當(dāng)?shù)谝淮闻袛嘟Y(jié)束后,一旦明確read中沒有key,要做后續(xù)的操作之前,先做一次加鎖操作,做完加鎖操作之后,又判斷了一次key是否在read中。這是為什么呢?其實(shí)是由于在加鎖這個(gè)操作的前后,map還是有可能有變化的,人不可能兩次踏入同一個(gè)河流,map也不可能在加鎖前后兩次都不變,所以這里必須進(jìn)行二次判斷,這里可以說是非常細(xì)節(jié)了。
其次,在判斷read或者dirty中已經(jīng)有key的時(shí)候,Store做的操作不是復(fù)制一份value到目標(biāo)結(jié)構(gòu),而是使用原子替換atomic.StorePointer 來將目標(biāo)map中key對(duì)應(yīng)的value指針替換為參數(shù)value。為什么呢? - 這是極致的性能優(yōu)化寫法,原子替換能減少一次值拷貝操作,做一次指針賦值就能替換拷貝內(nèi)存操作。從這里我們也能理解為什么這個(gè)并發(fā)map會(huì)放在atomic包中,因?yàn)樗膶?shí)現(xiàn)大量依賴atomic的原子操作。
同樣,我們將Load的代碼轉(zhuǎn)化為流程圖如下,
有key
沒有key
有key
沒有key
Load
判斷read中是否有key
直接返回對(duì)應(yīng)的value
加鎖
再次判斷read中是否有key
返回dirty中是否有key
標(biāo)記map的miss值加一
如果miss值大于dirty的個(gè)數(shù)
將dirty中的map通過指針切換到read
dirty置空
標(biāo)記map的miss值為0
從Load中我們大致能看出sync.Map的思路。
sync.Map內(nèi)部使用兩個(gè)map,read和dirty。其實(shí)read的map的作用是擋在讀寫操作的第一個(gè)屏障。如果讀寫在這個(gè)read中能直接操作的話,我們就直接在read中讀寫,那么就可以完全避免使用鎖,性能自然就提升了。
而dirty的作用就相當(dāng)于是一個(gè)緩沖區(qū),一旦要寫的key在read中找不到,我們就會(huì)先寫dirty中。這個(gè)好處是什么?也是不去影響讀read的操作,不會(huì)出現(xiàn)并發(fā)讀寫一個(gè)數(shù)據(jù)結(jié)構(gòu)的情況。
而什么時(shí)候dirty的緩存清空同步到read中呢?就是“當(dāng)map的miss標(biāo)記大于dirty的個(gè)數(shù)的時(shí)候”。
這里我讀的時(shí)候也確實(shí)有這個(gè)疑問,為什么是“當(dāng)miss標(biāo)記個(gè)數(shù)大于dirty個(gè)數(shù)”。而不是當(dāng)miss標(biāo)記個(gè)數(shù)大于某個(gè)值呢?我是這么理解,miss是代表讀操作在read中失效的數(shù)量,而dirty個(gè)數(shù)代表寫操作在read中失效的數(shù)量。如果使用固定值來比對(duì)miss個(gè)數(shù),那么這個(gè)固定值是不好定的,比如一個(gè)有10個(gè)key的map和一個(gè)有10000個(gè)key的map如果都是一樣的固定值,那是明顯不合適的。所以就找了這么個(gè)“浮動(dòng)閾值”。
concurrent-map和sync.map的比較
我們?cè)倩氐阶铋_始的那一段話:
標(biāo)準(zhǔn)庫中的sync.Map是專為append-only場(chǎng)景設(shè)計(jì)的。因此,如果您想將Map用于一個(gè)類似內(nèi)存數(shù)據(jù)庫,那么使用我們的版本可能會(huì)受益。你可以在golang repo上讀到更多,這里 and 這里 譯注:sync.Map在讀多寫少性能比較好,否則并發(fā)性能很差
通過以上的代碼分析,我們看出sync.Map的這個(gè)機(jī)制,是一個(gè)想追求無鎖讀寫的結(jié)構(gòu),它最好的運(yùn)行方式是讀永遠(yuǎn)都命中read,寫只命中dirty,這用能不用任何鎖機(jī)制就能做到map讀寫。而它最差的運(yùn)行狀態(tài)是read和dirty不斷做替換和清理動(dòng)作,性能就無法達(dá)到預(yù)期。而什么時(shí)候可能出現(xiàn)最差運(yùn)行狀態(tài)呢?- 大量的寫操作和大量的讀操作。大量讀寫會(huì)導(dǎo)致“map的miss標(biāo)記大于dirty的個(gè)數(shù)”。 這個(gè)時(shí)候sync.Map中第一層屏障會(huì)失效,dirty就會(huì)頻繁變動(dòng)。
而current-map就相當(dāng)于是一個(gè)比較中等中規(guī)中矩的方案。它的每次讀寫都會(huì)用到鎖,只是這個(gè)鎖的粒度比較小。它的最優(yōu)運(yùn)行方式是我們的所有并發(fā)讀寫都是分散在不同的hash切片中。它的最差運(yùn)行方式就是我們所有的并發(fā)讀寫都集中在一個(gè)hash切片。但是按照實(shí)際運(yùn)行邏輯,這兩種極端情況都不會(huì)發(fā)生。
所以總結(jié)下來,concurrent-map 的這段話確實(shí)沒有騙我們:
sync.Map在讀多寫少性能比較好,而concurrent-map 在key的hash度高的情況下性能比較好。
在無法確定讀寫比的情況下,建議使用 concurrent-map。
最后說一句:世上本沒有煩惱,選擇多了,便有了幸福的煩惱。