碼住!Golang併發安全與引用傳遞總結

語言: CN / TW / HK

導語 |  因為現在服務上雲的趨勢,業務程式碼都紛紛轉向golang的技術棧。在遷移或使用的過程中,由於對golang特性的生疏經常會遇到一些問題,本文總結了golang併發安全和引數引用傳值時的一些知識。

一、Map型別併發讀寫引發Fatal Error

先看一個在Go中關於Map型別併發讀寫的經典例子:

var testMap  = map[string]string{}
func main() {
go func() {
for{
_ = testMap["bar"]
}
}()
go func() {
for {
testMap["bar"] = "foo"
}
}()
select{}
}

以上例子會引發一個Fatal error:

fatal error: concurrent map read and map write

產生這個錯誤的原因就是在Go中Map型別並不是併發安全的,出於安全的考慮,此時會引發一個致命錯誤以保證程式不出現資料的混亂。

二、Go如何檢測Map併發異常

在Go原始碼map.go中,可以看到以下flags:

// flags
iterator = 1 // there may be an iterator using buckets
oldIterator = 2 // there may be an iterator using oldbuckets
hashWriting = 4 // a goroutine is writing to the map
sameSizeGrow = 8 // the current map growth is to a new map of the same size

在原始碼中mapaccess1、mapaccess2都用於查詢mapassign和mapdelete用於修改。

對於查詢操作,大致檢查併發錯誤的流程如下:在查詢前檢查併發flag是否存在,如果存在就丟擲異常。

if h.flags&hashWriting != 0 {
throw("concurrent map read and map write")
}

對於修改操作則如下:

  • 寫入前檢查一次標記位,通過後打上標記。

  • 寫入完成再次檢查標記位,通過後還原標記。

   //各類前置操作
....
if h.flags&hashWriting != 0 {
//檢查是否存在併發
throw("concurrent map writes")
}


//賦值標記位
h.flags ^= hashWriting
....
//後續操作
done:
//完成修改後,再次檢查標記位
if h.flags&hashWriting == 0 {
throw("concurrent map writes")
}
//還原標記位取消hashWriting標記
h.flags &^= hashWriting

三、如何避免Map的併發問題

go官方認為因為Map併發的問題在實際開發中並不常見,如果把Map原生設計成併發安全的會帶來巨大的效能開銷。因此需要使用額外方式來實現。

(一)自行使用鎖和map來解決併發問題

參考如下:

type cocurrentMap = struct {
sync.RWMutex
m map[string]string
}


func main() {
var testMap = &cocurrentMap{m:make(map[string]string)}
//寫
testMap.Lock()
testMap.m["a"] = "foo"
testMap.Unlock()
//讀
testMap.RLock()
fmt.Println(testMap.m["a"])
testMap.RUnlock()
}

這個方法存在問題就是併發量巨大的時候,鎖的競爭也會帶來巨量消耗,效能 一般。

(二)使用sync.Map

sync.Map通過巧妙的設計來提高併發安全下Map的效能,其設計思路是通過空間換時間來實現的,同時維護2份資料,read&dirty。read主要用來避免讀寫衝突。

其資料結構如下:

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


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{}
}

使用示例如下:

var m sync.Map
// 寫
m.Store("test", 1)
m.Store(1, true)


// 讀
val1, _ := m.Load("test")
val2, _ := m.Load(1)
fmt.Println(val1.(int))
fmt.Println(val2.(bool))


//遍歷
m.Range(func(key, value interface{}) bool {
//....
return true
})


//刪除
m.Delete("test")


//讀取或寫入
m.LoadOrStore("test", 1)

這裡對sync.Map的原理不做深入展開,只提幾點特性:

  • read和dirty是共享記憶體的,儘量減少冗餘記憶體的開銷。

  • read是原子性的,可以併發讀,寫需要加鎖。

  • 讀的時候先read中取,如果沒有則會嘗試去dirty中讀取(需要有標記位readOnly.amended配合)

  • dirty就是原生Map型別,需要配合各類鎖讀寫。

  • 當read中miss次數等於dirty長度時,dirty會提升為read,並且清理已經刪除的k-v(延遲更新,具體如何清理需要enrty中的p標記位配合)

  • 雙檢查(在加鎖後會再次對值檢查一遍是否依然符合條件)

  • sync.Map適用於讀多寫少的場景。

  • sync.Map沒有提供獲取長度size的方法,需要通過遍歷來計算。

四、切片型別Slice是併發安全的嗎

與Map一樣,Slice也不是併發安全的:

