Go 并發(fā)讀寫 sync.map 詳細
map 的兩種目前在業(yè)界使用的最多的并發(fā)支持的模式分別是:
- 原生
map +
互斥鎖或讀寫鎖mutex
。 - 標準庫
sync.Map
(Go1.9及以后)。
有了選擇,總是有選擇困難癥的,這兩種到底怎么選,誰的性能更加的好?我有一個朋友說 標準庫 sync.Map
性能菜的很,不要用。我到底聽誰的...
今天煎魚就帶你揭秘 Go sync.map
,我們先會了解清楚什么場景下,Go map
的多種類型怎么用,誰的性能最好!
接著根據(jù)各 map
性能分析的結(jié)果,針對性的對 sync.map
進行源碼解剖,了解 WHY。
一起愉快地開始吸魚之路。
1、sync.Map 優(yōu)勢
在 Go 官方文檔中明確指出 Map 類型的一些建議:
- 多個
goroutine
的并發(fā)使用是安全的,不需要額外的鎖定或協(xié)調(diào)控制。 - 大多數(shù)代碼應(yīng)該使用原生的
map
,而不是單獨的鎖定或協(xié)調(diào)控制,以獲得更好的類型安全性和維護性。
同時 Map
類型,還針對以下場景進行了性能優(yōu)化:
- 當一個給定的鍵的條目只被寫入一次但被多次讀取時。例如在僅會增長的緩存中,就會有這種業(yè)務(wù)場景。
- 當多個
goroutines
讀取、寫入和覆蓋不相干的鍵集合的條目時。
這兩種情況與 Go map
搭配單獨的 Mutex
或 RWMutex
相比較,使用 Map
類型可以大大減少鎖的爭奪。
2、性能測試
聽官方文檔介紹了一堆好處后,他并沒有講到缺點,所說的性能優(yōu)化后的優(yōu)勢又是否真實可信。我們一起來驗證一下。
首先我們定義基本的數(shù)據(jù)結(jié)構(gòu):
// 代表互斥鎖 type FooMap struct { sync.Mutex data map[int]int } // 代表讀寫鎖 type BarRwMap struct { sync.RWMutex data map[int]int } var fooMap *FooMap var barRwMap *BarRwMap var syncMap *sync.Map // 初始化基本數(shù)據(jù)結(jié)構(gòu) func init() { fooMap = &FooMap{data: make(map[int]int, 100)} barRwMap = &BarRwMap{data: make(map[int]int, 100)} syncMap = &sync.Map{} }
在配套方法上,常見的增刪改查動作我們都編寫了相應(yīng)的方法。用于后續(xù)的壓測(只展示部分代碼):
func builtinRwMapStore(k, v int) { barRwMap.Lock() defer barRwMap.Unlock() barRwMap.data[k] = v } func builtinRwMapLookup(k int) int { barRwMap.RLock() defer barRwMap.RUnlock() if v, ok := barRwMap.data[k]; !ok { return -1 } else { return v } } func builtinRwMapDelete(k int) { barRwMap.Lock() defer barRwMap.Unlock() if _, ok := barRwMap.data[k]; !ok { return } else { delete(barRwMap.data, k) } }
其余的類型方法基本類似,考慮重復(fù)篇幅問題因此就不在此展示了。
壓測方法基本代碼如下:
func BenchmarkBuiltinRwMapDeleteParalell(b *testing.B) { b.RunParallel(func(pb *testing.PB) { r := rand.New(rand.NewSource(time.Now().Unix())) for pb.Next() { k := r.Intn(100000000) builtinRwMapDelete(k) } }) }
這塊主要就是增刪改查的代碼和壓測方法的準備,壓測代碼直接復(fù)用的是大白大佬的 go19-examples/benchmark-for-map
項目。
也可以使用 Go 官方提供的 map\_bench\_test.go,有興趣的小伙伴可以自己拉下來運行試一下。
2.1 壓測結(jié)果
1)寫入
名 | 含義 | 壓測結(jié)果 |
---|---|---|
BenchmarkBuiltinMapStoreParalell-4 | map+mutex 寫入元素 | 237.1 ns/op |
BenchmarkSyncMapStoreParalell-4 | sync.map 寫入元素 | 509.3 ns/op |
BenchmarkBuiltinRwMapStoreParalell-4 | map+rwmutex 寫入元素 | 207.8 ns/op |
總體的排序(從慢到快)為:SyncMapStore < MapStore < RwMapStore。
2)查找
方法名 | 含義 | 壓測結(jié)果 |
---|---|---|
BenchmarkBuiltinMapLookupParalell-4 | map+mutex 查找元素 | 166.7 ns/op |
BenchmarkBuiltinRwMapLookupParalell-4 | map+rwmutex 查找元素 | 60.49 ns/op |
BenchmarkSyncMapLookupParalell-4 | sync.map 查找元素 | 53.39 ns/op |
在查找元素上,最慢的是原生 map+互斥鎖,其次是原生 map+讀寫鎖。最快的是 sync.map
類型。
總體的排序為:MapLookup < RwMapLookup < SyncMapLookup。
3)刪除
方法名 | 含義 | 壓測結(jié)果 |
---|---|---|
BenchmarkBuiltinMapDeleteParalell-4 | map+mutex 刪除元素 | 168.3 ns/op |
BenchmarkBuiltinRwMapDeleteParalell-4 | map+rwmutex 刪除元素 | 188.5 ns/op |
BenchmarkSyncMapDeleteParalell-4 | sync.map 刪除元素 | 41.54 ns/op |
在刪除元素上,最慢的是原生 map+
讀寫鎖,其次是原生 map+
互斥鎖,最快的是 sync.map
類型。
總體的排序為:RwMapDelete < MapDelete < SyncMapDelete
。
2.3 場景分析
根據(jù)上述的壓測結(jié)果,我們可以得出 sync.Map
類型:
- 在讀和刪場景上的性能是最佳的,領(lǐng)先一倍有多。
- 在寫入場景上的性能非常差,落后原生 map+鎖整整有一倍之多。
因此在實際的業(yè)務(wù)場景中。假設(shè)是讀多寫少的場景,會更建議使用 sync.Map 類型。
但若是那種寫多的場景,例如多 goroutine
批量的循環(huán)寫入,那就建議另辟途徑了,性能不忍直視(無性能要求另當別論)。
3、sync.Map 剖析
清楚如何測試,測試的結(jié)果后。我們需要進一步深挖,知其所以然。
為什么 sync.Map
類型的測試結(jié)果這么的 “偏科”,為什么讀操作性能這么高,寫操作性能低的可怕,他是怎么設(shè)計的?
3.1 數(shù)據(jù)結(jié)構(gòu)
sync.Map
類型的底層數(shù)據(jù)結(jié)構(gòu)如下:
type Map struct { mu Mutex read atomic.Value // readOnly dirty map[interface{}]*entry misses int } // Map.read 屬性實際存儲的是 readOnly。 type readOnly struct { m map[interface{}]*entry amended bool }
mu
:互斥鎖,用于保護read
和dirty
。read
:只讀數(shù)據(jù),支持并發(fā)讀?。?code>atomic.Value 類型)。如果涉及到更新操作,則只需要加鎖來保證數(shù)據(jù)安全。read
實際存儲的是readOnly
結(jié)構(gòu)體,內(nèi)部也是一個原生map
,amended
屬性用于標記read
和dirty
的數(shù)據(jù)是否一致。dirty
:讀寫數(shù)據(jù),是一個原生map
,也就是非線程安全。操作dirty
需要加鎖來保證數(shù)據(jù)安全。misses
:統(tǒng)計有多少次讀取read
沒有命中。每次read
中讀取失敗后,misses
的計數(shù)值都會加 1。
在 read
和 dirty
中,都有涉及到的結(jié)構(gòu)體:
type entry struct { p unsafe.Pointer // *interface{} }
其包含一個指針 p, 用于指向用戶存儲的元素(key)所指向的 value
值。
在此建議你必須搞懂 read
、dirty
、entry
,再往下看,食用效果會更佳,后續(xù)會圍繞著這幾個概念流轉(zhuǎn)。
3.2 查找過程
劃重點,Map
類型本質(zhì)上是有兩個 “map
”。一個叫 read
、一個叫 dirty
,長的也差不多:
sync.Map 的 2 個 map
當我們從 sync.Map 類型中讀取數(shù)據(jù)時,其會先查看 read 中是否包含所需的元素:
- 若有,則通過
atomic
原子操作讀取數(shù)據(jù)并返回。 - 若無,則會判斷
read.readOnly
中的amended
屬性,他會告訴程序 dirty 是否包含read.readOnly.m
中沒有的數(shù)據(jù);因此若存在,也就是amended
為 true,將會進一步到 dirty 中查找數(shù)據(jù)。
sync.Map
的讀操作性能如此之高的原因,就在于存在 read 這一巧妙的設(shè)計,其作為一個緩存層,提供了快路徑(fast path)的查找。
同時其結(jié)合 amended
屬性,配套解決了每次讀取都涉及鎖的問題,實現(xiàn)了讀這一個使用場景的高性能。
3.3 寫入過程
我們直接關(guān)注 sync.Map
類型的 Store
方法,該方法的作用是新增或更新一個元素。
源碼如下:
func (m *Map) Store(key, value interface{}) { read, _ := m.read.Load().(readOnly) if e, ok := read.m[key]; ok && e.tryStore(&value) { return } ... }
調(diào)用 Load
方法檢查 m.read
中是否存在這個元素。若存在,且沒有被標記為刪除狀態(tài),則嘗試存儲。
若該元素不存在或已經(jīng)被標記為刪除狀態(tài),則繼續(xù)走到下面流程:
func (m *Map) Store(key, value interface{}) { ... m.mu.Lock() read, _ = m.read.Load().(readOnly) if e, ok := read.m[key]; ok { if e.unexpungeLocked() { m.dirty[key] = e } e.storeLocked(&value) } else if e, ok := m.dirty[key]; ok { e.storeLocked(&value) } else { if !read.amended { m.dirtyLocked() m.read.Store(readOnly{m: read.m, amended: true}) } m.dirty[key] = newEntry(value) } m.mu.Unlock() }
由于已經(jīng)走到了 dirty
的流程,因此開頭就直接調(diào)用了 Lock
方法上互斥鎖,保證數(shù)據(jù)安全,也是凸顯性能變差的第一幕。
其分為以下三個處理分支:
- 若發(fā)現(xiàn) read 中存在該元素,但已經(jīng)被標記為已刪除(
expunged
),則說明dirty
不等于nil
(dirty 中肯定不存在該元素)。其將會執(zhí)行如下操作。 - 將元素狀態(tài)從已刪除(
expunged
)更改為 nil。 - 將元素插入 dirty 中。
- 若發(fā)現(xiàn) read 中不存在該元素,但 dirty 中存在該元素,則直接寫入更新
entry
的指向。 - 若發(fā)現(xiàn)
read
和dirty
都不存在該元素,則從read
中復(fù)制未被標記刪除的數(shù)據(jù),并向dirty
中插入該元素,賦予元素值 entry 的指向。
我們理一理,寫入過程的整體流程就是:
- 查
read
,read
上沒有,或者已標記刪除狀態(tài)。 - 上互斥鎖(Mutex)。
- 操作 dirty,根據(jù)各種數(shù)據(jù)情況和狀態(tài)進行處理。
回到最初的話題,為什么他寫入性能差那么多。究其原因:
- 寫入一定要會經(jīng)過
read
,無論如何都比別人多一層,后續(xù)還要查數(shù)據(jù)情況和狀態(tài),性能開銷相較更大。 - (第三個處理分支)當初始化或者
dirty
被提升后,會從read
中復(fù)制全量的數(shù)據(jù),若 read 中數(shù)據(jù)量大,則會影響性能。
可得知 sync.Map
類型不適合寫多的場景,讀多寫少是比較好的。
若有大數(shù)據(jù)量的場景,則需要考慮 read 復(fù)制數(shù)據(jù)時的偶然性能抖動是否能夠接受。
3.4 刪除過程
這時候可能有小伙伴在想了。寫入過程,理論上和刪除不會差太遠。怎么 sync.Map
類型的刪除的性能似乎還行,這里面有什么貓膩?
源碼如下:
func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool) { read, _ := m.read.Load().(readOnly) e, ok := read.m[key] ... if ok { return e.delete() } }
刪除是標準的開場,依然先到 read
檢查該元素是否存在。
若存在,則調(diào)用 delete
標記為 expunged
(刪除狀態(tài)),非常高效。可以明確在 read
中的元素,被刪除,性能是非常好的。
若不存在,也就是走到 dirty 流程中:
func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool) { ... if !ok && read.amended { m.mu.Lock() read, _ = m.read.Load().(readOnly) e, ok = read.m[key] if !ok && read.amended { e, ok = m.dirty[key] delete(m.dirty, key) m.missLocked() } m.mu.Unlock() } ... return nil, false }
若 read
中不存在該元素,dirty
不為空,read
與 dirty
不一致(利用 amended
判別),則表明要操作 dirty
,上互斥鎖。
再重復(fù)進行雙重檢查,若 read
仍然不存在該元素。則調(diào)用 delete
方法從 dirty
中標記該元素的刪除。
需要注意,出現(xiàn)頻率較高的 delete 方法:
func (e *entry) delete() (value interface{}, ok bool) { for { p := atomic.LoadPointer(&e.p) if p == nil || p == expunged { return nil, false } if atomic.CompareAndSwapPointer(&e.p, p, nil) { return *(*interface{})(p), true } } }
該方法都是將 entry.p
置為 nil
,并且標記為 expunged
(刪除狀態(tài)),而不是真真正正的刪除。
注:不要誤用 sync.Map
,前段時間從字節(jié)大佬分享的案例來看,他們將一個連接作為 key
放了進去,于是和這個連接相關(guān)的,例如:buffer
的內(nèi)存就永遠無法釋放了...
總結(jié):
針對 sync.Map
的性能差異,進行了深入的源碼剖析,了解到了其背后快、慢的原因,實現(xiàn)了知其然知其所以然。
經(jīng)常看到并發(fā)讀寫 map
導(dǎo)致致命錯誤,實在是令人憂心。大家覺得如果本文不錯,歡迎分享給更多的 Go 愛好者 :)
到此這篇關(guān)于Go 并發(fā)讀寫 sync.map 詳細的文章就介紹到這了,更多相關(guān)Go 并發(fā)讀寫 sync.map 內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
go語言題解LeetCode1122數(shù)組的相對排序
這篇文章主要為大家介紹了go語言題解LeetCode1122數(shù)組的相對排序,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2022-12-12重學(xué)Go語言之基礎(chǔ)數(shù)據(jù)類型詳解
Go語言有非常強大的數(shù)據(jù)類型系統(tǒng),其支持的數(shù)據(jù)類型大體上可分為四類:基礎(chǔ)數(shù)據(jù)類型、引用數(shù)據(jù)類型、接口類型、復(fù)合類型。本文就來講講它們各自的用法吧2023-02-02Go語言驅(qū)動低代碼應(yīng)用引擎工具Yao開發(fā)管理系統(tǒng)
這篇文章主要為大家介紹了Go語言驅(qū)動低代碼應(yīng)用引擎工具Yao開發(fā)管理系統(tǒng)使用詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2022-06-06windows下使用GoLand生成proto文件的方法步驟
本文主要介紹了windows下使用GoLand生成proto文件的方法步驟,文中通過示例代碼介紹的非常詳細,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2022-06-06Golang報“import cycle not allowed”錯誤的2種解決方法
這篇文章主要給大家介紹了關(guān)于Golang報"import cycle not allowed"錯誤的2種解決方法,文中通過示例代碼介紹的非常詳細,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友可以們下面隨著小編來一起看看吧2018-08-08