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