Go記憶體模型&Happen-Before(一)
一、前言
Go語言的記憶體模型規定了一個goroutine可以看到另外一個goroutine修改同一個變數的值的條件,這類似java記憶體模型中記憶體可見性問題。
當多個goroutine併發同時存取同一個資料時候必須把併發的存取的操作順序化,在go中可以實現操作順序化的工具有高階的通道(channel)通訊和同步原語比如sync包中的Mutex(互斥鎖)、RWMutex(讀寫鎖)或者和sync/atomic中的原子操作。
二、Happens Before原則
當程式裡面只有一個goroutine時候,雖然編譯器和CPU由於開啟了優化功能可能調整讀寫操作的順序,但是這個調整是不會影響程式的執行正確性:
a := 1//1 b := 2//2 c := a + b //3 ...
如上程式碼由於編譯器和cpu的優化,實際執行時候可能程式碼(2)先執行,然後程式碼(1)後執行,但是由於程式碼(3)依賴程式碼(1)和程式碼(2)建立的變數,所以程式碼(1)和(2)不會被放到程式碼(3)後執行,也就是說編譯器和CPU在不改變程式正確性的前提下才會對指令進行重排序,所以上面程式碼在單一goroutine時候並不會存在問題,也就是在單一goroutine 中Happens Before所要表達的順序就是程式執行的順序。
但是在多個goroutine時候就可能存在問題,比如下面程式碼:
//變數b初始化為0 var b int //goroutine A go func() { a := 1//1 b := 2//2 c := a + b //3 }() //goroutine B go func() { if 2 == b {//4 fmt.Println(a)//5 } }()
- 如上程式碼變數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包下的鎖或者通道。
三、總結
happen-before規則定義了多個goroutine對同一個共享資料進行讀寫的偏序關係,把併發的操作變成了可以預見的順序執行,這點和Java記憶體模型中的happen-before語義類似