Go 併發讀寫 sync.map 的強大之處

煎魚發表於2021-09-30

大家好,我是煎魚。

在之前的 《為什麼 Go map 和 slice 是非執行緒安全的?》 文章中,我們討論了 Go 語言的 map 和 slice 非執行緒安全的問題,基於此引申出了 map 的兩種目前在業界使用的最多的併發支援的模式。

分別是:

  • 原生 map + 互斥鎖或讀寫鎖 mutex。
  • 標準庫 sync.Map(Go1.9及以後)。

有了選擇,總是有選擇困難症的,這兩種到底怎麼選,誰的效能更加的好?我有一個朋友說 標準庫 sync.Map 效能菜的很,不要用。我到底聽誰的...

今天煎魚就帶你揭祕 Go sync.map,我們先會了解清楚什麼場景下,Go map 的多種型別怎麼用,誰的效能最好!

接著根據各 map 效能分析的結果,針對性的對 sync.map 進行原始碼解剖,瞭解 WHY。

一起愉快地開始吸魚之路。

sync.Map 優勢

在 Go 官方文件中明確指出 Map 型別的一些建議:

圖片

  • 多個 goroutine 的併發使用是安全的,不需要額外的鎖定或協調控制。
  • 大多數程式碼應該使用原生的 map,而不是單獨的鎖定或協調控制,以獲得更好的型別安全性和維護性。

同時 Map 型別,還針對以下場景進行了效能優化:

  • 當一個給定的鍵的條目只被寫入一次但被多次讀取時。例如在僅會增長的快取中,就會有這種業務場景。
  • 當多個 goroutines 讀取、寫入和覆蓋不相干的鍵集合的條目時。

這兩種情況與 Go map 搭配單獨的 Mutex 或 RWMutex 相比較,使用 Map 型別可以大大減少鎖的爭奪。

效能測試

聽官方文件介紹了一堆好處後,他並沒有講到缺點,所說的效能優化後的優勢又是否真實可信。我們一起來驗證一下。

首先我們定義基本的資料結構:

// 代表互斥鎖
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

// 初始化基本資料結構
func init() {
 fooMap = &FooMap{data: make(map[int]int, 100)}
 barRwMap = &BarRwMap{data: make(map[int]int, 100)}
 syncMap = &sync.Map{}
}

在配套方法上,常見的增刪改查動作我們都編寫了相應的方法。用於後續的壓測(只展示部分程式碼):

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)
 }
}

其餘的型別方法基本類似,考慮重複篇幅問題因此就不在此展示了。

壓測方法基本程式碼如下:

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)
  }
 })
}

這塊主要就是增刪改查的程式碼和壓測方法的準備,壓測程式碼直接複用的是大白大佬的 go19-examples/benchmark-for-map 專案。

也可以使用 Go 官方提供的 map\_bench\_test.go,有興趣的小夥伴可以自己拉下來執行試一下。

壓測結果

1)寫入:

方法名含義壓測結果
BenchmarkBuiltinMapStoreParalell-4map+mutex 寫入元素237.1 ns/op
BenchmarkSyncMapStoreParalell-4sync.map 寫入元素509.3 ns/op
BenchmarkBuiltinRwMapStoreParalell-4map+rwmutex 寫入元素207.8 ns/op

在寫入元素上,最慢的是 sync.map 型別,其次是原生 map+互斥鎖(Mutex),最快的是原生 map+讀寫鎖(RwMutex)。

總體的排序(從慢到快)為:SyncMapStore < MapStore < RwMapStore。

2)查詢:

方法名含義壓測結果
BenchmarkBuiltinMapLookupParalell-4map+mutex 查詢元素166.7 ns/op
BenchmarkBuiltinRwMapLookupParalell-4map+rwmutex 查詢元素60.49 ns/op
BenchmarkSyncMapLookupParalell-4sync.map 查詢元素53.39 ns/op

在查詢元素上,最慢的是原生 map+互斥鎖,其次是原生 map+讀寫鎖。最快的是 sync.map 型別。

總體的排序為:MapLookup < RwMapLookup < SyncMapLookup。

3)刪除:

方法名含義壓測結果
BenchmarkBuiltinMapDeleteParalell-4map+mutex 刪除元素168.3 ns/op
BenchmarkBuiltinRwMapDeleteParalell-4map+rwmutex 刪除元素188.5 ns/op
BenchmarkSyncMapDeleteParalell-4sync.map 刪除元素41.54 ns/op

在刪除元素上,最慢的是原生 map+讀寫鎖,其次是原生 map+互斥鎖,最快的是 sync.map 型別。

總體的排序為:RwMapDelete < MapDelete < SyncMapDelete。

場景分析

根據上述的壓測結果,我們可以得出 sync.Map 型別:

  • 在讀和刪場景上的效能是最佳的,領先一倍有多。
  • 在寫入場景上的效能非常差,落後原生 map+鎖整整有一倍之多。

因此在實際的業務場景中。假設是讀多寫少的場景,會更建議使用 sync.Map 型別。

但若是那種寫多的場景,例如多 goroutine 批量的迴圈寫入,那就建議另闢途徑了,效能不忍直視(無效能要求另當別論)。

sync.Map 剖析

清楚如何測試,測試的結果後。我們需要進一步深挖,知其所以然。

為什麼 sync.Map 型別的測試結果這麼的 “偏科”,為什麼讀操作效能這麼高,寫操作效能低的可怕,他是怎麼設計的?

資料結構