var testSlice []int
func main() {
for i:=0; i<1000; i++ {
go func() {
testSlice = append(testSlice, i)
}()
}
for idx, val := range testSlice {
fmt.Printf("idx:%d val:%d\n", idx, val)
}
}

可以看到輸出如下:

........

idx:901 val:999

idx:902 val:999

.........

但是在切片中並不會引發panic,如果程式無意中對切片使用了併發讀寫,嚴重的話會導致獲取的資料和之後儲存的資料錯亂,所以這裡要格外小心,可以通過加鎖來避免。

五、Map、Slice作為引數傳遞的問題

切片除了併發有問題外,當他作為引數傳遞的時候,也會導致意料之外的問題,Go官方說明在Go中所有的傳遞都是值傳遞,沒有引用傳遞的問題,但是在實際使用時,切片偶爾會引起一些疑惑,例如以下情況:

func changeVal(testSlice []string, idx int, val string){
testSlice[idx] = val
}


func main() {
var testSlice []string
testSlice = make([]string, 5)
testSlice[0] = "foo"
changeVal(testSlice, 0, "bar")
fmt.Println(testSlice[0])
}

以上程式碼執行後可以看到打印出的值為:

bar

這裡就奇怪了,如果按照Go官方說明在該語言中傳遞都是值傳遞的話,為什麼函式內修改切片會導致原切片也一起修改呢?這裡要分2個問題來看:

  • Go只會對基礎值型別在傳參中使用深拷貝,實際上對於Slice和Map型別,使用的是淺拷貝,Slice作為傳參,其指向的記憶體地址依然是原資料。

  • Slice擴容機制的影響:向Slice中新增元素超出容量的時候,我們知道會觸發擴容機制,而擴容機制會建立一份新的【原資料】此時,它與淺拷貝獲取到的變數是沒有任何關聯的。

可以通過以下程式碼驗證,我們故意構造觸發擴容的場景:

func appendVal(testSlice []string, val string){
fmt.Printf("testSlice:%p\n", testSlice)
testSlice = append(testSlice, "addCap") //觸發了擴容機制
fmt.Printf("after append testSlice:%p\n", testSlice)
testSlice[0] = val
}


func main() {
var testSlice []string
testSlice = make([]string, 5)
testSlice[0] = "foo"
appendVal(testSlice,"bar")
fmt.Println(testSlice[0]) //此時打印出的值為foo
}

可以看到控制檯列印如下:

testSlice:0xc00005a050

after append testSlice:0xc0000700a0

foo

此時因為擴容的影響導致原切片和傳遞後的切片不再有關聯,因此列印值回到了最初的原資料foo

除了擴容機制外,我們也可以利用go中的copy函式來強制深拷貝:

var newTestSlice []string
newTestSlice = make([]string, len(testSlice))
copy(newTestSlice, testSlice)
fmt.Printf("testSlice:%p\n", testSlice)
fmt.Printf("newTestSlice:%p\n", newTestSlice)

testSlice:0xc0000d6000

newTestSlice:0xc0000d6050

另外對於陣列型別,如果無意中轉換為切片時,也極容易導致這種不確定性發生。切片作為引數傳遞時,在函式內對切片進行修改,需要時刻注意。

回過頭再來看Map就一目瞭然了,因為Map的操作物件一直是引用,其即使擴容後,引用的地址不會改變,所以不會出現時而可以修改,時而不能修改的情況:

func changeMap(testMap map[string]string, k string, v string){
testMap[k] = v
}


func main() {
var testMap map[string]string
testMap = make(map[string]string)
testMap["foo"] = "bar"
changeMap(testMap, "foo", "rab")
fmt.Println(testMap)
}

輸出:map[foo:rab]

可以看到函式內修改了原引數的值。

六、總結

Go因為其簡潔的語法和高效的效能在當今微服務領域笑傲江湖,但是其本身語言特性在使用時,也會帶來不少坑,本文總結了併發場景和引數傳遞時容易引發的問題,從而注意避免這些情況的發生。

作者簡介

徐世佳

騰訊IEG運營開發工程師

騰訊IEG運營開發工程師,負責騰訊遊戲營銷活動開發,有豐富的大流量高併發活動開發經驗。

推薦閱讀

福利

我為大家整理了一份 從入門到進階的Go學習資料禮包 ,包含學習建議:入門看什麼,進階看什麼。 關注公眾號 「polarisxu」,回覆  ebook  獲取;還可以回覆「 進群 」,和數萬 Gopher 交流學習。