在 golang中,想要併發安全的操作map,可以使用sync.Map結構,sync.Map 是一個適合讀多寫少的資料結構,今天我們來看看它的設計思想,來看看為什麼說它適合讀多寫少的場景。
如下,是golang 中sync.Map的資料結構,其中 屬性read 是 只讀的 map,dirty 是負責寫入的map,sync.Map中的鍵值對value值本質上都是entry指標型別,entry中的p才指向了實際儲存的value值
。
// sync.Map的核心資料結構
type Map struct {
mu Mutex // 對 dirty 加鎖保護,執行緒安全
read atomic.Value // read 只讀的 map,充當快取層
dirty map[interface{}]*entry // 負責寫操作的 map,當misses = len(dirty)時,將其賦值給read
misses int // 未命中 read 時的累加計數,每次+1
}
// 上面read欄位的資料結構
type readOnly struct {
m map[interface{}]*entry //
amended bool // Map.dirty的資料和這裡read中 m 的資料不一樣時,為true
}
// 上面m欄位中的entry型別
type entry struct {
// value是個指標型別
p unsafe.Pointer // *interface{}
}
我們從一個sync.Map的資料寫入和資料查詢 兩個過程來分析這兩個map中資料的變化。
我將不展示具體的程式碼,僅僅講述資料的流動,相信懂了這個以後再去看程式碼應該不難。
步驟一: 首先是一個初始的sync.Map 結構,我們往其中寫入資料,資料會寫到dirty中,同時,由於sync.Map 剛剛建立,所以read map還不存在,所以這裡會先初始化一個read map 。amended
是read map中的一個屬性,為true代表 dirty 和read中資料不一致。
步驟二: 接著,如果後續再繼續寫入新資料,
在read map沒有從dirty 同步資料之前,即amended
變為false之前,再寫入新鍵值對都只會往dirty裡寫。
步驟三: 如果有讀操作,sync.Map 都會盡可能的讓其先讀read map,read map讀取不到並且amended
為true,即read 和dirty 資料不一致時,會去讀dirty,讀dirty的過程是上鎖的。
步驟四: 當讀取read map中miss次數大於等於dirty陣列的長度時,會觸發dirty map整體更新為readOnly map,並且這個過程是阻塞的。更新完成後,原先dirty會被置為空,amended
為false,代表read map同步了之前所有的資料。如下圖所示,
整體更新的邏輯是直接替換變數的值,並非挨個複製,
func (m *Map) missLocked() {
m.misses++
if m.misses < len(m.dirty) {
return
}
// 將dirty置給read,因為穿透機率太大了(原子操作,耗時很小)
m.read.Store(readOnly{m: m.dirty})
m.dirty = nil
m.misses = 0
}
步驟五: 如果後續sync.Map 不再插入新資料
,那麼讀取時就可以一直讀取read map中的資料了,直接讀取read map 中的key是十分高效的,只需要用atomic.Load 操作 取到readOnly map結構體,然後從中取出特定的key就行。
如果讀miss了,因為沒有插入新資料,read.amended=false
代表read 是儲存了所有的k,v鍵值對,讀miss後,也不會再去讀取dirty了,也就不會有讀dirty加鎖的過程。
// 上面read欄位的資料結構
type readOnly struct {
m map[interface{}]*entry //
amended bool // Map.dirty的資料和這裡read中 m 的資料不一樣時,為true
}
func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
// 因read只讀,執行緒安全,優先讀取
read, _ := m.read.Load().(readOnly)
e, ok := read.m[key]
// 如果read沒有,並且dirty有新資料,那麼去dirty中查詢(read.amended=true:dirty和read資料不一致)
// 暫時省略 後續程式碼
.......
}
上面的獲取key對應的value過程甚至比RWMutex 讀鎖下獲取map中的value還要高效,畢竟RWmutex 讀取時還需要加上讀鎖,其底層是用atomic.AddInt32
操作,而sync.Map 則是用 atomic.load
獲取map,atomic.AddInt32
的開銷比atomic.load
的開銷要大。
📢📢📢,所以,為什麼我們說golang的sync.Map 在大量讀的情況下效能極佳,因為在整個讀取過程中沒有鎖開銷,
atomic.load
原子操作消耗極低。
但是如果後續又寫入了新的鍵值對資料
,那麼 dirty map中就會又插入到新的鍵值對,dirty和read的資料又不一致了,read 的amended
將改為true。
並且由於之前dirty整體更新為read後,dirty欄位置為nil了,所以,在更改amended時,也會將read中的所有未被刪除的key同步到 dirty中。
📢📢📢注意,為什麼在dirty整體更新一次read map後,再寫入新的鍵值對時,需要將read map中的資料全部同步到dirty,因為隨著dirty的慢慢寫入,後續讀操作又會造成讀miss的增加,最終會再次觸發dirty map整體更新為readOnly map,amended
改為false,代表read map中又有所有鍵值對資料了,也就是會回到步驟三的操作,重複步驟三到步驟五的過程。
只有將read map中的資料全部同步到dirty ,才能保證後續的整體更新,不會造成丟失資料。
看到這裡應該能夠明白sync.Map的適合場景了,我來總結下,
sync.Map 適合讀多寫少的場景,大量的讀操作可以透過只讀取read map 擁有極好的效能。
而如果寫操作增加,首先會造成read map中讀取miss增加,會回源到dirty中讀取,且dirty可能會頻繁整體更新為read,回源讀取,整體更新的步驟都是阻塞上鎖的。
其次,寫操作也會帶來dirty和 read中資料頻繁的不一致,導致read中的資料需要同步到dirty中,這個過程在鍵值對比較多時,效能損耗較大且整個過程是阻塞的。
所以sync.Map 並不適合大量寫操作。