GoLang記憶體模型
一、前言
Go語言的記憶體模型規定了一個goroutine可以看到另外一個goroutine修改同一個變數的值的條件,這類似java記憶體模型中記憶體可見性問題(Java記憶體可見性問題可以參考拙作:Java併發程式設計之美一書)。
當多個goroutine併發同時存取同一個資料時候必須把併發的存取的操作順序化,在go中可以實現操作順序化的工具有高階的通道(channel)通訊和同步原語比如sync包中的Mutex(互斥鎖)、RWMutex(讀寫鎖)或者和sync/atomic中的原子操作。
二、Happens Before原則
當程式裡面只有一個goroutine時候,雖然編譯器和CPU由於開啟了優化功能可能調整讀寫操作的順序,但是這個調整是不會影響程式的執行正確性:
<code>a := 1//1</code><code>b := 2//2</code><code>c := a + b //3</code><code>...</code>
如上程式碼由於編譯器和cpu的優化,實際執行時候可能程式碼(2)先執行,然後程式碼(1)後執行,但是由於程式碼(3)依賴程式碼(1)和程式碼(2)建立的變數,所以程式碼(1)和(2)不會被放到程式碼(3)後執行,也就是說編譯器和CPU在不改變程式正確性的前提下才會對指令進行重排序,所以上面程式碼在單一goroutine時候並不會存在問題,也就是在單一goroutine 中Happens Before所要表達的順序就是程式執行的順序。
但是在多個goroutine時候就可能存在問題,比如下面程式碼:
<code>//變數b初始化為0</code><code>var b int </code><code>//goroutine A</code><code>go func() {</code><code>a := 1//1</code><code>b := 2//2</code><code>c := a + b //3</code><code>}()</code><code>//goroutine B</code><code>go func() {</code><code>if 2 == b {//4</code><code>fmt.Println(a)//5</code><code>}</code><code>}()</code>
- 如上程式碼變數b是一個全域性變數,初始化為0值
- 下面開啟了兩個goroutine,假設goroutine B有機會輸出值時候,那麼它可能輸出的值是多少那?其實可能是0也可能是1,輸出1大家可能會感到很直觀,那麼為何會輸出0 了?
- 這是因為編譯器或者CPU可能會對goroutine A中的指令做重排序,可能先執行了程式碼(2),然後在執行了程式碼(1)。假設當goroutine A執行程式碼(2)後,排程器排程了goroutine B執行,則goroutine B這時候會輸出0。
為了保證多goroutine下讀取共享資料的正確性,go中引入happens before原則,即在go程式中定義了多個記憶體操作執行的一種偏序關係。如果操作e1先於e2發生,我們說e2 happens after e1,如果e1操作既不先於e2發生又不晚於e2發生,我們說e1操作與e2操作併發發生。
在單一goroutine 中Happens Before所要表達的順序就是程式執行的順序,happens before原則指出在單一goroutine 中當滿足下面條件時候,對一個變數的寫操作w1對讀操作r1可見:
- 讀操作r1沒有發生在寫操作w1前
- 在讀操作r1之前,寫操作w1之後沒有其他的寫操作w2對變數進行了修改
在一個goroutine裡面,不存在併發,所以對變數的讀操作r1總是對最近的一個寫操作w1的內容可見,但是在多goroutine下則需要滿足下面條件才能保證寫操作w1對讀操作r1可見:
- 寫操作w1先於讀操作r1
- 任何對變數的寫操作w2要先於寫操作w1或者晚於讀操作r1
這兩條條件相比第一組的兩個條件更加嚴格,因為它要求沒有任何寫操作與w1或者讀操作r1併發的執行,而是要求在w1操作前或讀操作r1後發生。
在一個goroutine時候,不存在與w1或者r1併發的寫操作,所以前面兩種定義是等價的:一個讀操作r1總是對最近的一個對寫操作w1的內容可見。但是當有多個goroutines併發訪問變數時候,就需要引入同步機制來建立happen-before條件來確保讀操作r1對寫操作w1寫的內容可見。
需要注意的是在go記憶體模型中將多個goroutine中用到的全域性變數初始化為它的型別零值在內被視為一次寫操作,另外當讀取一個型別大小比機器字長大的變數的值時候表現為是對多個機器字的多次讀取,這個行為是未知的,go中使用sync/atomic包中的Load和Store操作可以解決這個問題。
解決多goroutine下共享資料可見性問題的方法是在訪問共享資料時候施加一定的同步措施,比如sync包下的鎖或者通道。
三、同步(Synchronization)
3.1初始化(Initialization)
程式的初始化是發生在一個goroutine內的,這個goroutine可以建立多個新的goroutine,建立的goroutine和當前的goroutine可以併發的執行。
如果在一個goroutine所在的原始碼包p裡面通過import命令匯入了包q,那麼q包裡面go檔案的初始化方法的執行會happens before 於包p裡面的初始化方法執行:
<code>package main</code><code>import (</code><code>"fmt"</code><code>"main/hello"</code><code>)</code><code>func init() {</code><code>fmt.Println("--main thread init---")</code><code>}</code><code>func main() {</code><code>fmt.Println("---main func start----")</code><code>hello.SayHello()</code><code>}</code>
- 如上程式碼main包裡面匯入了main/hello包,後者裡面含有一個hello.go的檔案,內容如下:
<code>package hello</code><code>import (</code><code>"fmt"</code><code>)</code><code>func init() {</code><code>fmt.Println("--hello pkg init---")</code><code>}</code><code>func SayHello() {</code><code>fmt.Println("--hello jiaduo---")</code><code>}</code>
- main包的main裡面呼叫了包hello的SayHello 方法。
執行上面程式碼會輸出:
<code>--hello pkg init---</code><code>--main thread init---</code><code>---main func start----</code><code>--hello jiaduo---</code>
可知hello包的init方法happen before main包的init執行,main包的init方法happen berfore main函式執行。
3.2 建立goroutine(Goroutine creation)
go語句啟動一個新的goroutine的動作 happen before 該新goroutine的執行,例如下面程式:
<code>package main</code><code>import (</code><code>"fmt"</code><code>"sync"</code><code>)</code><code>var a string</code><code>var wg sync.WaitGroup</code><code>func f() {</code><code>fmt.Print(a)</code><code>wg.Done()</code><code>}</code><code>func hello() {</code><code>a = "hello, world"</code><code>go f()</code><code>}</code><code>func main() {</code><code>wg.Add(1)</code><code>hello()</code><code>wg.Wait()</code><code>}</code>
如上程式碼呼叫hello方法後肯定會輸出”hello,world”,可能等hello方法執行完畢後才輸出(由於排程的原因)。
3.3 銷燬goroutine(Goroutine destruction)
一個goroutine的銷燬操作並不能確保 happen before 程式中的任何事件,比如下面例子:
<code>var a string</code><code>func hello() {</code><code>go func() { a = "hello" }()</code><code>print(a)</code><code>}</code>
如上程式碼 goroutine內對變數a的賦值並沒有加任何同步措施,所以並能不保證hello函式所在的goroutine對變數a的賦值可見。如果要確保一個goroutine對變數的修改對其他goroutine可見,必須使用一定的同步機制,比如鎖、通道來建立對同一個變數讀寫的偏序關係。
3.4 通道通訊(Channel communication)
在go中通道是用來解決多個goroutines之間進行同步的主要措施,在多個goroutines中,每個對通道進行寫操作的goroutine都對應著一個從通道讀操作的goroutine。
3.4.1 有緩衝通道
在有緩衝的通道時候向通道寫入一個數據總是 happen before 這個資料被從通道中讀取完成,如下例子:
<code>package main</code><code>import (</code><code>"fmt"</code><code>)</code><code>var c = make(chan int, 10)</code><code>var a string</code><code>func f() {</code><code>a = "hello, world" //1</code><code>c <- 0//2</code><code>}</code><code>func main() {</code><code>go f()//3</code><code><-c//4</code><code>fmt.Print(a) //5</code><code>}</code>
如上程式碼執行後可以確保輸出”hello, world”,這裡對變數a的寫操作(1) happen before 向通道寫入資料的操作(2),而向通道寫入資料的操作(2)happen before 從通道讀取資料完成的操作(4),而步驟(4)happen before 步驟(5)的列印輸出。
另外關閉通道的操作 happen before 從通道接受0值(關閉通道後會向通道傳送一個0值),修改上面程式碼(2)如下:
<code>package main</code><code>import (</code><code>"fmt"</code><code>)</code><code>var c = make(chan int, 10)</code><code>var a string</code><code>func f() {</code><code>a = "hello, world" //1</code><code>close(c)//2</code><code>}</code><code>func main() {</code><code>go f()//3</code><code><-c//4</code><code>fmt.Print(a) //5</code><code>}</code>
然後在執行也可以確保輸出”hello, world”。
注:在有緩衝通道中通過向通道寫入一個數據總是 happen before 這個資料被從通道中讀取完成,這個happen before規則使多個goroutine中對共享變數的併發訪問變成了可預見的序列化操作。
3.4.2 無緩衝通道
對應無緩衝的通道來說從通道接受(獲取叫做讀取)元素 happen before 向通道傳送(寫入)資料完成,看下下面程式碼:
<code>package main</code><code>import (</code><code>"fmt"</code><code>)</code><code>var c = make(chan int)</code><code>var a string</code><code>func f() {</code><code>a = "hello, world" //1</code><code><-c//2</code><code>}</code><code>func main() {</code><code>go f()//3</code><code>c <- 0//4</code><code>fmt.Print(a) //5</code><code>}</code>
如上程式碼執行也可保證輸出”hello, world”,注意改程式相比上一個片段,通道改為了無緩衝,並向通道傳送資料與讀取資料的步驟(2)(4)調換了位置。
在這裡寫入變數a的操作(1)happen before 從通道讀取資料完畢的操作(2),而從通道讀取資料的操作 happen before 向通道寫入資料完畢的操作(4),而步驟(4) happen before 列印輸出步驟(5)。
注:在無緩衝通道中從通道讀取資料的操作 happen before 向通道寫入資料完畢的操作,這個happen before規則使多個goroutine中對共享變數的併發訪問變成了可預見的序列化操作。
如上程式碼如果換成有緩衝的通道,比如c = make(chan int, 1)則就不能保證一定會輸出”hello, world”。
3.4.3 規則抽象
從容量為C的通道接受第K個元素 happen before 向通道第k+C次寫入完成,比如從容量為1的通道接受第3個元素 happen before 向通道第3+1次寫入完成。
這個規則對有緩衝通道和無緩衝通道的情況都適用,有緩衝的通道可以實現訊號量計數的功能,比如通道的容量可以認為是最大訊號量的個數,通道內當前元素個數可以認為是剩餘的訊號量個數,向通道寫入(傳送)一個元素可以認為是獲取一個訊號量,從通道讀取(接受)一個元素可以認為是釋放一個訊號量,所以有緩衝的通道可以作為限制併發數的一個通用手段:
<code>package main</code><code>import (</code><code>"fmt"</code><code>"time"</code><code>)</code><code>var limit = make(chan int, 3)</code><code>func sayHello(index int){</code><code>fmt.Println(index )</code><code>}</code><code>var work []func(int)</code><code>func main() {</code><code>work := append(work,sayHello,sayHello,sayHello,sayHello,sayHello,sayHello)</code><code>for i, w := range work {</code><code>go func(w func(int),index int) {</code><code>limit <- 1</code><code>w(index)</code><code><-limit</code><code>}(w,i)</code><code>}</code><code>time.Sleep(time.Second * 10)</code><code>}</code>
如上程式碼main goroutine裡面為work列表裡面的每個方法的執行開啟了一個單獨的goroutine,這裡有6個方法,正常情況下這7個goroutine可以併發執行,但是本程式使用快取大小為3的通道來做併發控制,導致同時只有3個goroutine可以併發執行。
四、鎖(locks)
sync包實現了兩個鎖型別,分別為 sync.Mutex(互斥鎖)和 sync.RWMutex(讀寫鎖)。
對應任何sync.Mutex or sync.RWMutex型別的遍歷I來說呼叫n次 l.Unlock() 操作 happen before 呼叫m次l.Lock()操作返回,其中n
<code>package main</code><code>import (</code><code>"fmt"</code><code>"sync"</code><code>)</code><code>var l sync.Mutex</code><code>var a string</code><code>func f() {</code><code>a = "hello, world" //1</code><code>l.Unlock()//2</code><code>}</code><code>func main() {</code><code>l.Lock()//3</code><code>go f()//4</code><code>l.Lock()//5</code><code>fmt.Print(a) //6</code><code>}</code>
執行上面程式碼可以確保輸出”hello, world”,其中對變數a的賦值操作(1) happen before 步驟(2),第一次呼叫 l.Unlock()的操作(2) happen before 第二次呼叫l.Lock()的操作(5),操作(5) happen before 列印輸出操作(6)
另外對任何一個sync.RWMutex型別的變數l來說,存在一個次數n,呼叫 l.RLock操作happens after 呼叫n次 l. Unlock(釋放寫鎖)並且相應的 l.RUnlock happen before 呼叫n+1次 l.Lock(寫鎖)
<code>package main</code><code>import (</code><code>"fmt"</code><code>"sync"</code><code>)</code><code>var l sync.RWMutex</code><code>var a string</code><code>func unlock() {</code><code>a = "unlock" //1</code><code>l.Unlock()//2</code><code>}</code><code>func runlock() {</code><code>a = "runlock" //3</code><code>l.RUnlock()//4</code><code>}</code><code>func main() {</code><code>l.Lock()//5</code><code>go unlock() //6</code><code>l.RLock()//7</code><code>fmt.Println(a) //8</code><code>go runlock()//9</code><code>l.Lock()//10</code><code>fmt.Print(a) //11</code><code>l.Unlock()</code><code>}</code>
- 執行上面程式碼一定會輸出如下:
<code>unlock</code><code>runlock</code>
- 如上程式碼 (1)對a的賦值 happen before 程式碼(2),而對l.RLock() (程式碼7) 的呼叫happen after對l.Unlock()(程式碼2)的第1次呼叫,所以程式碼(8)輸出unlock。
- 而對程式碼(7)l.RLock() 的呼叫happen after對l.Unlock()(程式碼2) 的第1次呼叫,相應的有對l.RUnlock() (程式碼4)的呼叫happen before 第2次對l.Lock()(程式碼4)的呼叫,所以程式碼(11)輸出runlock
也就是這裡對任何一個sync.RWMutex型別的變數l來說,存在一個次數1,呼叫 l.RLock操作happens after 呼叫1次 l. Unlock(釋放寫鎖)並且相應的 l.RUnlock happen before 呼叫2次 l.Lock(寫鎖)
五、一次執行(Once)
sync包提供了在多個goroutine存在的情況下進行安全初始化的一種機制,這個機制也就是提供的Once型別。多(goroutine)下多個goroutine可以同時執行once.Do(f)方法,其中f是一個函式,但是同時只有一個goroutine可以真正執行傳遞的f函式,其他的goroutine則會阻塞直到執行f的goroutine執行f完畢。
多goroutine下同時呼叫once.Do(f)時候,真正執行f()函式的goroutine, happen before 任何其他由於呼叫once.Do(f)而被阻塞的goroutine返回:
<code>package main</code><code>import (</code><code>"fmt"</code><code>"sync"</code><code>"time"</code><code>)</code><code>var a string</code><code>var once sync.Once</code><code>var wg sync.WaitGroup</code><code>func setup() {</code><code>time.Sleep(time.Second * 2) //1</code><code>a = "hello, world"</code><code>fmt.Println("setup over") //2</code><code>}</code><code>func doprint() {</code><code>once.Do(setup) //3</code><code>fmt.Println(a) //4</code><code>wg.Done()</code><code>}</code><code>func twoprint() {</code><code>go doprint()</code><code>go doprint()</code><code>}</code><code>func main() {</code><code>wg.Add(2)</code><code>twoprint()</code><code>wg.Wait()</code><code>}</code>
如上程式碼執行會輸出: setup over hello, world hello, world
- 上面程式碼使用wg sync.WaitGroup等待兩個goroutine執行完畢,由於 setup over只輸出一次,所以setup方法只運行了一次
- 由於輸出了兩次hello, world說明當一個goroutine在執行setup方法時候,另外一個在阻塞。
六、不正確的同步(Incorrect synchronization)
6.1 不正確的同步案例(一)
需要注意的是雖然一個goroutine對一個變數的讀取操作r,可以觀察到另外一個goroutine的寫操作w對變數的修改,但是這不意味著happening after 讀操作r的讀操作可以看到 happen before寫操作w的寫操作對變數的修改(需要注意這裡的先後指的是程式碼裡面宣告的操作的先後順序,而不是實際執行時候的):
<code>var a, b int</code><code>func f() {</code><code>a = 1//1</code><code>b = 2//2</code><code>}</code><code>func g() {</code><code>print(b)//3</code><code>print(a)//4</code><code>}</code><code>func main() {</code><code>go f()//5</code><code>g()//6</code><code>}</code>
- 比如上面程式碼一個可能的輸出為先列印2,然後列印0
- 由於程式碼(1)(2)沒有有任何同步措施,所以經過重排序後可能先執行程式碼(2),然後執行程式碼(1)。
- 另外由於步驟(5)開啟了一個新goroutine來執行f函式,所以f函式和g函式是併發執行,並且兩個goroutine沒做任何同步。
- 假設f函式先執行,並且由於重排序限制性了步驟(2),然後g函式執行了步驟(3)則這時候會打印出2,然後執行步驟(4)則打印出0,然後執行步驟(1)給變數a賦值。
也就是說這裡即使假設步驟(3)的讀取操作r 對步驟(2)的寫操作w的內容可見,但是還沒不能保證步驟(3)的讀取操作後面的讀取操作步驟(4)可以看到 先於程式碼中宣告的在步驟(2)前面的程式碼(1)對變數a賦值的內容。
6.2 不正確的同步案例(二)
使用雙重檢查機制來避免使用同步帶來的開銷,如下程式碼:
<code>var a string</code><code>var done bool</code><code>func setup() {</code><code>a = "hello, world"</code><code>done = true</code><code>}</code><code>func doprint() {</code><code>if !done {</code><code>once.Do(setup)</code><code>}</code><code>print(a)</code><code>}</code><code>func twoprint() {</code><code>go doprint()</code><code>go doprint()</code><code>}</code>
如上程式碼並不能保證一定輸出hello, world,而可能輸出空字串,這是因為在doPrint函式內即使可以能夠看到setup中對done變數的寫操作,也不能保證在doPrint裡面看到對變數a的寫操作。
6.3 不正確的同步案例(三)
另外一個常見的不正確的同步是等待某個變數的值滿足一定條件:
<code>var a string</code><code>var done bool</code><code>func setup() {</code><code>a = "hello, world"</code><code>done = true</code><code>}</code><code>func main() {</code><code>go setup()</code><code>for !done {</code><code>}</code><code>print(a)</code><code>}</code>
該案例同理,並不能確保在main函式內即使可以看到對變數done的寫操作,也可以看到對變數a的操作,所以main函式還是可能會輸出空串。更糟糕的是由於兩個goroutine沒有對變數done做同步措施,main函式所在goroutine可能看不到對done的寫操作,從而導致main函式所在goroutine一直執行在for迴圈出。
這種不正常的同步方式有更微妙的變體,例如這個程式:
<code>type T struct {</code><code>msg string</code><code>}</code><code>var g *T</code><code>func setup() {</code><code>t := new(T)</code><code>t.msg = "hello, world"</code><code>g = t</code><code>}</code><code>func main() {</code><code>go setup()</code><code>for g == nil {</code><code>}</code><code>print(g.msg)</code><code>}</code>
如上程式碼即使main函式內可以看到setup函式內對g的賦值,從而讓main函式退出,但是也不能保證main函式可以看到對 g.msg的賦值,也就是可能輸出空串
七、總結
通過上面所有的例子,不難看出解決多goroutine下共享資料可見性問題的方法是在訪問共享資料時候施加一定的同步措施。本文翻譯自https://golang.org/ref/mem ,並融入作者自己理解。