您好,登錄后才能下訂單哦!
之前的幾篇,幾乎已經把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值實際類型肯定會引發panic。
因此,一定不要違反字典對key類型的限制。應該在每次操作并發安全字典的時候,都去顯式的檢查key值的實際類型。無論是存、取、刪除都是如此。
更好的做法是,把針對同一個并發安全字典的幾種操作都集中起來,然后統一的編寫檢查代碼。如果是把并發安全字典封裝在一個結構體里,是一個很好的選擇。
總之,必須保證key的類型是可比較的(或者說是可判等的)。如果實在確定不了,那么可以先通過調用reflect.TypeOf函數得到一個鍵對應的反射類型值(即:reflect.Type類型),然后再調用這個值的Comparable方法,得到確切的判斷結果。Comparable方法返回一個布爾值,判斷類型是否是可比較的,即:判斷是否可以作為key的類型。
簡單說,可以使用類型斷言表達式或者反射操作來保證類型正確。為了進一步明確并發安全字典中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方法:
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方法原本的含義。
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改掉,不用加鎖,對性能影響就會很小。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。