天天看點

為什麼 Go map 和 slice 是非線程安全的?

非線程安全的例子

slice

我們使用多個 goroutine 對類型為 slice 的變量進行操作,看看結果會變的怎麼樣。

如下:

func main() {
 var s []string
 for i := 0; i < 9999; i++ {
  go func() {
   s = append(s, "腦子進煎魚了")
  }()
 }

 fmt.Printf("進了 %d 隻煎魚", len(s))
}           

輸出結果:

// 第一次執行
進了 5790 隻煎魚
// 第二次執行
進了 7370 隻煎魚
// 第三次執行
進了 6792 隻煎魚           

你會發現無論你執行多少次,每次輸出的值大機率都不會一樣。也就是追加進 slice 的值,出現了覆寫的情況。

是以在循環中所追加的數量,與最終的值并不相等。且這種情況,是不會報錯的,是一個出現率不算高的隐式問題。

這個産生的主要原因是程式邏輯本身就有問題,同時讀取到相同索引位,自然也就會産生覆寫的寫入了。

map

同樣針對 map 也如法炮制一下。重複針對類型為 map 的變量進行寫入。

func main() {
 s := make(map[string]string)
 for i := 0; i < 99; i++ {
  go func() {
   s["煎魚"] = "吸魚"
  }()
 }

 fmt.Printf("進了 %d 隻煎魚", len(s))
}           
fatal error: concurrent map writes

goroutine 18 [running]:
runtime.throw(0x10cb861, 0x15)
        /usr/local/Cellar/go/1.16.2/libexec/src/runtime/panic.go:1117 +0x72 fp=0xc00002e738 sp=0xc00002e708 pc=0x1032472
runtime.mapassign_faststr(0x10b3360, 0xc0000a2180, 0x10c91da, 0x6, 0x0)
        /usr/local/Cellar/go/1.16.2/libexec/src/runtime/map_faststr.go:211 +0x3f1 fp=0xc00002e7a0 sp=0xc00002e738 pc=0x1011a71
main.main.func1(0xc0000a2180)
        /Users/eddycjy/go-application/awesomeProject/main.go:9 +0x4c fp=0xc00002e7d8 sp=0xc00002e7a0 pc=0x10a474c
runtime.goexit()
        /usr/local/Cellar/go/1.16.2/libexec/src/runtime/asm_amd64.s:1371 +0x1 fp=0xc00002e7e0 sp=0xc00002e7d8 pc=0x1063fe1
created by main.main
        /Users/eddycjy/go-application/awesomeProject/main.go:8 +0x55
           

好家夥,程式運作會直接報錯。并且是 Go 源碼調用 throw 方法所導緻的緻命錯誤,也就是說 Go 程序會中斷。

不得不說,這個并發寫 map 導緻的 fatal error: concurrent map writes 錯誤提示。我有一個朋友,已經看過少說幾十次了,不同組,不同人...

是個日經的隐式問題。

如何支援并發讀寫

對 map 上鎖

實際上我們仍然存在并發讀寫 map 的訴求(程式邏輯決定),因為 Go 語言中的 goroutine 實在是太友善了。

像是一般寫爬蟲任務時,基本會用到多個 goroutine,擷取到資料後再寫入到 map 或者 slice 中去。

Go 官方在 Go maps in action 中提供了一種簡單又便利的方式來實作:

var counter = struct{
    sync.RWMutex
    m map[string]int
}{m: make(map[string]int)}           

這條語句聲明了一個變量,它是一個匿名結構(struct)體,包含一個原生和一個嵌入讀寫鎖 sync.RWMutex。

要想從變量中中讀出資料,則調用讀鎖:

counter.RLock()
n := counter.m["煎魚"]
counter.RUnlock()
fmt.Println("煎魚:", n)           

要往變量中寫資料,則調用寫鎖:

counter.Lock()
counter.m["煎魚"]++
counter.Unlock()           

這就是一個最常見的 Map 支援并發讀寫的方式了。

sync.Map

前言