sync.Map 型別的底層資料結構如下:

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:只讀資料,支援併發讀取(atomic.Value 型別)。如果涉及到更新操作,則只需要加鎖來保證資料安全。
  • read 實際儲存的是 readOnly 結構體,內部也是一個原生 map,amended 屬性用於標記 read 和 dirty 的資料是否一致。
  • dirty:讀寫資料,是一個原生 map,也就是非執行緒安全。操作 dirty 需要加鎖來保證資料安全。
  • misses:統計有多少次讀取 read 沒有命中。每次 read 中讀取失敗後,misses 的計數值都會加 1。

在 read 和 dirty 中,都有涉及到的結構體:

type entry struct {
 p unsafe.Pointer // *interface{}
}

其包含一個指標 p, 用於指向使用者儲存的元素(key)所指向的 value 值。

在此建議你必須搞懂 read、dirty、entry,再往下看,食用效果會更佳,後續會圍繞著這幾個概念流轉。

查詢過程

劃重點,Map 型別本質上是有兩個 “map”。一個叫 read、一個叫 dirty,長的也差不多:

圖片

sync.Map 的 2 個 map

當我們從 sync.Map 型別中讀取資料時,其會先檢視 read 中是否包含所需的元素:

  • 若有,則通過 atomic 原子操作讀取資料並返回。
  • 若無,則會判斷 read.readOnly 中的 amended 屬性,他會告訴程式 dirty 是否包含 read.readOnly.m 中沒有的資料;因此若存在,也就是 amended 為 true,將會進一步到 dirty 中查詢資料。

sync.Map 的讀操作效能如此之高的原因,就在於存在 read 這一巧妙的設計,其作為一個快取層,提供了快路徑(fast path)的查詢。

同時其結合 amended 屬性,配套解決了每次讀取都涉及鎖的問題,實現了讀這一個使用場景的高效能。

寫入過程

我們直接關注 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
 }
  ...
}

呼叫 Load 方法檢查 m.read 中是否存在這個元素。若存在,且沒有被標記為刪除狀態,則嘗試儲存。

若該元素不存在或已經被標記為刪除狀態,則繼續走到下面流程:

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()
}

由於已經走到了 dirty 的流程,因此開頭就直接呼叫了 Lock 方法上互斥鎖,保證資料安全,也是凸顯效能變差的第一幕

其分為以下三個處理分支:

  • 若發現 read 中存在該元素,但已經被標記為已刪除(expunged),則說明 dirty 不等於 nil(dirty 中肯定不存在該元素)。其將會執行如下操作。
  • 將元素狀態從已刪除(expunged)更改為 nil。
  • 將元素插入 dirty 中。
  • 若發現 read 中不存在該元素,但 dirty 中存在該元素,則直接寫入更新 entry 的指向。
  • 若發現 read 和 dirty 都不存在該元素,則從 read 中複製未被標記刪除的資料,並向 dirty 中插入該元素,賦予元素值 entry 的指向。

我們理一理,寫入過程的整體流程就是:

  • 查 read,read 上沒有,或者已標記刪除狀態。
  • 上互斥鎖(Mutex)。
  • 操作 dirty,根據各種資料情況和狀態進行處理。

回到最初的話題,為什麼他寫入效能差那麼多。究其原因:

  • 寫入一定要會經過 read,無論如何都比別人多一層,後續還要查資料情況和狀態,效能開銷相較更大。
  • (第三個處理分支)當初始化或者 dirty 被提升後,會從 read 中複製全量的資料,若 read 中資料量大,則會影響效能。

可得知 sync.Map 型別不適合寫多的場景,讀多寫少是比較好的。

若有大資料量的場景,則需要考慮 read 複製資料時的偶然效能抖動是否能夠接受。

刪除過程

這時候可能有小夥伴在想了。寫入過程,理論上和刪除不會差太遠。怎麼 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 檢查該元素是否存在。

若存在,則呼叫 delete 標記為 expunged(刪除狀態),非常高效。可以明確在 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,上互斥鎖。

再重複進行雙重檢查,若 read 仍然不存在該元素。則呼叫 delete 方法從 dirty 中標記該元素的刪除。

需要注意,出現頻率較高的 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(刪除狀態),而不是真真正正的刪除

注:不要誤用 sync.Map,前段時間從位元組大佬分享的案例來看,他們將一個連線作為 key 放了進去,於是和這個連線相關的,例如:buffer 的記憶體就永遠無法釋放了...

總結

通過閱讀本文,我們明確了 sync.Map 和原生 map +互斥鎖/讀寫鎖之間的效能情況。

標準庫 sync.Map 雖說支援併發讀寫 map,但更適用於讀多寫少的場景,因為他寫入的效能比較差,使用時要考慮清楚這一點。

另外我們針對 sync.Map 的效能差異,進行了深入的原始碼剖析,瞭解到了其背後快、慢的原因,實現了知其然知其所以然。

經常看到併發讀寫 map 導致致命錯誤,實在是令人憂心。大家覺得如果本文不錯,歡迎分享給更多的 Go 愛好者 :)

若有任何疑問歡迎評論區反饋和交流,最好的關係是互相成就,各位的點贊就是煎魚創作的最大動力,感謝支援。

文章持續更新,可以微信搜【腦子進煎魚了】閱讀,本文 GitHub github.com/eddycjy/blog 已收錄,歡迎 Star 催更。

參考

  • Package sync
  • 踩了 Golang sync.Map 的一個坑
  • go19-examples/benchmark-for-map
  • 通過例項深入理解sync.Map的工作原理

相關文章