Go36-34,35-併發安全字典(sync.Map)

併發安全字典(sync.Map)

之前的幾篇,幾乎已經把Go語言自帶的同步工具都講過了。這篇要講的是一個併發安全的高級數據結構:sync.Map。

原生字典

Go語言自帶的字典類型map,就是原生字典,並不是併發安全的。
在使用原生字典的時候,應該在啓動goroutine之前就完成字典的初始化和賦值。或者更高級的做法是,可以在goroutine中,在首次使用的時候通過sync.Once來併發安全的完成初始化和賦值的操作,達到一個延遲初始化的優化效果。之後在使用字典的時候,就只能獲取其中的內容,不能再對其進行修改了。這個在講sync.Once時,在最後有示例。
如果是需要一個併發安全的,可以修改內容的原生字典,也不是太麻煩。加上互斥鎖或讀寫鎖就可以輕鬆實現了。下面就是一個自制的簡易併發安全字典:

package main

import (
    "fmt"
    "sync"
)

// 一個自制的簡易併發安全字典
type ConcurrentMap struct {
    m map[interface{}]interface{}
    mu sync.RWMutex
}

func NewConcurrentMap() *ConcurrentMap {
    return &ConcurrentMap{
        m: make(map[interface{}]interface{}),
    }
}

func (cm *ConcurrentMap) Delete(key interface{}) {
    cm.mu.Lock()
    defer cm.mu.Unlock()
    delete(cm.m, key)
}

func (cm *ConcurrentMap) Load(key interface{}) (value interface{}, ok bool) {
    cm.mu.RLock()
    defer cm.mu.RUnlock()
    value, ok = cm.m[key]
    return
}

func (cm *ConcurrentMap) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool) {
    cm.mu.Lock()
    defer cm.mu.Unlock()
    actual, loaded = cm.m[key]
    if loaded {
        return
    }
    cm.m[key] = value
    actual = value
    return
}

func (cm *ConcurrentMap) Store(key, value interface{}) {
    cm.mu.Lock()
    defer cm.mu.Unlock()
    cm.m[key] = value
}

func (cm *ConcurrentMap) Range(f func(key, value interface{}) bool) {
    cm.mu.RLock()
    defer cm.mu.RUnlock()
    for k, v := range cm.m {
        if !f(k, v) {
            break
        }
    }
}

func main() {
    pairs := []struct{
        k string
        v int
    }{
        {"k1", 1},
        {"k2", 2},
        {"k3", 3},
        {"k4", 4},
    }

    {
        fmt.Println("創建map")
        cm := NewConcurrentMap()
        for i := range pairs {
            cm.Store(pairs[i].k, pairs[i].v)
        }
        fmt.Println(cm.m)

        fmt.Println("遍歷輸出map")
        cm.Range (func(k, v interface{}) bool {
            fmt.Printf("%v: %v\n", k, v)
            return true
        })

        fmt.Println("Load 和 LoadOrStore 方法")
        key := "k3"
        value, ok := cm.Load(key)
        fmt.Printf("Load %v: %v: %v\n", ok, key, value)
        value, ok = cm.LoadOrStore(key, 5)
        fmt.Printf("LoadOrStore %v: %v: %v\n", ok, key, value)
        key = "k5"
        value, ok = cm.Load(key)
        fmt.Printf("Load %v: %v: %v\n", ok, key, value)
        value, ok = cm.LoadOrStore(key, 5)
        fmt.Printf("LoadOrStore %v: %v: %v\n", ok, key, value)
        fmt.Println(cm.m)

        fmt.Println("Delete 方法")
        key = "k2"
        cm.Delete(key)
        fmt.Println(cm.m)
        key = "k21"
        cm.Delete(key)
        fmt.Println(cm.m)
    }
    fmt.Println("Over")
}

併發安全字典

Go言語官方是在Go 1.9中才正式加入了併發安全的字典類型sync.Map。所以在這之前,已經有很多庫提供了類似的數據結構,其中有一些的性能在很大程度上有效的避免了的鎖的依賴,性能相對會好一些。不過這些都是過去了,現在有了官方的併發安全的字典類型sync.Map可以使用。
下面就是使用併發安全字典的示例:

package main

import (
    "fmt"
    "sync"
)

