Ref: https://golang.org/ref/mem
- 簡介
golang內存模型,主要說明了如下問題。在一個goroutine中讀取變量,而該變量是由其他goroutine賦值的,這種情況下如何能夠安全正確的讀取。
- 建議
對於有多個goroutine在使用的變量,修改時需要序列化的讀取。
主要方式包括,通過channel的方式、sync/atomic等原子同步操作等。
如果你想讀完以下內容,以便理解你的程序內在運行機制,說明你很聰明。
但是不建議你這么聰明~
- 歷史經驗
只有一個goroutine的時候,讀寫操作都會如程序定義的順序執行。這是因為,盡管編譯器和中央處理器是可能會改變執行順序,但並不會影響編程語言定義的goroutine中的行為邏輯。但也是因為可能改變執行順序,同樣的操作在不同的goroutine中觀察到的執行順序並不一致。比如A goroutine執行了a=1;b=2;另一個goroutine觀察到的結果可能是b先於a被賦值了。
為具體說明讀寫操作的要求,我們定義了之前某個版本中Golang程序中內存操作的一部分邏輯如下。如果事件e1發生在事件e2之前,我們說e2發生在e1之后.如果e1並不發生在e2之前,也不發生e2在之后,我們說e1和e2是同步發生的。
在一個單goroutine的程序中,事件發生的順序就是程序描述的順序。
滿足如下兩個條件的情況下,對變量v的讀取操作r,是能夠觀察到對v的寫入操作w的:
- r並不發生在w之前;
- r之前,w之后,再沒有其他對v的寫操作;
為了保證對變量v對讀取操作r,能夠觀察到特定的對v得寫操作w,需要保證w是r唯一能夠觀察到寫操作。因此,要保證r能夠觀察到w需要滿足如下兩個條件:
- w發生在r之前;
- 其他對v的寫操作,要么發生在w之前,要么發生在r之后;
這對條件是比第一個條件更加嚴格,它要求r和w的同時,沒有其它的寫操作(即和r或w同步的寫操作);
在單一goroutine里面,沒有同步操作,所以以上兩組條件是等價的。但是對於多goroutine,需要通過同步事件來確定順序發生,從而保證讀操作能夠觀察到寫操作。
在內存模型里面,變量v初始化為零值,也是一種寫操作。
讀寫大於單一機器碼的變量的動作,實際操作順序不定,
- 同步
- 初始化
程序初始化在單一goroutine里面,但是goroutine會創建其他goroutine,然后多個goroutine同步執行。
如果package p引用了package q,q的init函數的執行,會先於所有p的函數執行。
Main.main函數的執行,在所有init函數執行完后。
-
- Goroutine的創建
go表達式,會創建一個goroutine,然后該goroutine才能開始執行。
var a string func f() { print(a) } func hello() { a = "hello, world" go f() }
以上代碼示例,調用hello函數,可能在hello已經return到時候,f才回執行print。
-
- Goroutine的銷毀
goroutine的退出時機並沒有保證一定會在某個事件之前。
var a string
func hello() {
go func() { a = "hello" }()
print(a)
}
比如以上示例,對a的賦值,並不保證與hello本身的任何動作保持同步關系,所以也不能保證被其他任何goroutine的讀操作觀察到。事實上,任何一個激進的編譯器都會把這里整個go表達式直接刪掉,不做編譯。
如果一個goroutine的影響想被其他的goroutine觀察到,必須通過同步機制(比如鎖、channel)來確定相對順序關系。
-
- Channel通信
channel通信是goroutines之間主要的同步方式。一般來說channel上的每一次send都會相應有另一個goroutine從此channel受到消息。
同一個channel上,send操作總是先於相應的receive操作完成。
var c = make(chan int, 10) var a string func f() { a = "hello, world" c <- 0 } func main() { go f() <-c print(a) }
以上示例,能夠保證print出『hello, world』。對a的寫,是先於往c中發送0,而從c中接收值,先於print。
channel的關閉,先於接收到該channel關閉而發出來的0.
在上面這個例子中,用close(c)代替 c<-0,其效果是一樣的。
對於沒有緩存的channel,receive發生在send完成之前。
var c = make(chan int) var a string func f() { a = "hello, world" <-c } func main() { go f() c <- 0 print(a) }
以上示例,依舊能夠保證print出『hello, world』。對a的寫,先於從c接收;從c接收,先於 c <- 0執行完; c <- 0執行完,先於print執行。
但如果channel是緩存的(例如c = make(chan int, 1)),那么以上程序不能保證print出『hello, world』,甚至有可能出現空值、crash等情況;
對於緩存容量為C的channel,第k次接收,先於K+C次發送完成。
這條規則概括了緩存和非緩存channel的規則。因此基於帶緩存的channel,可以實現令牌策略:在channel中緩存的數量代表active的數量;channel的緩存容量表示最大可以使用的數量;發送消息表示申請了一個令牌,接收消息表示釋放了一塊令牌。這是限制並發常用的一種手段。
var limit = make(chan int, 3) func main() { for _, w := range work { go func(w func()) { limit <- 1 w() <-limit }(w) } select{} }
以上示例程序,對於work list中的每一條,都創建了一個goroutine,但是用limit這個帶緩存的channel來限制了,最多同時只能有3個goroutines來執行work方法。
-
- 鎖機制
sync包中實現了兩個鎖的數據類型,分別是sync.Mutex和sync.RWMutex
對於任何的sync.Mutex和sync.RWMutex類型變量l,和n<m,對於l.Unlock()的調用n,總是先於對於l.Lock()的調用m。
var l sync.Mutex var a string func f() { a = "hello, world" l.Unlock() } func main() { l.Lock() go f() l.Lock() print(a) }
如上示例能夠保證print出『hello, world』。f中第一個l.Unlock()的調用,先於main中第二個l.Lock()的調用;第二個l.Lock()的調用先於print的調用;
任何對於l.Rlock的調用(其中l為sync.RWMutex類型變量),總是有一個n,l.Lock在調用n執行l.Unlock之后才能return;對應的,l.RUnlock的執行在調用n+1執行l.Unlock之前。
-
- 單例(Once)
Sync包提供了一種安全的多goroutine種初始化機制,那就是Once類型。對於特定的方法f,多個線程都能調用Once.Do(f),但是只有一個線程會執行f,其他線程的調用都會阻塞住,直到f執行完。
對於Once.Do(f),有且僅有一次調用會被真正執行,而且在其他被的調用返回之前執行完。
var a string var once sync.Once func setup() { a = "hello, world" } func doprint() { once.Do(setup) print(a) } func twoprint() { go doprint() go doprint() }
這里print兩次『hello, world』,但只有第一次調用doprint會執行setup賦值。
- 不正確的同步
對於同步發生的讀操作r和寫操作w,r有可能觀察到w。但即使發生了這種情況,不代表r之后的讀操作,也能觀察到w之前的寫操作。
var a, b int func f() { a = 1 b = 2 } func g() { print(b) print(a) } func main() { go f() g() }
如上例子,g打印出2,然后是0.這個事實顛覆了我們的一些習慣認知。
對於同步問題加鎖一定要double check。
var a string
var done bool
func setup() {
a = "hello, world"
done = true
}
func doprint() {
if !done {
once.Do(setup)
}
print(a)
}
func twoprint() {
go doprint()
go doprint()
}
如這個例子,不能保證觀察到done的寫操作時候,也能觀察到對a的寫操作。其中一個goroutine可能打印出空字符串。
另外一種錯誤的典型如下:
var a string var done bool func setup() { a = "hello, world" done = true } func main() { go setup() for !done { } print(a) }
同上一個例子一樣,這里對done得寫觀察,不能保證對a的寫觀察,所以也可能打印出空字符串。
更甚,由於main和setup兩個線程間沒有同步事件,並不能保證main中一定能觀察到done的寫操作,因此main中的一直循環下去沒有結束。(這里不是很理解,只能說setup的執行時機和main中for循環沒有明確的相對先后和相對距離,所以可能導致循環很久setup還沒執行,或執行了但是沒有更新到main所讀取的done)
還有以上風格的一些變體,如下:
type T struct { msg string } var g *T func setup() { t := new(T) t.msg = "hello, world" g = t } func main() { go setup() for g == nil { } print(g.msg) }
即使main能夠觀察到g的賦值而退出循環,但是也不能保證觀察到g.msg的初始化值。
對於以上所有例子,解決方案是一樣的,定義明確的同步機制。