Go 內(nèi)存模型 并發(fā)可見性
TLTR
- 協(xié)程之間的數(shù)據(jù)可見性滿足HappensBefore法則,并具有傳遞性
- 如果包 p 導(dǎo)入包 q,則 q 的 init 函數(shù)的完成發(fā)生在任何 p 的操作開始之前
- main.main 函數(shù)的啟動發(fā)生在所有 init 函數(shù)完成之后
go
語句啟動新的協(xié)程發(fā)生在新協(xié)程啟動開始之前go
協(xié)程的退出并不保證發(fā)生在任何事件之前channel
上的發(fā)送發(fā)生在對應(yīng)channel
接收之前- 無buffer
channel
的接收發(fā)生在發(fā)送操作完成之前 - 對于容量為C的buffer channel來說,第k次從channel中接收,發(fā)生在第
k + C
次發(fā)送完成之前。 - 對于任何的
sync.Mutex
或者sync.RWMutex
變量,且有
n<m,第
n個調(diào)用
UnLock一定發(fā)生在
m個
Lock`之前。 - 從 once.Do(f) 對 f() 的單個調(diào)用返回在任何一個 once.Do(f) 返回之前。
- 如果兩個動作不滿足HappensBefore,則順序無法預(yù)測
介紹
Go內(nèi)存模型指定了在何種條件下可以保證在一個 goroutine 中讀取變量時觀察到不同 goroutine 中寫入該變量的值。
建議
通過多個協(xié)程并發(fā)修改數(shù)據(jù)的程序必須將操作序列化。為了序列化訪問,通過channel操作或者其他同步原語( sync
、 sync/atomic
)來保護(hù)數(shù)據(jù)。
如果你必須要閱讀本文的其他部分才能理解你程序的行為,請盡量不要這樣...
Happens Before
在單個 goroutine
中,讀取和寫入的行為必須像按照程序指定的順序執(zhí)行一樣。 也就是說,只有當(dāng)重新排序不會改變語言規(guī)范定義的 goroutine 中的行為時,編譯器和處理器才可以重新排序在單個 goroutine 中執(zhí)行的讀取和寫入。 由于這種重新排序,一個 goroutine 觀察到的執(zhí)行順序可能與另一個 goroutine 感知的順序不同。 例如,如果一個 goroutine 執(zhí)行 a = 1; b = 2;,另一個可能會在 a 的更新值之前觀察到 b 的更新值。
為了滿足讀寫的需求,我們定義了 happens before
,Go程序中內(nèi)存操作的局部順序。如果事件 e1
在 e2
之前發(fā)生,我們說 e2
在 e1
之后發(fā)生。還有,如果 e1
不在 e2
之前發(fā)生、 e2
也不在 e1
之前發(fā)生,那么我們說 e1
和 e2
并發(fā)happen。
在單個 goroutine
中, happens-before
順序由程序指定。
當(dāng)下面兩個條件滿足時,變量 v
的閱讀操作 r
就 可能 觀察到寫入操作 w
r
不在w
之前發(fā)生- 沒有其他的請求
w2
發(fā)生在w
之后,r
之前
為了保證 r
一定能閱讀到 v
,保證 w
是 r
能觀測到的唯一的寫操作。當(dāng)下面兩個條件滿足時, r
保證可以讀取到 w
w
在r
之前發(fā)生- 任何其他對共享變量
v
的操作,要么在w
之前發(fā)生,要么在r
之后發(fā)生
這一對條件比上一對條件更強(qiáng);這要求無論是 w
還是 r
,都沒有相應(yīng)的并發(fā)操作。
在單個 goroutine
中,沒有并發(fā)。所以這兩個定義等價:讀操作 r
能讀到最近一次 w
寫入 v
的值。但是當(dāng)多個 goroutine
訪問共享變量時,它們必須使用同步事件來建立 happens-before
關(guān)系。
使用變量 v
類型的0值初始化變量 v
的行為類似于內(nèi)存模型中的寫入。
對于大于單個機(jī)器字長的值的讀取和寫入表現(xiàn)為未指定順序的對多個機(jī)器字長的操作。
同步
初始化
程序初始化在單個 goroutine 中運(yùn)行,但該 goroutine 可能會創(chuàng)建其他并發(fā)運(yùn)行的 goroutine。
如果包 p 導(dǎo)入包 q,則 q 的 init 函數(shù)的完成發(fā)生在任何 p 的操作開始之前。
main.main 函數(shù)的啟動發(fā)生在所有 init 函數(shù)完成之后。
Go協(xié)程的創(chuàng)建
go
語句啟動新的協(xié)程發(fā)生在新協(xié)程啟動開始之前。
舉個例子
- var a string
- func f() {
- print(a)
- }
- func hello() {
- a = "hello, world"
- go f()
- }
調(diào)用 hello
將會打印 hello, world
。當(dāng)然,這個時候 hello
可能已經(jīng)返回了。
Go協(xié)程的銷毀
go
協(xié)程的退出并不保證發(fā)生在任何事件之前
- var a string
- func hello() {
- go func() { a = "hello" }()
- print(a)
- }
對 a 的賦值之后沒有任何同步事件,因此不能保證任何其他 goroutine 都會觀察到它。 事實(shí)上,激進(jìn)的編譯器可能會刪除整個 go 語句。
如果一個 goroutine 的效果必須被另一個 goroutine 觀察到,請使用同步機(jī)制,例如鎖或通道通信來建立相對順序。
通道通信
通道通信是在go協(xié)程之間傳輸數(shù)據(jù)的主要手段。在特定通道上的發(fā)送總有一個對應(yīng)的channel的接收,通常是在另外一個協(xié)程。
channel
上的發(fā)送發(fā)生在對應(yīng) channel
接收之前
- var c = make(chan int, 10)
- var a string
- func f() {
- a = "hello, world"
- c <- 0
- }
- func main() {
- go f()
- <-c
- print(a)
- }
程序能保證輸出 hello, world
。對a的寫入發(fā)生在往 c
發(fā)送數(shù)據(jù)之前,往 c
發(fā)送數(shù)據(jù)又發(fā)生在從 c
接收數(shù)據(jù)之前,它又發(fā)生在 print
之前。
channel
的關(guān)閉發(fā)生在從 channel
中獲取到0值之前
在之前的例子中,將 c<-0
替換為 close(c)
,程序還是能保證輸出 hello, world
無buffer channel
的接收發(fā)生在發(fā)送操作完成之前
這個程序,和之前一樣,但是調(diào)換發(fā)送和接收操作,并且使用無buffer的channel
- var c = make(chan int)
- var a string
- func f() {
- a = "hello, world"
- <-c
- }
- func main() {
- go f()
- c <- 0
- print(a)
- }
也保證能夠輸出 hello, world
。對a的寫入發(fā)生在c的接收之前,繼而發(fā)生在c的寫入操作完成之前,繼而發(fā)生在print之前。
如果該 channel
是buffer channel
(例如: c=make(chan int, 1)
),那么程序就不能保證輸出 hello, world
??赡軙蛴】兆址?、崩潰等等。從而,我們得到一個相對通用的推論:
對于容量為C的buffer channel來說,第k次從channel中接收,發(fā)生在第 k + C
次發(fā)送完成之前。
此規(guī)則將先前的規(guī)則推廣到緩沖通道。 它允許通過buffer channel 來模擬信號量:通道中的條數(shù)對應(yīng)活躍的數(shù)量,通道的容量對應(yīng)于最大并發(fā)數(shù)。向channel發(fā)送數(shù)據(jù)相當(dāng)于獲取信號量,從channel中接收數(shù)據(jù)相當(dāng)于釋放信號量。 這是限制并發(fā)的常用習(xí)慣用法。
該程序?yàn)楣ぷ髁斜碇械拿總€條目啟動一個 goroutine,但是 goroutine 使用 limit
channel進(jìn)行協(xié)調(diào),以確保一次最多三個work函數(shù)正在運(yùn)行。
- var limit = make(chan int, 3)
- func main() {
- for _, w := range work {
- go func(w func()) {
- limit <- 1
- w()
- <-limit
- }(w)
- }
- select{}
- }
鎖
sync
包中實(shí)現(xiàn)了兩種鎖類型: sync.Mutex
和 sync.RWMutex
對于任何的 sync.Mutex
或者 sync.RWMutex
變量 ,且有
n<m ,第
n 個調(diào)用
UnLock 一定發(fā)生在
m 個
Lock`之前。
- var l sync.Mutex
- var a string
- func f() {
- a = "hello, world"
- l.Unlock()
- }
- func main() {
- l.Lock()
- go f()
- l.Lock()
- print(a)
- }
這個程序也保證輸出 hello,world
。第一次調(diào)用 unLock
一定發(fā)生在第二次 Lock
調(diào)用之前
對于任何 sync.RWMutex
的 RLock
方法調(diào)用,存在變量n,滿足 RLock
方法發(fā)生在第 n
個 UnLock
調(diào)用之后,并且對應(yīng)的 RUnlock
發(fā)生在第 n+1
個 Lock
方法之前。
Once
在存在多個 goroutine 時, sync
包通過 once
提供了一種安全的初始化機(jī)制。對于特定的 f
,多個線程可以執(zhí)行 once.Do(f)
,但是只有一個會運(yùn)行 f()
,另一個調(diào)用會阻塞,直到 f()
返回
從 once.Do(f) 對 f() 的單個調(diào)用返回在任何一個 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()
- }
調(diào)用 twoprint 將只調(diào)用一次 setup。 setup
函數(shù)將在任一打印調(diào)用之前完成。 結(jié)果將是 hello, world
打印兩次。
不正確的同步
注意,讀取 r
有可能觀察到了由寫入 w
并發(fā)寫入的值。盡管觀察到了這個值,也并不意味著 r
后續(xù)的讀取可以讀取到 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兩個值。
雙檢查鎖是為了降低同步造成的開銷。舉個例子, twoprint
方法可能會被誤寫成
- 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()
- }
因?yàn)闆]有任何機(jī)制保證,協(xié)程觀察到done為true的同時可以觀測到a為 hello, world
,其中有一個 doprint
可能會輸出空字符。
另外一個例子
- var a string
- var done bool
- func setup() {
- a = "hello, world"
- done = true
- }
- func main() {
- go setup()
- for !done {
- }
- print(a)
- }
和以前一樣,不能保證在 main 中,觀察對 done 的寫入意味著觀察對 a 的寫入,因此該程序也可以打印一個空字符串。 更糟糕的情況下,由于兩個線程之間沒有同步事件,因此無法保證 main 會觀察到對 done 的寫入。 main 中的循環(huán)會一直死循環(huán)。
下面是該例子的一個更微妙的變體
- 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不為nil,但是也沒有任何機(jī)制保證可以讀取到t.msg。
在上述例子中,解決方案都是相同的:請使用顯式的同步機(jī)制。