func main() {
    pairs := []struct{
        k string
        v int
    }{
        {"k1", 1},
        {"k2", 2},
        {"k3", 3},
        {"k4", 4},
    }

    {
        fmt.Println("創建map")
        cm := sync.Map{}
        for i := range pairs {
            cm.Store(pairs[i].k, pairs[i].v)
        }

        fmt.Println("遍歷輸出map")
        cm.Range (func(k, v interface{}) bool {
            fmt.Printf("%v: %v\n", k, v)
            return true
        })

        fmt.Println("Load 和 LoadOrStore 方法")
        key := "k3"
        value, ok := cm.Load(key)
        fmt.Printf("Load %v: %v: %v\n", ok, key, value)
        value, ok = cm.LoadOrStore(key, 5)
        fmt.Printf("LoadOrStore %v: %v: %v\n", ok, key, value)
        key = "k5"
        value, ok = cm.Load(key)
        fmt.Printf("Load %v: %v: %v\n", ok, key, value)
        value, ok = cm.LoadOrStore(key, 5)
        fmt.Printf("LoadOrStore %v: %v: %v\n", ok, key, value)

        fmt.Println("Delete 方法")
        key = "k2"
        cm.Delete(key)
        key = "k21"
        cm.Delete(key)

        fmt.Println("遍歷輸出map")
        cm.Range (func(k, v interface{}) bool {
            fmt.Printf("%v: %v\n", k, v)
            return true
        })
    }
    fmt.Println("Over")
}

這裏用起來和上面原生加鎖的字典沒太大差別,其實上面的例子裏的方法名稱和功能應該就是參照這裏寫的。

併發安全字典與原生字典的比較

這裏從3個方面對併發安全字典和原生字典進行了比較。
算法複雜度
這個字典類型提供了一些常用的鍵值存取操作方法,並保證了這些操作的併發安全。同時,它的存、取、刪除等操作都可以基本保證在常數時間內執行完畢。就是說,它的算法複雜度與原生字典一樣都是O(1)的。
效率和鎖
與單純使用原生字典和互斥鎖的用法相比,使用併發安全字典可以顯著減少鎖的爭奪。雖然併發安全字典本身也用到了鎖,但是它在儘可能的避免使用鎖。使用鎖,就以爲着要把一些併發的操作強制串行化,這就會降低程序的性能。因此,在講原子操作的時候,就建議能用原子操作就不要用鎖。不過當時也說了,原子操作支持的數據類型有侷限性。
編譯器的支持
無論在何種場景下使用併發安全字典,都需要記得,它與原生字典是明顯不同的。併發安全字典只有Go語言標準庫中的一員,而原生字典是語言層面的東西。正是因爲這個原因,Go語言的編譯器不會對它的鍵和值進行特殊的類型檢查。它所有的方法涉及的鍵和值類型都是空接口。所以,必須在程序中自行保證它的鍵類型和值類型的正確性。

併發安全字典key類型的要求

原生字典的key不能是如下的類型:

  • 不能是,函數類型
  • 不能是,字典類型
  • 不能是,切片類型

併發安全字典的key的類型也是一樣的限制。
在併發安全字典內部,使用的存儲介質仍然是原生字典,上面這些類型都不能用。但是由於併發安全字典的key類型的實際類型是空接口,就是可以是任何類型,所以編譯器是允許使用任何類型的。如果使用了上述key不支持的類型,編譯階段是發現不了的。只要在程序運行期間才能確定鍵的實際類型,此時如果是不正確的key值實際類型肯定會引發panic。
因此,一定不要違反字典對key類型的限制。應該在每次操作併發安全字典的時候,都去顯式的檢查key值的實際類型。無論是存、取、刪除都是如此。
更好的做法是,把針對同一個併發安全字典的幾種操作都集中起來,然後統一的編寫檢查代碼。如果是把併發安全字典封裝在一個結構體裏,是一個很好的選擇。
總之,必須保證key的類型是可比較的(或者說是可判等的)。如果實在確定不了,那麼可以先通過調用reflect.TypeOf函數得到一個鍵對應的反射類型值(即:reflect.Type類型),然後再調用這個值的Comparable方法,得到確切的判斷結果。Comparable方法返回一個布爾值,判斷類型是否是可比較的,即:判斷是否可以作爲key的類型。

保證key和value的類型正確

簡單說,可以使用類型斷言表達式或者反射操作來保證類型正確。爲了進一步明確併發安全字典中key值的實際類型,這裏大致有兩個方案。

只能存儲特定的類型

