我們知道,Go 中的 map 類型是非併發安全的,所以 Go 就在 sync 包中提供了 map 的併發原語 sync.Map,允許併發操作,本文就帶大家詳細解讀下 sync.Map 的原理。
使用示例
sync.Map 提供了基礎類型 map 的常用功能,使用示例如下:
package main
import (
"fmt"
"sync"
)
func main() {
var s sync.Map
// 存儲鍵值對
s.Store("name", "江湖十年")
s.Store("age", 20)
s.Store("location", "Beijing")
// 讀取值
if value, ok := s.Load("name"); ok {
fmt.Println("name:", value)
}
// 刪除一個鍵
s.Delete("age")
// 遍歷 sync.Map
s.Range(func(key, value interface{}) bool {
fmt.Printf("%s: %s\n", key, value)
return true // 繼續遍歷
})
}
sync.Map 是一個結構體,和其他常見的併發原語一樣零值可用。Store 方法用於存儲一個鍵值對,Load 方法根據給定的鍵讀取對應的值,Delete 方法則可以刪除一個鍵值對,Range 方法則用於遍歷 sync.Map 中存儲的所有鍵值對。
以上便是 sync.Map 的基本使用方法,更多功能將會在稍後的源碼解讀部分介紹。
源碼解讀
我們可以在 sync.Map 文檔中看到其定義和實現的 exported 方法:
https://pkg.go.dev/sync@go1.23.0#Map
type Map
func (m *Map) Clear()
func (m *Map) CompareAndDelete(key, old any) (deleted bool)
func (m *Map) CompareAndSwap(key, old, new any) (swapped bool)
func (m *Map) Delete(key any)
func (m *Map) Load(key any) (value any, ok bool)
func (m *Map) LoadAndDelete(key any) (value any, loaded bool)
func (m *Map) LoadOrStore(key, value any) (actual any, loaded bool)
func (m *Map) Range(f func(key, value any) bool)
func (m *Map) Store(key, value any)
func (m *Map) Swap(key, value any) (previous any, loaded bool)
sync.Map 結構體不僅提供了 map 的基本功能,還提供瞭如 CompareAndSwap、LoadOrStore 等複合操作,隨着我們對源碼的深入探究,你將學會這裏所有方法的原理和用途。
核心結構體
sync.Map 主要由以下幾個核心結構體組成:
https://github.com/golang/go/blob/go1.23.0/src/sync/map.go
type Map struct {
mu sync.Mutex // 保護 dirty map 的互斥鎖
read atomic.Pointer[readOnly] // 只讀 map,原子操作無需加鎖
dirty map[any]*entry // 可變 map,包含所有數據,操作時需要加鎖
misses int // 記錄 read map 的 miss 計數
}
type readOnly struct {
m map[any]*entry // 存儲數據的只讀 map
amended bool // 是否有新的 key 只存在於 dirty map
}
type entry struct {
p atomic.Pointer[any] // 存儲值的指針,支持原子操作
}
var expunged = new(any) // 用於標記從 dirty map 中被刪除的元素
sync.Map 結構體字段不多,不過 sync.Map 源碼整體上來説邏輯還是比較複雜的,並且細節很多,我先為你大致梳理下 sync.Map 的設計思路,方便後續理解源碼。
我們可以粗略的將 sync.Map 的設計類比成緩存系統,當我們要讀取 map 中某個 key 對應的值時,sync.Map 會優先從緩存中讀取,如果緩存未命中,則繼續從 DB 中讀取。sync.Map 還會在特定條件滿足的情況下同步緩存和 DB 中的數據。
有了緩存系統的類比,我再為你講解 sync.Map 的源碼,就更容易理解了。
在 sync.Map 結構體中,mu 字段是一個互斥鎖,用於保護對 dirty 屬性的操作;dirty 字段是一個 map 類型,可以類比為緩存系統中的 DB,所以理論上 dirty 中的數據應該是全量的;read 字段是一個原子類型的指針,指向 readOnly 結構體,readOnly 內部其實也是使用 map 來存儲數據,你可以將 read 類比為緩存;misses 字段則用於計數,記錄緩存未命中的次數,當我們要讀取 map 中某個 key 對應的值時,優先從 read 讀取,如果 read 中不存在,則 misses 值加 1,然後繼續從 dirty 中讀取,當 misses 值達到某個閾值時,sync.Map 就會將 dirty 提升為 read。
readOnly 結構體中 m 字段用於存儲 map 數據;amended 用於標記是否有新的 key 只存在於 dirty 中,而不在 read 中。當我們要讀取 sync.Map 中某個 key 對應的值時,會優先從 read.m 中讀取,如果 read.m 中不存在,read.amended 則決定了是否需要繼續從 dirty 中讀取,read.amended 為 false 時,表示 read.m 和 dirty 中的數據一致,所以無需繼續從 dirty 中讀取;read.amended 為 true 時,表示 read.m 中的數據要落後於 dirty,read.m 存在數據缺失,所以可以嘗試繼續從 dirty 中讀取,如果 dirty 中還是沒有,才最終確定 sync.Map 中確實沒有此 key。
entry 結構體代表一個 map 中的 value,即在 sync.Map 中存儲的 key 對應的值(value),所有存入 sync.Map 中的值都會被包裝成 entry 對象,這樣可以方便標記鍵值對的狀態。我們可以看到這裏還有一個全局變量 expunged,值為一個任意的指針,用於標記 sync.Map 中的某個鍵值對已經從 dirty 中被刪除。一個 entry 對象可能有 3 種狀態,當其內部的字段 p 值為一個對象的指針時,表示正常狀態,即這個鍵值對正常存在於 sync.Map 中;當 p 的值為 nil 時,表示這個鍵值對已經被刪除;當 p 的值為 expunged 時,表示這個鍵值對已經被徹底刪除。關於這兩個刪除狀態,你可以簡單的將 nil 類比為數據庫中的邏輯刪除,expunged 理解為物理刪除,稍後閲讀完源碼,你就能理解其用意了。
接下來我將對 sync.Map 提供的方法進行一一講解。
核心方法
首先,我們來看 sync.Map 提供的幾個比較核心的方法 Store、Load、Delete、Range 和 Clear。
Store
Store 方法可以將指定的鍵值對存入 sync.Map。
Store 方法源碼如下:
func (m *Map) Store(key, value any) {
_, _ = m.Swap(key, value)
}
不過 Store 方法實際上會將操作代理到 Swap 方法。
Swap 方法能夠交換給定 key 對應的值,並返回之前的舊值(如果存在的話),返回值 loaded 表示 key 是否存在。即 Swap 用於存儲鍵值對,它可以設置一個新的鍵值對,也可以更新一個已存在的鍵值對。
Swap 方法源碼如下:
func (m *Map) Swap(key, value any) (previous any, loaded bool) {
// 先嚐試從 read map 獲取 key 對應的 entry
read := m.loadReadOnly()
if e, ok := read.m[key]; ok { // 如果 key 存在
// 嘗試交換值
if v, ok := e.trySwap(&value); ok { // 如果交換成功
if v == nil { // 説明已被刪除,但沒有徹底刪除(expunged)
return nil, false // 新增鍵值對成功
}
return *v, true // 交換成功
}
}
// 未找到或需要修改 dirty map,需要加鎖處理
m.mu.Lock()
read = m.loadReadOnly()
if e, ok := read.m[key]; ok { // 如果 key 在 read map 中
if e.unexpungeLocked() {
// 如果 entry 之前被 expunged,意味着 dirty map 不為 nil 且該 entry 不在 dirty map 中
m.dirty[key] = e
}
// 進行值交換
if v := e.swapLocked(&value); v != nil {
loaded = true
previous = *v
}
} else if e, ok := m.dirty[key]; ok { // 如果 key 在 dirty map 中
// 進行值交換
if v := e.swapLocked(&value); v != nil {
loaded = true
previous = *v
}
} else { // key 即不在 read map 中,也不在 dirty map 中
if !read.amended {
// 添加第一個新 key 到 dirty map,需要先標記 read map 為不完整
m.dirtyLocked()
m.read.Store(&readOnly{m: read.m, amended: true})
}
// 在 dirty map 中創建新 entry
m.dirty[key] = newEntry(value)
}
m.mu.Unlock()
return previous, loaded
}
Swap 方法會先嚐試從 read 中獲取給定 key 對應的值 entry,此時無需加鎖。
如果 key 存在,説明是更新操作,那麼調用 e.trySwap(&value) 嘗試交換 key 對應的值。如果返回的 ok 為 true 説明交換成功,那麼此時有兩種情況,① 如果返回的 v 等於 nil 説明這個 key 以前存在過,但是已經被刪除了,不過還沒有被徹底刪除(expunged),此時返回的 loaded 值為 false 標識 key 不存在,是新增鍵值對成功;② 如果 v 的值不為 nil,説明 key 存在,是更新操作,返回的 loaded 值設為 true。如果返回的 ok 為 false,説明這個 key 以前存在過,但是已經被徹底刪除了(expunged),交換失敗,程序繼續往下執行。
這裏也可以發現,read 其實並不完全是“只讀”,在更新的情況下,其實是可以寫的(調用 e.trySwap(&value) 會直接修改 read.m 中的值,稍後我們將會看到源碼實現)。對於同一個鍵值對,read.m 和 dirty 指向的是同一個 entry 指針對象,所以,其實這裏修改 read 時 dirty 也會同步修改。read 真正代表的其實是無鎖操作,只有修改 dirty 的時候才需要加鎖。
如果 key 在 read 中未找到,則接下來會涉及對 dirty 的操作,所以需要加鎖。
接着,這裏會再次調用 m.loadReadOnly() 重新加載 read,並檢查 read 中是否存在 key,這也是保證併發安全的常見操作,雙重檢查(double-checking)。
如果這次檢查發現 key 在 read 中,那麼調用 e.swapLocked(&value) 將 entry 的值更新成新的值,並且這裏也會通過 m.dirty[key] = e 同時更新 dirty(調用 e.unexpungeLocked() 能確保 entry 未被標記為 expunged)。
如果 key 不在 read 中,而在 dirty 中存在,那麼直接將其更新成新的值。
如果 key 即不在 read 中,也不在 dirty 中,則説明是新插入的鍵值對,在 dirty 中創建並保存新的 entry。此外,這裏有一個判斷 if !read.amended,如果成立,即 read.amended 值為 false,意味着現在的 read 中數據是完整的,此時 dirty 有可能為 nil(後續講解,你將知道何時會出現這種情況),調用 m.dirtyLocked() 能夠確保 dirty 不為 nil,如果為 nil 則將其初始化,並且這裏會將 read.amended 值標記為 true,即標記 read.m 是不完整的(因為這裏只將 newEntry(value) 賦值給了 dirty,並沒有賦值給 read),以後如果在 read.m 裏查詢不到 key,就會去 dirty 中繼續查找。
等這一切都做完後,鍵值對就一定被存儲到了 sync.Map 中,調用 m.mu.Unlock() 解鎖,並返回存儲鍵值對的結果。
接下來我們再看下 Swap 方法中的幾個依賴方法是如何實現的。
首先是用於加載 read 的 *Map.loadReadOnly 方法實現:
func (m *Map) loadReadOnly() readOnly {
if p := m.read.Load(); p != nil {
return *p
}
return readOnly{}
}
read 屬性是 atomic.Pointer 類型,其 Load 方法用於加載指針對象,*p 則可以獲取指針中的值。
調用 e.trySwap(&value) 方法可以嘗試將 entry 中的值更新,其實現如下:
func (e *entry) trySwap(i *any) (*any, bool) {
for {
p := e.p.Load()
if p == expunged {
return nil, false
}
if e.p.CompareAndSwap(p, i) {
return p, true
}
}
}
這裏使用 for 循環來執行 CAS 操作,直到操作成功返回。
如果 entry 的值為 expunged,説明此 entry 已被徹底刪除,那麼無法交換其值。這是因為,如果某個 entry 對象的值被標記為 expunged,那麼它一定是在 read 中有,而在 dirty 中沒有,所以不要交換,不然 read 的數據就比 dirty 多了,這是不允許發生的(後續看到什麼情況下 entry 被賦值為 expunged 你就更加清晰這裏我在説什麼了)。如果允許交換,調用 CompareAndSwap 方法可以原子的交換值,交換成功返回 true,失敗則進行下一輪循環。
調用 e.unexpungeLocked() 能確保 entry 未被標記為 expunged,其實現如下:
func (e *entry) unexpungeLocked() (wasExpunged bool) {
return e.p.CompareAndSwap(expunged, nil)
}
這裏僅有一行代碼,如果 entry 的值為 expunged,則將其交換為 nil。
調用 e.swapLocked(&value) 方法可以更新 entry 對象中的值,其實現如下:
func (e *entry) swapLocked(i *any) *any {
return e.p.Swap(i)
}
調用 m.dirtyLocked() 能夠確保 dirty 被正確初始化,其實現如下:
func (m *Map) dirtyLocked() {
if m.dirty != nil {
return
}
read := m.loadReadOnly()
m.dirty = make(map[any]*entry, len(read.m))
for k, e := range read.m {
if !e.tryExpungeLocked() {
m.dirty[k] = e
}
}
}
如果 dirty 不為 nil,則無需處理,直接返回。至於 dirty 何時會為 nil 呢?這裏留個疑問,後續閲讀 Load 方法源碼時,你就知道了。
否則,需要初始化一個新的 dirty,然後全量遍歷 read.m,如果 !e.tryExpungeLocked() 成立,則將 entry 存入 dirty。這裏使用遍歷的方式將 read.m 拷貝到 dirty,而沒有直接全量賦值,目的是為了遍歷每一個對象,從而過濾出已經標記為刪除的數據(值為 nil 和 expunged 的都會被清理),防止 read 無限增長下去(因為 dirty 會在特定情況下提升為 read,所以這裏 dirty 過濾掉了被刪除的數據,下次提升為 read 時,這個新的 read 就比舊的 read 中垃圾數據更少)。
這裏用到的 e.tryExpungeLocked() 實現如下:
func (e *entry) tryExpungeLocked() (isExpunged bool) {
p := e.p.Load()
for p == nil {
if e.p.CompareAndSwap(nil, expunged) {
return true
}
p = e.p.Load()
}
return p == expunged
}
*entry.tryExpungeLocked 方法的作用是過濾掉值為 nil 或 expunged 的 entry,即已經標記為刪除的鍵值對。
這裏同樣使用了 CAS 操作,將 entry 值為 nil 的, 轉換為 expunged,表示徹底刪除。
如果操作成功,直接返回,所以我們可以發現, sync.Map 中一個值的刪除過程是,如果在 read 中先置為了 nil,之後在某次從 read 複製全量數據到 dirty 時,會將其置為 expunged 狀態,並且不會被拷貝到 dirty。所以此時 read 和 dirty 中就存在數據不一致了,在 read 中這個值被標記為徹底刪除 expunged,在 dirty 沒有此鍵值對。所以我們在前文説:如果某個 entry 對象的值被標記為 expunged,那麼它一定是在 read 中有,而在 dirty 中沒有。
你可以回顧一下前文中講的 e.unexpungeLocked() 方法能確保 entry 未被標記為 expunged,如果 entry 的值為 expunged,則將其改回 nil。並通過 m.dirty[key] = e 將這個 entry 賦值給 dirty,然後修改其值為最新值。
所以 e.unexpungeLocked() 方法和 e.tryExpungeLocked() 方法其實是有連繫的,二者是相反操作,它們通過修改 entry 的值來標識一個鍵值對的狀態。其實 sync.Map 之所以這麼設計,也是為了減少 entry 被徹底刪除的可能性,我們可以認為,某個鍵值對被加入進來,之後刪除掉,那麼很有可能下次還會被重新加入進來,所以才搞了個 nil 作為邏輯刪除,而非徹底從 map 中刪除,也許可以救一下。而 expunged 則用來處理某個被刪除的對象在 read 中存在,而在 dirty 中不存在的情況,當讀取 read 發現其已經被標記為徹底刪除,那麼就沒比較加鎖再去 dirty 中查找了。
並且,我們還可以發現,因為調用 dirtyLocked 可能全量遍歷 read.m,所以就可能會導致 sync.Map 的性能退化。
調用 newEntry(value) 可以創建一個新的 entry 對象,其實現如下:
func newEntry(i any) *entry {
e := &entry{}
e.p.Store(&i)
return e
}
至此,Store 方法涉及的所有源碼,就都講解清楚了。
需要特別強調的是,以上提到的方法中,帶有 Locked 結尾的方法,都需要外部持有鎖才可以調用,以保證併發安全。
Load
Load 方法用於從 sync.Map 中讀取給定 key 對應的值,如果 key 不存在,則返回值為 nil,且第二個返回值 ok 為 false。
Load 方法源碼如下:
func (m *Map) Load(key any) (value any, ok bool) {
read := m.loadReadOnly() // 加載只讀 map
e, ok := read.m[key] // 嘗試從 read map 中查找 key
// 如果 read map 中找不到 key,並且 dirty map 可能包含新鍵(amended 為 true),則進入慢路徑
if !ok && read.amended {
m.mu.Lock() // 操作 dirty 需要加鎖
// 在獲取鎖的過程中,dirty 可能已經被提升為 read,因此需要重新加載 read map 進行 double-checking
read = m.loadReadOnly()
e, ok = read.m[key]
if !ok && read.amended {
// 如果在 read map 仍然找不到,則嘗試從 dirty map 查找
e, ok = m.dirty[key] // 從 dirty map 中查找 key
// 無論 key 是否存在,記錄一次 miss:
// 在 dirty map 提升為 read map 之前,這個 key 的查詢都會走慢路徑
m.missLocked() // miss 計數值 + 1
}
m.mu.Unlock()
}
// 如果最終 key 仍然不存在,則返回 nil
if !ok {
return nil, false
}
return e.load() // 返回找到的值
}
同 Store 方法一樣,Load 方法也會優先加載只讀的 map 對象 read,並嘗試從 read 中查找 key。
如果 ok 説明找到了,那麼可以直接調用 e.load() 加載並返回找到的值。這是一個快速路徑,不需要加鎖。
如果 !ok 説明沒找到,並且 read.amended 為 true 時,説明 dirty 中可能包含新的鍵值對,則需要加鎖,進入到慢路徑查找。
先進行 double-checking,在獲取鎖的過程中,dirty 可能已經被提升為 read,因此需要重新加載 read。如果這次檢查 read 還不滿足,則嘗試從 dirty 中查找,並且 misses 計數值加 1。
查找結束後進行解鎖操作,然後根據 ok 的值返回結果,如果 !ok 説明沒找到,返回 nil。否則説明查找到了 key,調用 e.load() 返回找到的值。
接下來我們同樣列出 Load 方法中幾個依賴方法的具體實現。
調用 m.missLocked() 方法可以將 misses 計數值加 1,其實現如下:
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
}
可以發現,當 misses 的次數達到 dirty 中全量數據的長度時(所以 misses 的閾值就是 dirty 中全量數據的長度),dirty 會被提升為 read(這裏直接使用 dirty 作為 readOnly 的 m 屬性值),這一步相當於刷新了緩存,更新成最新數據,並且 dirty 被置為了 nil,misses 計數值清零。所以這裏也解釋了前文中的疑問,dirty 何時會為 nil 呢?就是在此時。那麼這個目的又是什麼呢?還記得前文中講過調用 m.dirtyLocked() 方法時,如果 dirty 值為 nil,則會遍歷 read.m 的數據,然後過濾掉被刪除的值,將正常值存入 dirty 嗎,這就是為了有一個機會,能夠清理被標記刪除的對象,以防止 sync.Map 無限增長。
這裏有個細節,構造 readOnly 對象時 amended 沒有賦值,默認值為 false,標識 read 和 dirty 現在數據是等價的,read 是全量數據(其實 dirty 中此時數據為 nil,所以此時以 read 數據為準)。後續查找 key 時,如果 read 中沒找到,就無需再加鎖繼續查找 dirty 了。
調用 e.load() 能夠加載 entry 對象中記錄的具體值,其實現如下:
func (e *entry) load() (value any, ok bool) {
p := e.p.Load()
if p == nil || p == expunged {
return nil, false
}
return *p, true
}
這裏無需過多解釋,就是從 atomic.Pointer 類型中獲取包含的值。
那麼現在 Load 方法也講解完成了。
其實到這裏我們可以總結出,sync.Map 之所以設計出 read + dirty 兩層結構,其實就是以空間換時間的思路,做了兩層的數據冗餘,對於 read 的操作都是原子性的,無需加鎖,而 read 中數據存在滯後的情況下,再加鎖訪問 dirty。
Delete
Delete 方法用於從 sync.Map 中刪除指定的鍵值對。
Delete 方法源碼如下:
func (m *Map) Delete(key any) {
m.LoadAndDelete(key) // 直接調用 LoadAndDelete 進行刪除,忽略其返回值
}
在 Delete 方法內部,直接調用了 LoadAndDelete 方法。LoadAndDelete 方法會刪除 key 對應的值,並返回刪除前的值(如果存在),第二個返回值 loaded 標識該 key 是否存在。
LoadAndDelete 方法源碼如下:
func (m *Map) LoadAndDelete(key any) (value any, loaded bool) {
// 先從 read map 讀取,避免加鎖
read := m.loadReadOnly() // 加載只讀 map
e, ok := read.m[key]
if !ok && read.amended {
// 若 read map 沒有 key,且 dirty map 可能包含該 key,則加鎖檢查 dirty map
m.mu.Lock()
read = m.loadReadOnly() // double-checking
e, ok = read.m[key]
if !ok && read.amended {
// 在 dirty map 查找 key
e, ok = m.dirty[key]
delete(m.dirty, key) // 刪除 dirty map 中的 key
// 記錄一次 miss,表示該 key 走了慢路徑,直到 dirty map 提升為 read map
m.missLocked()
}
m.mu.Unlock()
}
if ok {
// 若 key 存在,調用 entry.delete() 刪除值並返回
return e.delete()
}
return nil, false // read map 和 dirty map 都無此 key
}
有了前面兩個方法的講解,現在閲讀 LoadAndDelete 方法的源碼就輕鬆多了。
同樣的套路,優先加載只讀 map,先嚐試從 read 中查找 key。若 read 中沒有該 key,且 dirty 中可能包含該 key,則加鎖檢查 dirty。同樣做了 double-checking 處理,再次查詢 read 依然不滿足,才嘗試從 dirty 中查找。這裏的操作是先查找再刪除 dirty 中的 key,這也正是 Load 和 Delete 兩個操作。此外,這裏還會記錄一次 misses。
Load 和 Delete 操作都結束後進行解鎖操作,之後根據 ok 的值,返回結果,如果 ok 則説明查找到了 key,調用 e.delete() 刪除值並返回,否則説明未查到該 key,返回 nil。
LoadAndDelete 方法中依賴的 *entry.delete 方法實現如下:
func (e *entry) delete() (value any, ok bool) {
for {
p := e.p.Load()
if p == nil || p == expunged {
return nil, false
}
if e.p.CompareAndSwap(p, nil) {
return *p, true
}
}
}
當 p 的值為 nil 或 expunged 時,説明該 key 已經被刪除了,所以返回的 ok 值為 false,否則通過 CAS 操作對其進行刪除,刪除成功返回 true。
注意這裏的刪除只會將 entry 中存儲的值置為 nil,並不會徹底刪除 entry 對象。
Range
Range 方法可以遍歷 sync.Map 中所有的鍵值對,並依次調用給定的 f(key, value) 函數,如果 f 返回 false,則停止遍歷。
Range 方法源碼如下:
func (m *Map) Range(f func(key, value any) bool) {
// 讀取當前的 read map
read := m.loadReadOnly()
// 如果 read map 是完整的(未修改),直接遍歷
if read.amended {
// 若 read.amended == true,説明 dirty map 存在新增 key,需要提升為 read
m.mu.Lock()
read = m.loadReadOnly()
if read.amended {
// 提升 dirty map 為 read
read = readOnly{m: m.dirty}
copyRead := read
m.read.Store(©Read)
// 清空 dirty map 並重置 miss 計數
m.dirty = nil
m.misses = 0
}
m.mu.Unlock()
}
// 遍歷 read map
for k, e := range read.m {
v, ok := e.load()
if !ok {
continue
}
// 調用用户提供的函數 f
if !f(k, v) {
break // f 返回 false 時停止遍歷
}
}
}
對於 Range 操作來説,只需要遍歷 map,即這是一個讀取操作,所以會讀取當前的 read 值,如果 read 是完整的(read.amended == false),那麼可以直接遍歷 read,並且每次 for 循環中都會調用用户提供的函數 f,如果 f 返回 false,則停止遍歷。
如果 read.amended 值為 true,説明 dirty 中存在新增的 key,那麼就將其提升為 read,然後再進行 read 遍歷。因為需要操作 dirty,所以會加鎖,並且進行了 double-checking,在提升 dirty 為 read 後,會清空 dirty 並重置 misses 計數。所以説調用 Range 方法也是一個可能導致後面 sync.Map 會出現性能退化的原因。
這裏將 dirty 提升為 read 的邏輯跟在 Load 方法中調用 m.missLocked() 的邏輯類似。
Clear
Clear 方法會刪除所有鍵值對,使 sync.Map 變為空。
Clear 方法源碼如下:
func (m *Map) Clear() {
read := m.loadReadOnly() // 加載只讀 map
// 如果 read map 為空且 dirty map 也未修改(amended 為 false),則無需執行清理
if len(read.m) == 0 && !read.amended {
// 如果 map 已經為空,則避免分配新的 readOnly。
return
}
m.mu.Lock() // 加鎖
defer m.mu.Unlock()
// double-checking
// 重新加載 read map,避免在獲取鎖的過程中發生變化
read = m.loadReadOnly()
if len(read.m) > 0 || read.amended {
// 清空 read map,存儲一個新的空 readOnly 結構
m.read.Store(&readOnly{})
}
// 清空 dirty map
clear(m.dirty)
// 復位 miss 計數,防止下一次操作時剛清空的 dirty map 立即被提升到 read map
m.misses = 0
}
Clear 方法首先會加載 read,並判斷 read.m 是否為空,如果為空且 dirty 中也不存在新的 key,則無需進一步處理,直接返回。否則,需要加鎖後進一步處理。在 double-checking 後會再次判斷 read.m 是否為空,如果不為空或 read.amended 為 true,則清空 read。最終,再清空 dirty 並重置 misses 計數。
複合操作方法
sync.Map 不僅提供了 map 的常規操作方法,它還實現了幾個複合操作方法 LoadAndDelete、LoadOrStore、CompareAndSwap 和 CompareAndDelete。所謂複合方法,就是一個方法同時支持兩個以上的操作,而且這幾個複合方法都能夠見名知意。
其中 LoadAndDelete 方法前文中已經講解,那麼接下來就介紹下其他幾個方法。
LoadOrStore
LoadOrStore 方法用來獲取或保存一個鍵值對,如果 key 存在,則返回其當前值,否則存儲並返回給定的 value,loaded 返回值表示是否是從 map 中加載的值(true 表示 key 已存在,false 表示存儲了新值)。
LoadOrStore 方法源碼如下:
func (m *Map) LoadOrStore(key, value any) (actual any, loaded bool) {
// 優先檢查只讀 map,避免加鎖,提高併發性能
read := m.loadReadOnly() // 加載只讀 map
if e, ok := read.m[key]; ok {
// 嘗試加載或存儲值
actual, loaded, ok := e.tryLoadOrStore(value)
if ok {
return actual, loaded
}
}
// 加鎖處理
m.mu.Lock()
defer m.mu.Unlock()
// 進入慢路徑
// 重新加載 read map 以防數據變化
read = m.loadReadOnly()
if e, ok := read.m[key]; ok { // key 在 read map 中
// 解除 expunged 狀態並放入 dirty map
if e.unexpungeLocked() {
m.dirty[key] = e
}
// 嘗試加載或存儲
actual, loaded, _ = e.tryLoadOrStore(value)
} else if e, ok := m.dirty[key]; ok { // key 僅存在於 dirty map 中
actual, loaded, _ = e.tryLoadOrStore(value)
m.missLocked() // 記錄一次 miss
} else { // 該 key 完全不存在
if !read.amended {
// 這是 dirty map 第一次存入新 key,需要確保它已分配,並標記 read map 為不完整
m.dirtyLocked()
m.read.Store(&readOnly{m: read.m, amended: true})
}
// 在 dirty map 中存儲新值
m.dirty[key] = newEntry(value)
actual, loaded = value, false
}
return actual, loaded
}
LoadOrStore 方法先嚐試從 read 中獲取給定 key 對應的值,如果存在,則調用 e.tryLoadOrStore(value) 嘗試加載或存儲值,並且根據返回值 ok 來決定是否返回。
其中 *entry.tryLoadOrStore 方法實現如下:
func (e *entry) tryLoadOrStore(i any) (actual any, loaded, ok bool) {
p := e.p.Load()
if p == expunged {
// entry 被標記為 expunged,不可修改
return nil, false, false
}
if p != nil {
// entry 已經存在,loaded 為 true,返回當前值
return *p, true, true
}
ic := i
// 循環執行 CAS 操作,直到成功返回
for {
// 如果當前值為 nil,則嘗試存儲新值
if e.p.CompareAndSwap(nil, &ic) {
return i, false, true // 存儲成功,loaded 為 false
}
// 每次循環都要執行一次檢查
p = e.p.Load()
if p == expunged {
// entry 被 expunged,返回失敗
return nil, false, false
}
if p != nil {
// entry 已經存在,loaded 為 true,返回當前值
return *p, true, true
}
}
}
如果 entry 已經被標記為 expunged,tryLoadOrStore 不會修改 entry 的值,並且返回值 ok 置為 false。如果 entry 中的值不為 nil,説明是正常狀態的值,直接返回其 value。否則 entry 中的值被標記為 nil,tryLoadOrStore 會繼續通過 CAS 操作原子地存儲或加載一個值。
如果當前 entry 中的值為 nil,那麼 CAS 操作就會成功,返回 loaded 為 false。在併發情況下,當前 entry 中的值可能會被其他 goroutine 修改成功,所以 CAS 操作如果失敗,則還需要對 entry 中的值狀態再次做檢查。for 循環最終會在某個 case 操作成功後返回。
然後我們再回到 LoadOrStore 方法中的邏輯繼續向下看。
如果 LoadOrStore 方法在 read 中沒有找到給定的 key,或者 e.tryLoadOrStore(value) 失敗,則需要加鎖後做進一步檢查。如果這一次在 read 中找到了給定的 key,則將 entry 記錄到 dirty 中,並調用 e.tryLoadOrStore(value) 嘗試加載或存儲值。值得注意的是,這裏會先調用 e.unexpungeLocked() 確保 dirty 未被標記為 expunged。如果 key 不在 read 中,而在 dirty 中存在,那麼直接將其更新成新的值,並記錄一次 misses。如果 key 即不在 read 中,也不在 dirty 中,則説明是新插入的鍵值對,在 dirty 中創建並保存新的 entry。
這段邏輯與 Swap 方法中的邏輯非常相似,你可以對比學習。
CompareAndSwap
CompareAndSwap 方法僅在給定的 key 存在且對應的值等於傳入的 old 時(old 必須是可比較的類型),將其替換為 new。
CompareAndSwap 方法源碼如下:
func (m *Map) CompareAndSwap(key, old, new any) (swapped bool) {
// 先嚐試從 read map 查找 key
read := m.loadReadOnly()
if e, ok := read.m[key]; ok { // 如果 key 在 read map 中
// 直接在 entry 上嘗試 CompareAndSwap
return e.tryCompareAndSwap(old, new)
} else if !read.amended {
// 如果 key 不存在,並且 read map 沒有標記為 amended,説明 dirty map 也不會有 key
return false
}
// 進入 dirty map 處理,需要加鎖
m.mu.Lock()
defer m.mu.Unlock()
read = m.loadReadOnly()
if e, ok := read.m[key]; ok { // 如果 key 在 read map 中
swapped = e.tryCompareAndSwap(old, new)
} else if e, ok := m.dirty[key]; ok { // 如果 key 在 dirty map 中
swapped = e.tryCompareAndSwap(old, new)
// 這裏雖然 CompareAndSwap 不會修改 map 的 key 集合,
// 但因為涉及鎖的操作,需要增加一次 miss 計數,
// 這樣 dirty map 之後會被提升為 read,提高訪問效率。
m.missLocked()
}
return swapped
}
如果給定的 key 在 read 中存在,則直接調用 e.tryCompareAndSwap(old, new) 嘗試比較並交換。
*entry.tryCompareAndSwap 方法實現如下:
func (e *entry) tryCompareAndSwap(old, new any) bool {
// 加載當前 entry 存儲的值
p := e.p.Load()
// 如果值為 nil(條目已刪除)或 expunged(條目已被徹底刪除)或當前值不等於 old,則返回 false
if p == nil || p == expunged || *p != old {
return false
}
// 複製 new 以優化逃逸分析,避免在比較失敗時不必要的堆分配
nc := new
// 循環執行 CAS 操作,直到成功返回
for {
// 嘗試使用原子操作 CompareAndSwap 替換值
if e.p.CompareAndSwap(p, &nc) {
return true
}
// 每次循環都要執行一次檢查
// 重新加載最新的值,確保併發情況下仍滿足條件
p = e.p.Load()
// 如果值發生變化,或者條目已刪除,則返回 false
if p == nil || p == expunged || *p != old {
return false
}
}
}
tryCompareAndSwap 方法會比較 entry 中的值是否等於給定的 old 值,如果相等且它的值未被刪除(未被標記為 nil 或 expunged),則將其替換為 new 值。如果它的值已被刪除或不等於 old 值,則會返回 false,且不對 entry 做任何修改。
回到 CompareAndSwap 方法邏輯中,如果給定的 key 不在 read 中且 read.amended 為 true,則需要加鎖繼續到 dirty 中查找,且 misses 計數值加 1。
這個方法的整體邏輯還是比較簡單的。
CompareAndDelete
CompareAndDelete 方法僅在給定的 key 存在且對應的值等於 old 時刪除該鍵值對。
CompareAndDelete 方法源碼如下:
func (m *Map) CompareAndDelete(key, old any) (deleted bool) {
// 先嚐試從 read map 查找 key
read := m.loadReadOnly()
e, ok := read.m[key]
// 如果 key 不在 read map 中,但 read.amended == true,説明可能在 dirty map
if !ok && read.amended {
m.mu.Lock() // 進入 dirty map 處理,需要加鎖
read = m.loadReadOnly()
e, ok = read.m[key]
if !ok && read.amended {
e, ok = m.dirty[key]
// 不直接從 m.dirty 刪除 key,而是僅執行“compare”邏輯。
// 當 dirty map 未來被提升為 read 時,該 entry 會被標記 expunged。
//
// 記錄一次 miss,這樣該 key 未來會走慢路徑,直到 dirty map 變為 read。
m.missLocked()
}
m.mu.Unlock()
}
// 如果 key 存在於 read map 或 dirty map
for ok {
p := e.p.Load()
// 如果值已被刪除(nil)、expunged(徹底刪除)或者值不等於 old,則返回 false
if p == nil || p == expunged || *p != old {
return false
}
// CAS 交換值為 nil,成功刪除
if e.p.CompareAndSwap(p, nil) {
return true
}
}
return false
}
這個方法主要邏輯也比較簡單,我就不一行一行的講解了。值得一提的是,這裏的刪除操作與 Delete 方法一樣也是通過 CAS 操作將 entry 中的值改為 nil 來實現的。
至此,sync.Map 中的所有源碼咱們就都講解完成了。
總結
sync.Map 的用法非常簡單,它提供了 map 的基礎功能,並且還擴展了幾個複合功能。
要讀懂 sync.Map 的源碼,可以將其類比為緩存系統,先對其設計思路做到心中有數,再閲讀源碼,不然很容易陷入到細節而不知所措。
你需要記住 sync.Map 的主體邏輯是,所有方法都優先嚐試無鎖操作 read,如果 read 條件不滿足,再嘗試加鎖操作 dirty。read 和 dirty 存儲鍵值對時,指向的是同一個 entry 對象。對於 entry 實例,你需要理解其 3 種狀態,nil、expunged 和正常指針對象,它們之間是如何轉換的。
所有對 read 的操作都無需加鎖,對 dirty 的操作才需要加鎖,再結合 entry 的 nil 和 expunged 兩種狀態,sync.Map 可以儘可能實現無鎖操作。不過,Range 方法和 m.missLocked() 操作都有可能使 dirty 為 nil,這會導致 sync.Map 後續操作出現性能退化,需要格外小心。此外,其實嚴格來講,read 並不代表只讀 map,部分更新、刪除動作也都會操作 read,只有新插入一個鍵值對時才一定要操作 ditry。
這篇文章內容有點長,有些文字寫的也很囉嗦,但這都是筆者有意而為之,目的是讓你加深印象,多讀幾遍,理解更加深刻。只有明白了 sync.Map 的原理,才能將其用好。最後強調一點,謹慎起見,必要時還是要進行性能測試來驗證 sync.Map 是否能滿足我們的業務需求。
本文示例源碼我都放在了 GitHub 中,歡迎點擊查看。
希望此文能對你有所啓發。
文章都看到這裏了,歡迎點擊原文首發地址 https://mp.weixin.qq.com/s/Bh3j9zGccXjm6jfQL6JxmA 關注我的公眾號一起交流學習。
聯繫我
- 公眾號:Go編程世界
- 微信:jianghushinian
- 郵箱:jianghushinian007@outlook.com
- 博客:https://jianghushinian.cn
- GitHub:https://github.com/jianghushinian