golang sync.Map之如何設計一個併發安全的讀寫分離結構?

蓝胖子的编程梦發表於2024-03-19

在 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中資料不一致。

image.png

步驟二: 接著,如果後續再繼續寫入新資料,
在read map沒有從dirty 同步資料之前,即amended 變為false之前,再寫入新鍵值對都只會往dirty裡寫。

image.png

步驟三: 如果有讀操作,sync.Map 都會盡可能的讓其先讀read map,read map讀取不到並且amended 為true,即read 和dirty 資料不一致時,會去讀dirty,讀dirty的過程是上鎖的。

image.png

步驟四: 當讀取read map中miss次數大於等於dirty陣列的長度時,會觸發dirty map整體更新為readOnly map,並且這個過程是阻塞的。更新完成後,原先dirty會被置為空,amended 為false,代表read map同步了之前所有的資料。如下圖所示,

image.png

整體更新的邏輯是直接替換變數的值,並非挨個複製,

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中

image.png

📢📢📢注意,為什麼在dirty整體更新一次read map後,再寫入新的鍵值對時,需要將read map中的資料全部同步到dirty,因為隨著dirty的慢慢寫入,後續讀操作又會造成讀miss的增加,最終會再次觸發dirty map整體更新為readOnly map,amended 改為false,代表read map中又有所有鍵值對資料了,也就是會回到步驟三的操作,重複步驟三到步驟五的過程。

image.png

只有將read map中的資料全部同步到dirty ,才能保證後續的整體更新,不會造成丟失資料。

看到這裡應該能夠明白sync.Map的適合場景了,我來總結下,

sync.Map 適合讀多寫少的場景,大量的讀操作可以透過只讀取read map 擁有極好的效能。

而如果寫操作增加,首先會造成read map中讀取miss增加,會回源到dirty中讀取,且dirty可能會頻繁整體更新為read,回源讀取,整體更新的步驟都是阻塞上鎖的。

其次,寫操作也會帶來dirty和 read中資料頻繁的不一致,導致read中的資料需要同步到dirty中,這個過程在鍵值對比較多時,效能損耗較大且整個過程是阻塞的。

所以sync.Map 並不適合大量寫操作。

相關文章