這個是方案一:讓併發安全字典只能存儲某個特定類型的key。
指定key只能是某個類型,一旦完全確定了key的類型,就可以在存、取、刪除的時候,使用類型斷言表達式去對key的類型做檢查了。一般這種檢查並不繁瑣,而且要是把併發安全字典封裝在一個結構體裏,就更方便了。這時Go語言編譯器就可以幫助完成檢查的工作。就像下面這樣:

// 這是一個key爲字符串類型,value爲數字的字典
type StrIntMap struct {
    m sync.Map
}

func (ms *StrIntMap) Delete(key int) {
    ms.m.Delete(key)
}

func (ms *StrIntMap) Load(key int) (value int, ok bool) {
    v, ok := ms.m.Load(key)
    if v != nil {
        value = v.(int)
    }
    return
}

func (ms *StrIntMap) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool) {
    a, loaded := ms.m.LoadOrStore(key, value)
    actual = a.(int)
    return
}

func (ms *StrIntMap) Range(f func(key string, value int) bool) {
    f1 := func(key, value interface{}) bool {
        return f(key.(string), value.(int))
    }
    ms.m.Range(f1)
}

func (ms *StrIntMap) Store(key string, value int) {
    ms.m.Store(key, value)
}

把併發安全字典封裝到結構體中之後,要把原本字典中公開的方法在結構體上再實現一遍。只要簡單的調用併發安全字典裏的方法就可以了。由於自己寫的結構體的方法的參數列表裏已經明確的定義了變量的類型,所以就不用再做類型檢查了,並且使用編譯器編寫代碼的時候類型錯誤也會有提示。基於上面的情況,使用這些方法取出key和value的時候,完全不用擔心類型不正確,因爲正確性在最初存入的時候就已經保證了。這裏在取出值的時候,由於sync.Map返回的是空接口,所以需要用類型斷言做一下類型轉換。這裏用類型斷言的時候只使用一個返回值,直接轉,不用擔心會有錯誤。

使用反射

上一個方案雖然很好,但是有一點不方便。就是封裝了一大堆內容,實現了一個key-value的類型。如果還是需要另外一個類型的字典,還得再封裝同樣的一大堆的內容。這樣的話,在需求多樣化之後,工作量反而更大,而且會有很多雷同的代碼。
這裏需要一個這樣效果的方案:既要保持sync.Map類型原有的靈活性,又可以約束key和value的類型。
這個是方案二:就是通過反射來實現做類型的判斷

結構體的類型

這次在設計結構體類型的時候,只包含sync.Map類型的字段就不夠了,需要向下面這樣:

type ConcurrentMap struct {
    m         sync.Map
    keyType   reflect.Type
    valueType reflect.Type
}

這次多定義了2個字段keyType和valueType,分別用於保存key類型和value類型。類型都是reflect.Type,就是反射類型。這個類型可以代表Go語言的任何數據類型,並且類型的值也非常容易獲得:就是通過reflect.TypeOf函數並把某個樣本值傳入即可,比如:reflect.TypeOf(int(3)),就是int類型的反射類型值。

構造函數

首先提供一個方法,讓外部的代碼可以創建一個結構體:

func NewConcurrentMap(keyType, valueType reflect.Type) (*ConcurrentMap, error) {
    if keyType == nil {
        return nil, fmt.Errorf("key 類型爲 nil")
    }
    if !keyType.Comparable() { // 判斷類型是否可以比較,就是是否是內做key的類型
        return nil, fmt.Errorf("不可比較的類型: %s", keyType)
    }
    if valueType == nil {
        return nil, fmt.Errorf("value 類型爲 nil")
    }
    cm := &ConcurrentMap{
        keyType:   keyType,
        valueType: valueType,
    }
    return cm, nil
}

創建成功,則返回結構體的指針和nil的錯誤類型。創建失敗,則返回nil和具體的錯誤類型。
先判斷傳入的類型是否爲nil。另外對於key,基於對key類型的限制,必須是可比較的,這裏用reflect包裏的Comparable方法就可以進行判斷了。

Load方法

結構體有了,接下來就實現所有的方法。首先是Load方法:

func (cm *ConcurrentMap) Load(key interface{}) (value interface{}, ok bool) {
    if reflect.TypeOf(key) != cm.keyType{
        return
    }
    return cm.m.Load(key)
}

