Go語言內存模型
名詞定義
執行體 - Go里的Goroutine或Java中的Thread
背景介紹
內存模型的目的是為了定義清楚變量的讀寫在不同執行體里的可見性。理解內存模型在并發編程中非常重要,因為代碼的執行順序和書寫的邏輯順序并不會完全一致,甚至在編譯期間編譯器也有可能重排代碼以最優化CPU執行, 另外還因為有CPU緩存的存在,內存的數據不一定會及時更新,這樣對內存中的同一個變量讀和寫也不一定和期望一樣。
和Java的內存模型規范類似,Go語言也有一個內存模型,相對JMM來說,Go的內存模型比較簡單,Go的并發模型是基于CSP(Communicating Sequential Process)的,不同的Goroutine通過一種叫Channel的數據結構來通信;Java的并發模型則基于多線程和共享內存,有較多的概念(violatie, lock, final, construct, thread, atomic等)和場景,當然java.util.concurrent并發工具包大大簡化了Java并發編程。
Go內存模型規范了在什么條件下一個Goroutine對某個變量的修改一定對其它Goroutine可見。
Happens Before
在一個單獨的Goroutine里,對變量的讀寫和代碼的書寫順序一致。比如以下的代碼:
package mainimport ( "log" )
var a, b, c int
func main() { a = 1 b = 2 c = a + 2 log.Println(a, b, c) }</pre>
盡管在編譯期和執行期,編譯器和CPU都有可能重排代碼,比如,先執行b=2,再執行a=1,但c=a+2是保證在a=1后執行的。這樣最后的執行結果一定是1 2 3,不會是1 2 2。但下面的代碼則可能會輸出0 0 0,1 2 2,0 2 3(b=2比a=1先執行),1 2 3等各種可能。
package mainimport ( "log" )
var a, b, c int
func main() { go func() { a = 1 b = 2 }() go func() { c = a + 2 }() log.Println(a, b, c) }</pre>
Happens-before 定義
Happens-before用來指明Go程序里的內存操作的局部順序。如果一個內存操作事件e1 happens-before e2,則e2 happens-after e1也成立;如果e1不是happens-before e2,也不是happens-after e2,則e1和e2是并發的。
在這個定義之下,如果以下情況滿足,則對變量(v)的內存寫操作(w)對一個內存讀操作(r)來說允許可見的:
- r不在w開始之前發生(可以是之后或并發);
- w和r之間沒有另一個寫操作(w’)發生;
</ol>為了保證對變量(v)的一個特定寫操作(w)對一個讀操作(r)可見,就需要確保w是r唯一允許的寫操作,于是如果以下情況滿足,則對變量(v)的內存寫操作(w)對一個內存讀操作(r)來說保證可見的:
- w在r開始之前發生;
- 所有其它對v的寫操作只在w之前或r之后發生;
</ol>可以看出后一種約定情況比前一種更嚴格,這種情況要求沒有w或r沒有其他的并發寫操作。
在單個Goroutine里,因為肯定沒有并發,上面兩種情況是等價的。對變量v的讀操作可以讀到最近一次寫操作的值(這個應該很容易理解)。但在多個Goroutine里如果要訪問一個共享變量,我們就必須使用同步工具來建立happens-before條件,來保證對該變量的讀操作能讀到期望的修改值。
要保證并行執行體對共享變量的順序訪問方法就是用鎖。Java和Go在這點上是一致的。
以下是具體的可被利用的Go語言的happens-before規則,從本質上來講,happens-before規則確定了CPU緩沖和主存的同步時間點(通過內存屏障等指令),從而使得對變量的讀寫順序可被確定–也就是我們通常說的“同步”。
同步方法
初始化
- 如果package p 引用了package q,q的init()方法 happens-before p (Java工程師可以對比一下final變量的happens-before規則)
- main.main()方法 happens-after所有package的init()方法結束。
</ol>創建Goroutine
- go語句創建新的goroutine happens-before 該goroutine執行(這個應該很容易理解)
</ol>package mainimport ( "log" "time" )
var a, b, c int
func main() { a = 1 b = 2 go func() { c = a + 2 log.Println(a, b, c) }() time.Sleep(1 * time.Second) }</pre>
利用這條happens-before,我們可以確定c=a+2是happens-aftera=1和b=2,所以結果輸出是可以確定的1 2 3,但如果是下面這樣的代碼,輸出就不確定了,有可能是1 2 3或0 0 2
func main() { go func() { c = a + 2 log.Println(a, b, c) }() a = 1 b = 2 time.Sleep(1 * time.Second) }銷毀Goroutine
- Goroutine的退出并不保證happens-before任何事件。
</ol>var a stringfunc hello() { go func() { a = "hello" }() print(a) }</pre>
上面代碼因為a="hello"沒有使用同步事件,并不能保證這個賦值被主goroutine可見。事實上,極度優化的Go編譯器甚至可以完全刪除這行代碼go func() { a = "hello" }()。
Goroutine對變量的修改需要讓對其它Goroutine可見,除了使用鎖來同步外還可以用Channel。
Channel通信
在Go編程中,Channel是被推薦的執行體間通信的方法,Go的編譯器和運行態都會盡力對其優化。
- 對一個Channel的發送操作(send) happens-before 相應Channel的接收操作完成
- 關閉一個Channel happens-before 從該Channel接收到最后的返回值0
- 不帶緩沖的Channel的接收操作(receive) happens-before 相應Channel的發送操作完成
</ol>var c = make(chan int, 10) var a stringfunc f() { a = "hello, world" c <- 0 }
func main() { go f() <-c print(a) }</pre>
上述代碼可以確保輸出hello, world,因為a = "hello, world"happens-beforec <- 0,print(a)happens-after<-c, 根據上面的規則1)以及happens-before的可傳遞性,a = "hello, world"happens-beforeprint(a)。
根據規則2)把c<-0替換成close(c)也能保證輸出hello,world,因為關閉操作在<-c接收到0之前發送。
var c = make(chan int) var a stringfunc f() { a = "hello, world" <-c }
func main() { go f() c <- 0 print(a) }</pre>
根據規則3),因為c是不帶緩沖的Channel,a = "hello, world"happens-before<-chappens-beforec <- 0happens-beforeprint(a), 但如果c是緩沖隊列,如定義c = make(chan int, 1), 那結果就不確定了。
鎖
sync包實現了兩種鎖數據結構:
- sync.Mutex -> java.util.concurrent.ReentrantLock
- sync.RWMutex -> java.util.concurrent.locks.ReadWriteLock
</ol>其happens-before規則和Java的也類似:
- 任何sync.Mutex或sync.RWMutex 變量(l),定義 n < m, 第n次l.Unlock()happens-before 第m次l.lock()調用返回。
</ol>var l sync.Mutex var a stringfunc f() { a = "hello, world" l.Unlock() }
func main() { l.Lock() go f() l.Lock() print(a) }</pre>
a = "hello, world"happens-beforel.Unlock()happens-before 第二個l.Lock()happens-beforeprint(a)
Once
sync包還提供了一個安全的初始化工具Once。還記得Java的Singleton設計模式,double-check,甚至triple-check的各種單例初始化方法嗎?Go則提供了一個標準的方法。
- once.Do(f)中的f()happens-before 任何多個once.Do(f)調用的返回,且f()有且只有一次調用。
</ol>var a string var once sync.Oncefunc setup() { a = "hello, world" }
func doprint() { once.Do(setup) print(a) }
func twoprint() { go doprint() go doprint() }</pre>
上面的代碼雖然調用兩次doprint(),但實際上setup只會執行一次,并且并發的once.Do(setup)都會等待setup返回后再繼續執行。
參考鏈接