這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
Golang語言
介紹
如何保證在一個goroutine中看到在另一個goroutine修改的變數的值,這篇文章進行了詳細說明。
建議
如果程式中修改資料時有其他goroutine同時讀取,那麼必須將讀取序列化。為了序列化訪問,請使用channel或其他同步原語,例如sync和sync/atomic來保護資料。
先行發生
在一個gouroutine中,讀和寫一定是按照程式中的順序執行的。即編譯器和處理器只有在不會改變這個goroutine的行為時才可能修改讀和寫的執行順序。由於重排,不同的goroutine可能會看到不同的執行順序。例如,一個goroutine執行a = 1;b = 2;
,另一個goroutine可能看到b
在a
之前更新。
為了說明讀和寫的必要條件,我們定義了先行發生(Happens Before)
--Go程式中執行記憶體操作的偏序。如果事件e1
發生在e2
前,我們可以說e2
發生在e1
後。如果e1
不發生在e2
前也不發生在e2
後,我們就說e1
和e2
是並發的。
在單獨的goroutine中先行發生的順序即是程式中表達的順序。
當下麵條件滿足時,對變數v的讀操作r是被允許看到對v的寫操作w的:
1 r不先行發生於w
2 在w後r前沒有對v的其他寫操作
為了保證對變數v的讀操作r看到對v的寫操作w,要確保w是r允許看到的唯一寫操作。即當下麵條件滿足時,r 被保證看到w:
1 w先行發生於r
2 其他對共用變數v的寫操作要麼在w前,要麼在r後。
這一對條件比前面的條件更嚴格,需要沒有其他寫操作與w或r並發發生。
單獨的goroutine中沒有並發,所以上面兩個定義是相同的:讀操作r看到最近一次的寫操作w寫入v的值。當多個goroutine訪問共用變數v時,它們必須使用同步事件來建立先行發生這一條件來保證讀操作能看到需要的寫操作。 對變數v的零值初始化在記憶體模型中表現的與寫操作相同。 對大於一個字的變數的讀寫動作表現的像以不確定順序對多個一字大小的變數的操作。
同步
初始化
程式的初始化在單獨的goroutine中進行,但這個goroutine可能會建立出並發執行的其他goroutine。
如果包p引入(import)包q,那麼q的init函數的結束先行發生於p的所有init函數開始 main.main函數的開始發生在所有init函數結束之後
建立goroutine
go
關鍵字開啟新的goroutine,先行發生於這個goroutine開始執行,例如下面程式:
a stringfunc f() { print(a)} func hello() { a = "hello, world" go f()}
調用hello
會在之後的某時刻列印出"hello, world"(可能在hello
返回之後)
銷毀goroutine
gouroutine的退出並不會保證先行發生於程式的任何事件。例如下面程式:
a stringfunc hello() { go func() { a = "hello" }() print(a)}
沒有用任何同步操作限制對a的賦值,所以並不能保證其他goroutine能看到a的變化。實際上,一個激進的編譯器可能會刪掉整個go語句。 如果想要在一個goroutine中看到另一個goroutine的執行效果,請使用鎖或者channel這種同步機制來建立程式執行的相對順序。
channel通訊
channel通訊是goroutine同步的主要方法。每一個在特定channel的發送操作都會匹配到通常在另一個goroutine執行的接收操作。
在channel的發送操作先行發生於對應的接收操作完成 例如:
c = make(chan int, 10)var a stringfunc f() { a = "hello, world" c <- 0}func main() { go f() <-c print(a)}
這個程式能保證列印出"hello, world"。對a的寫先行發生於在c上的發送,先行發生於在c上的對應的接收完成,先行發生於print
。
對channel的關閉先行發生於接收到零值,因為channel已經被關閉了。
在上面的例子中,將c <- 0
替換為close(c)
還會產生同樣的結果。
無緩衝channel的接收先行發生於發送完成
如下程式(和上面類似,只交換了對channel的讀寫位置並使用了非緩衝channel):
c = make(chan int)var a stringfunc f() { a = "hello, world" <-c}
n() { go f() c <- 0 print(a)}
此程式也能保證列印出"hello, world"。對a的寫先行發生於從c接收,先行發生於向c發送完成,先行發生於print
。
如果是帶緩衝的channel(例如c = make(chan int, 1)
),程式不保證列印出"hello, world"(可能列印Null 字元,程式崩潰或其他行為)。
在容量為C的channel上的第k個接收先行發生於從這個channel上的第k+C次發送完成。
這條規則將前面的規則推廣到了帶緩衝的channel上。可以通過帶緩衝的channel來實現計數訊號量:channel中的元素數量對應著活動的數量,channel的容量表示同時活動的最大數量,發送元素擷取訊號量,接收元素釋放訊號量,這是限制並發的通常用法。
下面程式為work
中的每一項開啟一個goroutine,但這些goroutine通過有限制的channel來確保最多同時執行三個工作函數(w)。
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變數l和n < m,n次調用l.Unlock()先行發生於m次l.Lock()返回
下面程式:
l sync.Mutexvar a stringfunc f() { a = "hello, world" l.Unlock()}func main() { l.Lock() go f() l.Lock() print(a)}
能保證列印出"hello, world"。第一次調用l.Unlock()(在f()中)先行發生於main中的第二次l.Lock()返回, 先行發生於print。
對於sync.RWMutex變數l,任意的函數調用l.RLock滿足第n次l.RLock後發生於第n次調用l.Unlock,對應的l.RUnlock先行發生於第n+1次調用l.Lock。
Once
sync包的Once
為多個goroutine提供了安全的初始化機制。能在多個線程中執行once.Do(f)
,但只有一個f()
會執行,其他調用會一直阻塞直到f()返回。
通過
執行
先行發生(指f()返回)於其他的
返回。
如下程式:
a stringvar once sync.Oncefunc setup() { a = "hello, world"}func doprint() { once.Do(setup) print(a)}func twoprint() { go doprint() go doprint()}
調用twoprint
會列印"hello, world"兩次。setup
只在第一次doprint
時執行。
錯誤的同步方法
注意,讀操作r可能會看到並發的寫操作w。即使這樣也不能表明r之後的讀能看到w之前的寫。
如下程式:
a, b intfunc f() { a = 1 b = 2}func g() { print(b) print(a)} func main() { go f() g()}
g
可能先列印出2然後是0。
這個事實證明一些舊的習慣是錯誤的。
雙重檢查鎖定是為了避免同步的資源消耗。例如twoprint程式可能會錯誤的寫成:
a stringvar done boolfunc setup() { a = "hello, world" done = true}func doprint() { if !done { once.Do(setup) } print(a)}func twoprint() { go doprint() go doprint()}
在doprint
中看到done
被賦值並不保證能看到對a賦值。此程式可能會錯誤地輸出Null 字元而不是"hello, world"。
另一個錯誤的習慣是忙等待 例如:
a stringvar done boolfunc setup() { a = "hello, world" done = true}func main() { go setup() for !done { } print(a)}
和之前程式類似,在main中看到done
被賦值不能保證看到a
被賦值,所以此程式也可能列印出Null 字元。更糟糕的是因為兩個線程間沒有同步事件,在main中可能永遠不會看到done
被賦值,所以main中的迴圈不保證能結束。
對程式做一個微小的改變:
T struct { msg string}var g *Tfunc setup() { t := new(T) t.msg = "hello, world" g = t}func main() { go setup() for g == nil { } print(g.msg)}
即使main看到了g != nil
並且退出了迴圈,也不能保證看到g.msg的初始化值。
在上面所有的例子中,解決辦法都是相同的:明確的使用同步。
作者丨 tailnode
連結丨 http://tailnode.tk/2017/01/Go的記憶體模型/
原文丨https://golang.org/ref/mem