這裏的類型檢查的代碼非常簡單。如果參數key的反射類型與keyTpye字段的反射類型不相等,那麼就直接返回空值。類型都不對,那麼字典裏是一定沒有這個key的,所以就直接返回查詢不到的結果。這時,返回的是nil和false,完全符合Load方法原本的含義。

Store方法

Stroe方法接收2個空接口類型,沒有返回值:

func (cm *ConcurrentMap) Store(key, value interface{}) {
    if reflect.TypeOf(key) != cm.keyType {
        panic(fmt.Errorf("key類型不符合: 實際類型: %v, 需要類型: %v\n", reflect.TypeOf(key), cm.keyType))
    }
    if reflect.TypeOf(value) != cm.valueType {
        panic(fmt.Errorf("value類型不符合: 實際類型: %v, 需要類型: %v\n", reflect.TypeOf(value), cm.valueType))
    }
}

這裏的做法是一旦類型檢查不符,就直接引發panic。這麼做主要是由於Store方法沒有結果聲明,就是無返回值,所以在參數值有問題的時候,無法通過比較平和的方式告知調用方。這麼做也是符合Store方法的原本含義的。
也可以把結構體的Store方法改的和sysc.Map裏的Store方法稍微不一樣一點,就是加上結果聲明,返回一個error類型。這樣當參數值類型不正確的時候,就返回響應的錯誤,而不引發panic。作爲示例,就先用panic的方式吧。在實際的應用場景裏可以再做優化和改進。

完整的定義

其他的方法就都差不多了,下面展示了所有定義的方法:

type ConcurrentMap struct {
    m         sync.Map
    keyType   reflect.Type
    valueType reflect.Type
}

func NewConcurrentMap(keyType, valueType reflect.Type) (*ConcurrentMap, error) {
    if keyType == nil {
        return nil, fmt.Errorf("key 類型爲 nil")
    }
    if !keyType.Comparable() { // 判斷類型是否可以比較,就是是否是內做key的類型
        return nil, fmt.Errorf("不可比較的類型: %s", keyType)
    }
    if valueType == nil {
        return nil, fmt.Errorf("value 類型爲 nil")
    }
    cm := &ConcurrentMap{
        keyType:   keyType,
        valueType: valueType,
    }
    return cm, nil
}

func (cm *ConcurrentMap) Load(key interface{}) (value interface{}, ok bool) {
    if reflect.TypeOf(key) != cm.keyType {
        return
    }
    return cm.m.Load(key)
}

func (cm *ConcurrentMap) Store(key, value interface{}) {
    if reflect.TypeOf(key) != cm.keyType {
        panic(fmt.Errorf("key類型不符合: 實際類型: %v, 需要類型: %v\n", reflect.TypeOf(key), cm.keyType))
    }
    if reflect.TypeOf(value) != cm.valueType {
        panic(fmt.Errorf("value類型不符合: 實際類型: %v, 需要類型: %v\n", reflect.TypeOf(value), cm.valueType))
    }
    cm.m.Store(key, value)
}

func (cm *ConcurrentMap) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool) {
    if reflect.TypeOf(key) != cm.keyType {
        panic(fmt.Errorf("key類型不符合: 實際類型: %v, 需要類型: %v\n", reflect.TypeOf(key), cm.keyType))
    }
    if reflect.TypeOf(value) != cm.valueType {
        panic(fmt.Errorf("value類型不符合: 實際類型: %v, 需要類型: %v\n", reflect.TypeOf(value), cm.valueType))
    }
    actual, loaded = cm.m.LoadOrStore(key, value)
    return
}

func (cm *ConcurrentMap) Delete(key interface{}) {
    if reflect.TypeOf(key) != cm.keyType {
        return
    }
    cm.m.Delete(key)
}

func (cm *ConcurrentMap) Range(f func(key, value interface{}) bool) {
    cm.m.Range(f)
}

總結

第一種方案,適用於可以完全確定key和value的具體類型的情況。這是,可以利用Go語言的編譯器去做類型檢查,並用類型斷言表達式作爲輔助。缺陷是一次定義只能滿足一種字典類型,一旦字典類型多樣化,就需要編寫大量的重複代碼。不過還有一個好處,就是在編譯階段就可以發現類型不正確的問題。
第二種方案,在程序運行之前不用明確key和value的類型,只要在初始化併發安全字典的時候,動態的給定key和value的類型即可。主要是用到了reflect包中的方法和數據類型。靈活性高了,一次就能滿足所有的字典類型。但是那些反射操作或多或少都會降低程序的性能。而且如果有類型不符合的情況,無法在編譯階段發現。

