這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
本文為轉載,原文連結
在Go 1.6之前, 內建的map類型是部分goroutine安全的,並發的讀沒有問題,並發的寫可能有問題。自go 1.6之後, 並發地讀寫map會報錯,這在一些知名的開源庫中都存在這個問題,所以go 1.9之前的解決方案是額外綁定一個鎖,封裝成一個新的struct或者單獨使用鎖都可以。
本文帶你深入到sync.Map的具體實現中,看看為了增加一個功能,代碼是如何變的複雜的,以及作者在實現sync.Map的一些思想。
有並發問題的map
官方的faq已經提到內建的map不是線程(goroutine)安全的。
首先,讓我們看一段並發讀寫的代碼,下列程式中一個goroutine一直讀,一個goroutine一隻寫同一個索引值,即即使讀寫的鍵不相同,而且map也沒有"擴容"等操作,代碼還是會報錯。
package mainfunc main() { m := make(map[int]int) go func() { for { _ = m[1] } }() go func() { for { m[2] = 2 } }() select {}}
錯誤資訊是: fatal error: concurrent map read and map write。
如果你查看Go的原始碼: hashmap_fast.go#L118,會看到讀的時候會檢查hashWriting標誌, 如果有這個標誌,就會報並發錯誤。
寫的時候會設定這個標誌: hashmap.go#L542
h.flags |= hashWriting
hashmap.go#L628設定完之後會取消這個標記。
當然,代碼中還有好幾處並發讀寫的檢查, 比如寫的時候也會檢查是不是有並發的寫,刪除鍵的時候類似寫,遍曆的時候並發讀寫問題等。
有時候,map的並發問題不是那麼容易被發現, 你可以利用-race參數來檢查。
Go 1.9之前的解決方案
但是,很多時候,我們會並發地使用map對象,尤其是在一定規模的項目中,map總會儲存goroutine共用的資料。在Go官方blog的Go maps in action一文中,提供了一種簡便的解決方案。
var counter = struct{ sync.RWMutex m map[string]int}{m: make(map[string]int)}
它使用嵌入struct為map增加一個讀寫鎖。
讀資料的時候很方便的加鎖:
counter.RLock()n := counter.m["some_key"]counter.RUnlock()fmt.Println("some_key:", n)
寫資料的時候:
unter.Lock()counter.m["some_key"]++counter.Unlock()
sync.Map
可以說,上面的解決方案相當簡潔,並且利用讀寫鎖而不是Mutex可以進一步減少讀寫的時候因為鎖帶來的效能。
但是,它在一些情境下也有問題,如果熟悉Java的同學,可以對比一下java的ConcurrentHashMap的實現,在map的資料非常大的情況下,一把鎖會導致大並發的用戶端共爭一把鎖,Java的解決方案是shard, 內部使用多個鎖,每個區間共用一把鎖,這樣減少了資料共用一把鎖帶來的效能影響,orcaman提供了這個思路的一個實現: concurrent-map,他也詢問了Go相關的開發人員是否在Go中也實現這種方案,由於實現的複雜性,答案是Yes, we considered it.,但是除非有特別的效能提升和應用情境,否則沒有進一步的開發訊息。
那麼,在Go 1.9中sync.Map是怎麼實現的呢?它是如何解決並發提升效能的呢?
sync.Map的實現有幾個最佳化點,這裡先列出來,我們後面慢慢分析。
空間換時間。 通過冗餘的兩個資料結構(read、dirty),實現加鎖對效能的影響。
使用唯讀資料(read),避免讀寫衝突。
動態調整,miss次數多了之後,將dirty資料提升為read。
double-checking。
延遲刪除。 刪除一個索引值只是打標記,只有在提升dirty的時候才清理刪除的資料。
優先從read讀取、更新、刪除,因為對read的讀取不需要鎖。
下面我們介紹sync.Map的重點代碼,以便理解它的實現思想。
首先,我們看一下sync.Map的資料結構:
type Map struct { // 當涉及到dirty資料的操作的時候,需要使用這個鎖 mu Mutex // 一個唯讀資料結構,因為唯讀,所以不會有讀寫衝突。 // 所以從這個資料中讀取總是安全的。 // 實際上,實際也會更新這個資料的entries,如果entry是未刪除的(unexpunged), 並不需要加鎖。如果entry已經被刪除了,需要加鎖,以便更新dirty資料。 read atomic.Value // readOnly // dirty資料包含當前的map包含的entries,它包含最新的entries(包括read中未刪除的資料,雖有冗餘,但是提升dirty欄位為read的時候非常快,不用一個一個的複製,而是直接將這個資料結構作為read欄位的一部分),有些資料還可能沒有移動到read欄位中。 // 對於dirty的操作需要加鎖,因為對它的操作可能會有讀寫競爭。 // 當dirty為空白的時候, 比如初始化或者剛提升完,下一次的寫操作會複製read欄位中未刪除的資料到這個資料中。 dirty map[interface{}]*entry // 當從Map中讀取entry的時候,如果read中不包含這個entry,會嘗試從dirty中讀取,這個時候會將misses加一, // 當misses累積到 dirty的長度的時候, 就會將dirty提升為read,避免從dirty中miss太多次。因為操作dirty需要加鎖。 misses int}
它的資料結構很簡單,值包含四個欄位:read、mu、dirty、misses。
它使用了冗餘的資料結構read、dirty。dirty中會包含read中為刪除的entries,新增加的entries會加入到dirty中。
read的資料結構是:
type readOnly struct { m map[interface{}]*entry amended bool // 如果Map.dirty有些資料不在中的時候,這個值為true}
amended指明Map.dirty中有readOnly.m未包含的資料,所以如果從Map.read找不到資料的話,還要進一步到Map.dirty中尋找。
對Map.read的修改是通過原子操作進行的。
雖然read和dirty有冗餘資料,但這些資料是通過指標指向同一個資料,所以儘管Map的value會很大,但是冗餘的空間佔用還是有限的。
readOnly.m和Map.dirty儲存的實值型別是*entry,它包含一個指標p, 指向使用者儲存的value值。
type entry struct { p unsafe.Pointer // *interface{}}
p有三種值:
nil: entry已被刪除了,並且m.dirty為nil
expunged: entry已被刪除了,並且m.dirty不為nil,而且這個entry不存在於m.dirty中
其它: entry是一個正常的值
以上是sync.Map的資料結構,下面我們重點看看Load、Store、Delete、Range這四個方法,其它輔助方法可以參考這四個方法來理解。
Load
載入方法,也就是提供一個鍵key,尋找對應的值value,如果不存在,通過ok反映:
func (m *Map) Load(key interface{}) (value interface{}, ok bool) { // 1.首先從m.read中得到唯讀readOnly,從它的map中尋找,不需要加鎖 read, _ := m.read.Load().(readOnly) e, ok := read.m[key] // 2. 如果沒找到,並且m.dirty中有新資料,需要從m.dirty尋找,這個時候需要加鎖 if !ok && read.amended { m.mu.Lock() // 雙檢查,避免加鎖的時候m.dirty提升為m.read,這個時候m.read可能被替換了。 read, _ = m.read.Load().(readOnly) e, ok = read.m[key] // 如果m.read中還是不存在,並且m.dirty中有新資料 if !ok && read.amended { // 從m.dirty尋找 e, ok = m.dirty[key] // 不管m.dirty中存不存在,都將misses計數加一 // missLocked()中滿足條件後就會提升m.dirty m.missLocked() } m.mu.Unlock() } if !ok { return nil, false } return e.load()}
這裡有兩個值的關注的地方。一個是首先從m.read中載入,不存在的情況下,並且m.dirty中有新資料,加鎖,然後從m.dirty中載入。
二是這裡使用了雙檢查的處理,因為在下面的兩個語句中,這兩行語句並不是一個原子操作。
if !ok && read.amended { m.mu.Lock()
雖然第一句執行的時候條件滿足,但是在加鎖之前,m.dirty可能被提升為m.read,所以加鎖後還得再檢查m.read,後續的方法中都使用了這個方法。
雙檢查的技術Java程式員非常熟悉了,單例模式的實現之一就是利用雙檢查的技術。
可以看到,如果我們查詢的索引值正好存在於m.read中,無須加鎖,直接返回,理論上效能優異。即使不存在於m.read中,經過miss幾次之後,m.dirty會被提升為m.read,又會從m.read中尋找。所以對於更新/增加較少,載入存在的key很多的case,效能基本和無鎖的map類似。
下面看看m.dirty是如何被提升的。 missLocked方法中可能會將m.dirty提升。
func (m *Map) missLocked() { m.misses++ if m.misses < len(m.dirty) { return } m.read.Store(readOnly{m: m.dirty}) m.dirty = nil m.misses = 0}
上面的最後三行代碼就是提升m.dirty的,很簡單的將m.dirty作為readOnly的m欄位,原子更新m.read。提升後m.dirty、m.misses重設, 並且m.read.amended為false。
Store
這個方法是更新或者新增一個entry。
func (m *Map) Store(key, value interface{}) { // 如果m.read存在這個鍵,並且這個entry沒有被標記刪除,嘗試直接儲存。 // 因為m.dirty也指向這個entry,所以m.dirty也保持最新的entry。 read, _ := m.read.Load().(readOnly) if e, ok := read.m[key]; ok && e.tryStore(&value) { return } // 如果`m.read`不存在或者已經被標記刪除 m.mu.Lock() read, _ = m.read.Load().(readOnly) if e, ok := read.m[key]; ok { if e.unexpungeLocked() { //標記成未被刪除 m.dirty[key] = e //m.dirty中不存在這個鍵,所以加入m.dirty } e.storeLocked(&value) //更新 } else if e, ok := m.dirty[key]; ok { // m.dirty存在這個鍵,更新 e.storeLocked(&value) } else { //新索引值 if !read.amended { //m.dirty中沒有新的資料,往m.dirty中增加第一個新鍵 m.dirtyLocked() //從m.read中複製未刪除的資料 m.read.Store(readOnly{m: read.m, amended: true}) } m.dirty[key] = newEntry(value) //將這個entry加入到m.dirty中 } m.mu.Unlock()}func (m *Map) dirtyLocked() { if m.dirty != nil { return } read, _ := m.read.Load().(readOnly) m.dirty = make(map[interface{}]*entry, len(read.m)) for k, e := range read.m { if !e.tryExpungeLocked() { m.dirty[k] = e } }}func (e *entry) tryExpungeLocked() (isExpunged bool) { p := atomic.LoadPointer(&e.p) for p == nil { // 將已經刪除標記為nil的資料標記為expunged if atomic.CompareAndSwapPointer(&e.p, nil, expunged) { return true } p = atomic.LoadPointer(&e.p) } return p == expunged}
你可以看到,以上操作都是先從操作m.read開始的,不滿足條件再加鎖,然後操作m.dirty。
Store可能會在某種情況下(初始化或者m.dirty剛被提升後)從m.read中複製資料,如果這個時候m.read中資料量非常大,可能會影響效能。
Delete
刪除一個索引值。
func (m *Map) Delete(key interface{}) { read, _ := m.read.Load().(readOnly) e, ok := read.m[key] if !ok && read.amended { m.mu.Lock() read, _ = m.read.Load().(readOnly) e, ok = read.m[key] if !ok && read.amended { delete(m.dirty, key) } m.mu.Unlock() } if ok { e.delete() }}
同樣,刪除操作還是從m.read中開始, 如果這個entry不存在於m.read中,並且m.dirty中有新資料,則加鎖嘗試從m.dirty中刪除。
注意,還是要雙檢查的。 從m.dirty中直接刪除即可,就當它沒存在過,但是如果是從m.read中刪除,並不會直接刪除,而是打標記:
func (e *entry) delete() (hadValue bool) { for { p := atomic.LoadPointer(&e.p) // 已標記為刪除 if p == nil || p == expunged { return false } // 原子操作,e.p標記為nil if atomic.CompareAndSwapPointer(&e.p, p, nil) { return true } }}
Range
因為for ... range map是內建的語言特性,所以沒有辦法使用for range遍曆sync.Map, 但是可以使用它的Range方法,通過回調的方式遍曆。
func (m *Map) Range(f func(key, value interface{}) bool) { read, _ := m.read.Load().(readOnly) // 如果m.dirty中有新資料,則提升m.dirty,然後在遍曆 if read.amended { //提升m.dirty m.mu.Lock() read, _ = m.read.Load().(readOnly) //雙檢查 if read.amended { read = readOnly{m: m.dirty} m.read.Store(read) m.dirty = nil m.misses = 0 } m.mu.Unlock() } // 遍曆, for range是安全的 for k, e := range read.m { v, ok := e.load() if !ok { continue } if !f(k, v) { break } }}
Range方法調用前可能會做一個m.dirty的提升,不過提升m.dirty不是一個耗時的操作。
sync.Map的效能
Go 1.9原始碼中提供了效能的測試: map_bench_test.go、map_reference_test.go
我也基於這些代碼修改了一下,得到下面的測試資料,相比較以前的解決方案,效能多少回有些提升,如果你特別關注效能,可以考慮sync.Map。
BenchmarkHitAll/*sync.RWMutexMap-4 20000000 83.8 ns/opBenchmarkHitAll/*sync.Map-4 30000000 59.9 ns/opBenchmarkHitAll_WithoutPrompting/*sync.RWMutexMap-4 20000000 96.9 ns/opBenchmarkHitAll_WithoutPrompting/*sync.Map-4 20000000 64.1 ns/opBenchmarkHitNone/*sync.RWMutexMap-4 20000000 79.1 ns/opBenchmarkHitNone/*sync.Map-4 30000000 43.3 ns/opBenchmarkHit_WithoutPrompting/*sync.RWMutexMap-4 20000000 81.5 ns/opBenchmarkHit_WithoutPrompting/*sync.Map-4 30000000 44.0 ns/opBenchmarkUpdate/*sync.RWMutexMap-4 5000000 328 ns/opBenchmarkUpdate/*sync.Map-4 10000000 146 ns/opBenchmarkUpdate_WithoutPrompting/*sync.RWMutexMap-4 5000000 336 ns/opBenchmarkUpdate_WithoutPrompting/*sync.Map-4 5000000 324 ns/opBenchmarkDelete/*sync.RWMutexMap-4 10000000 155 ns/opBenchmarkDelete/*sync.Map-4 30000000 55.0 ns/opBenchmarkDelete_WithoutPrompting/*sync.RWMutexMap-4 10000000 173 ns/opBenchmarkDelete_WithoutPrompting/*sync.Map-4 10000000 147 ns/op
其它
sync.Map沒有Len方法,並且目前沒有跡象要加上 (issue#20680),所以如果想得到當前Map中有效entries的數量,需要使用Range方法遍曆一次, 比較X疼。
LoadOrStore方法如果提供的key存在,則返回已存在的值(Load),否則儲存提供的索引值(Store)。