雖然有了 Map+Mutex 的極簡方案,但是也仍然存在一定問題。那就是在 map 的資料量非常大時,隻有一把鎖(Mutex)就非常可怕了,一把鎖會導緻大量的争奪鎖,導緻各種沖突和性能低下。

常見的解決方案是分片化,将一個大 map 分成多個區間,各區間使用多個鎖,這樣子鎖的粒度就大大降低了。不過該方案實作起來很複雜,很容易出錯。是以 Go 團隊到比較為止暫無推薦,而是采取了其他方案。

該方案就是在 Go1.9 起支援的 sync.Map,其支援并發讀寫 map,起到一個補充的作用。

具體介紹

Go 語言的 sync.Map 支援并發讀寫 map,采取了 “空間換時間” 的機制,備援了兩個資料結構,分别是:read 和 dirty,減少加鎖對性能的影響:

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

其是專門為 append-only 場景設計的,也就是适合讀多寫少的場景。這是他的優點之一。

若出現寫多/并發多的場景,會導緻 read map 緩存失效,需要加鎖,沖突變多,性能急劇下降。這是他的重大缺點。

提供了以下常用方法:

func (m *Map) Delete(key interface{})
func (m *Map) Load(key interface{}) (value interface{}, ok bool)
func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool)
func (m *Map) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool)
func (m *Map) Range(f func(key, value interface{}) bool)
func (m *Map) Store(key, value interface{})
           

Delete:删除某一個鍵的值。

Load:傳回存儲在 map 中的鍵的值,如果沒有值,則傳回 nil。ok 結果表示是否在 map 中找到了值。

LoadAndDelete:删除一個鍵的值,如果有的話傳回之前的值。

LoadOrStore:如果存在的話,則傳回鍵的現有值。否則,它存儲并傳回給定的值。如果值被加載,加載的結果為 true,如果被存儲,則為 false。

Range:遞歸調用,對 map 中存在的每個鍵和值依次調用閉包函數 f。如果 f 傳回 false 就停止疊代。

Store:存儲并設定一個鍵的值。

實際運作例子如下:

var m sync.Map

func main() {
 //寫入
 data := []string{"煎魚", "鹹魚", "烤魚", "蒸魚"}
 for i := 0; i < 4; i++ {
  go func(i int) {
   m.Store(i, data[i])
  }(i)
 }
 time.Sleep(time.Second)

 //讀取
 v, ok := m.Load(0)
 fmt.Printf("Load: %v, %v\n", v, ok)

 //删除
 m.Delete(1)

 //讀或寫
 v, ok = m.LoadOrStore(1, "吸魚")
 fmt.Printf("LoadOrStore: %v, %v\n", v, ok)

 //周遊
 m.Range(func(key, value interface{}) bool {
  fmt.Printf("Range: %v, %v\n", key, value)
  return true
 })
}
           
Load: 煎魚, true
LoadOrStore: 吸魚, false
Range: 0, 煎魚
Range: 1, 吸魚
Range: 3, 蒸魚
Range: 2, 烤魚
           

為什麼不支援

Go Slice 的話,主要還是索引位覆寫問題,這個就不需要糾結了,勢必是程式邏輯在編寫上有明顯缺陷,自行改之就好。

但 Go map 就不大一樣了,很多人以為是預設支援的,一個不小心就翻車,這麼的常見。那憑什麼 Go 官方還不支援,難不成太複雜了,性能太差了,到底是為什麼?

原因如下(via @go faq):

典型使用場景:map 的典型使用場景是不需要從多個 goroutine 中進行安全通路。

非典型場景(需要原子操作):map 可能是一些更大的資料結構或已經同步的計算的一部分。

性能場景考慮:若是隻是為少數程式增加安全性,導緻 map 所有的操作都要處理 mutex,将會降低大多數程式的性能。

彙總來講,就是 Go 官方在經過了長時間的讨論後,認為 Go map 更應适配典型使用場景,而不是為了小部分情況,導緻大部分程式付出代價(性能),決定了不支援。

如果你想開發小程式或者了解更多小程式的内容,可以通過專業開發公司,來幫助你實作開發需求:

廈門在乎科技

-專注

廈門小程式開發公司

、app開發、網站開發

繼續閱讀