大家好,我是煎魚。
初入 Go 語言的大門,有不少的小夥伴會快速的 3 天精通 Go,5 天上手專案,14 天上線業務迭代,21 天排查、定位問題,順帶捎個反省報告。
其中最常見的初級錯誤,Go 面試較最愛問的問題之一:
(來自讀者的提問)
為什麼在 Go 語言裡,map 和 slice 不支援併發讀寫,也就是是非執行緒安全的,為什麼不支援?
見招拆招後,緊接著就會開始討論如何讓他們倆 ”冤家“ 支援併發讀寫?
今天我們這篇文章就來理一理,瞭解其前因後果,一起吸魚學懂 Go 語言。
非執行緒安全的例子
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 更應適配典型使用場景,而不是為了小部分情況,導致大部分程式付出代價(效能),決定了不支援。
總結
在今天這篇文章中,我們針對 Go 語言中的 map 和 slice 進行了基本的介紹,也對不支援併發讀者的場景進行了模擬展示。
同時也針對業內常見的支援併發讀寫的方式進行了講述,最後分析了不支援的原因,讓我們對整個前因後果有了一個完整的瞭解。
不知道你在日常是否有遇到過 Go 語言中非線性安全的問題呢,歡迎你在評論區留言和大家一起交流!
若有任何疑問歡迎評論區反饋和交流,最好的關係是互相成就,各位的點贊就是煎魚創作的最大動力,感謝支援。
文章持續更新,可以微信搜【腦子進煎魚了】閱讀,本文 GitHub github.com/eddycjy/blog 已收錄,歡迎 Star 催更。