併發安全字典內部的實現

保證併發安全,還要保證效率,主要的問題就是要儘量避免使用鎖。
sync.Map類型在內部使用了大量的原子操作來存取key和value,並使用了兩個原生的字典作爲存儲介質。

只讀字典

其中一個原生的字典是sync.Map的read字段,類型是sync/atomic.Value類型。這個原生字典可以被看作一個快照,總會在條件滿足時,去重新保存所屬的sync.Map值中包含的所有key-value對。這裏叫它只讀字典,它雖然不會增減其中的key,但是允許改變其中的key所對應的value。所以這個只讀特性只是對於所有的key的。
因爲是sync/atomic.Value類型,所以都是原子操作,不用鎖。另外,這個只讀字典在存儲key-value對的時候,對value還做了一層封裝。先把值轉換爲了unsafe.Pointer類型的值,然後再把unsafe.Pointer也封裝,並存儲在其中的原生字典中。如此一來,在變更某個key所對應的value的時候,就可以使用原子操作了。

髒字典

另一個原生字典是sync.Map的dirty字段,它的key類型是空接口。它存儲key-value對的方式與read字段中的原生字典一致,並且也是把value的值先做轉換和封裝後再進行存儲的。這裏叫它髒字典
有點不太好理解,看下源碼裏的類型定義幫助理解只讀字典和髒字典的類型:

type Map struct {
    mu Mutex
    read atomic.Value // readOnly
    dirty map[interface{}]*entry
    misses int
}

// readOnly is an immutable struct stored atomically in the Map.read field.
type readOnly struct {
    m       map[interface{}]*entry
    amended bool // true if the dirty map contains some key not in m.
}

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

存取值的過程

瞭解下sync.Map是如何巧妙的使用它的2個原生字典的。分析各個方法在執行時具體進行的操作,再判斷這些操作對性能的影響。

獲取值的過程
sync.Map在查找指定的key所對應的value的時候,會先去只讀字典中查找,只讀字典是原子操作,不要要鎖。只有當只讀字典裏沒有,再去髒字典裏查找,訪問髒字典需要加鎖。

存儲值的過程
在存key-value的時候,只要只讀字典中已經存在這個key了,並且未被標記爲刪除,就會把新值直接存到value裏然後直接返回,還是不需要加鎖。否則,纔會加鎖後,把新的key-value加到髒字典裏。這個時候還要抹去只讀字典裏的刪除標記。

刪除標記
當一個kye-value對應該被刪除,但是仍然存在於只讀字典中的時候,會用標記來標記刪除,但是還不會直接武林刪除。
這種情況會在重建髒字典以後的一段時間內出現。不過,過不了多久,還是會被真正的刪除掉的。在查找和遍歷的時候,有刪除標記的key-value對會忽略的。

刪除的過程
刪除key-value對,sync.Map會先檢查只讀字典中是否有對應的key。如果沒有,可能會在髒字典中,那就加鎖然後試圖從髒字典裏刪掉該key-value對。最後,sync.Map會把key-value對中指向value的那個指針置爲nil,這是另一種邏輯刪除的方式,髒字點重建之後自然就真正刪除了。

重建髒字典
只讀字典和髒字典之間是會互相轉換的。在髒字典中查找key-value對足夠次數時,sync.Map會把髒字典直接作爲只讀字典,保存到read字段中。然後把dirty字段置爲nil。之後一旦再有新的key-value存入,就會依據只讀字典去重建髒字典,會把只讀字典標記爲刪除的key-value對過濾掉。這些轉換操作都有髒字典,所以都需要加鎖。

小結
sync.Map的只讀字典和髒字典中的key-value對並不是實時同步的。由於只讀字典的key不能改變,所以其中的key-value對可能是不全的。而髒字典中的key-value對總是全的,不多也不少。新的key-value對只讀字典裏沒有;已經標記刪除的key-value對物理上還在只讀字典裏,只是加了標記。
在讀操作爲主,而寫操作很少的情況下,併發安全字典的性能會更好。在幾個寫操作中,新增key-value對的操作對併發安全字典的性能影響最大,其次是刪除操作。修改操作影響略小,只讀字典裏如果有這個key的話,並且沒有標記刪除,直接在只讀字典裏把value改掉,不用加鎖,對性能影響就會很小。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章