Go語言記憶體模型

kjfcpua發表於2014-02-15

目錄:

名詞定義

執行體 - 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裡,對變數的讀寫和程式碼的書寫順序一致。比如以下的程式碼:

  1. package main
  2. import (
  3. "log"
  4. )
  5. var a, b, c int
  6. func main() {
  7. a = 1
  8. b = 2
  9. c = a + 2
  10. log.Println(a, b, c)
  11. }

儘管在編譯期和執行期,編譯器和CPU都有可能重排程式碼,比如,先執行b=2,再執行a=1,但c=a+2是保證在a=1後執行的。這樣最後的執行結果一定是1 2 3,不會是1 2 2。但下面的程式碼則可能會輸出0 0 01 2 20 2 3 (b=2比a=1先執行), 1 2 3等各種可能。

  1. package main
  2. import (
  3. "log"
  4. )
  5. var a, b, c int
  6. func main() {
  7. go func() {
  8. a = 1
  9. b = 2
  10. }()
  11. go func() {
  12. c = a + 2
  13. }()
  14. log.Println(a, b, c)
  15. }

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)來說允許可見的:

  1. r不在w開始之前發生(可以是之後或併發);
  2. w和r之間沒有另一個寫操作(w’)發生;

為了保證對變數(v)的一個特定寫操作(w)對一個讀操作(r)可見,就需要確保w是r唯一允許的寫操作,於是如果以下情況滿足,則對變數(v)的記憶體寫操作(w)對一個記憶體讀操作(r)來說保證可見的:

  1. w在r開始之前發生;
  2. 所有其它對v的寫操作只在w之前或r之後發生;

可以看出後一種約定情況比前一種更嚴格,這種情況要求沒有w或r沒有其他的併發寫操作。

在單個Goroutine裡,因為肯定沒有併發,上面兩種情況是等價的。對變數v的讀操作可以讀到最近一次寫操作的值(這個應該很容易理解)。但在多個Goroutine裡如果要訪問一個共享變數,我們就必須使用同步工具來建立happens-before條件,來保證對該變數的讀操作能讀到期望的修改值。

要保證並行執行體對共享變數的順序訪問方法就是用鎖。Java和Go在這點上是一致的。

以下是具體的可被利用的Go語言的happens-before規則,從本質上來講,happens-before規則確定了CPU緩衝和主存的同步時間點(通過記憶體屏障等指令),從而使得對變數的讀寫順序可被確定–也就是我們通常說的“同步”。

同步方法

初始化

  1. 如果package p 引用了package q,q的init()方法 happens-before p (Java工程師可以對比一下final變數的happens-before規則
  2. main.main()方法 happens-after所有package的init()方法結束。

建立Goroutine

  1. go語句建立新的goroutine happens-before 該goroutine執行(這個應該很容易理解)
  1. package main
  2. import (
  3. "log"
  4. "time"
  5. )
  6. var a, b, c int
  7. func main() {
  8. a = 1
  9. b = 2
  10. go func() {
  11. c = a + 2
  12. log.Println(a, b, c)
  13. }()
  14. time.Sleep(1 * time.Second)
  15. }

利用這條happens-before,我們可以確定c=a+2是happens-aftera=1和b=2,所以結果輸出是可以確定的1 2 3,但如果是下面這樣的程式碼,輸出就不確定了,有可能是1 2 30 0 2

  1. func main() {
  2. go func() {
  3. c = a + 2
  4. log.Println(a, b, c)
  5. }()
  6. a = 1
  7. b = 2
  8. time.Sleep(1 * time.Second)
  9. }

銷燬Goroutine

  1. Goroutine的退出並不保證happens-before任何事件
  1. var a string
  2. func hello() {
  3. go func() { a = "hello" }()
  4. print(a)
  5. }

上面程式碼因為a="hello" 沒有使用同步事件,並不能保證這個賦值被主goroutine可見。事實上,極度優化的Go編譯器甚至可以完全刪除這行程式碼go func() { a = "hello" }()

Goroutine對變數的修改需要讓對其它Goroutine可見,除了使用鎖來同步外還可以用Channel。

Channel通訊

在Go程式設計中,Channel是被推薦的執行體間通訊的方法,Go的編譯器和執行態都會盡力對其優化。

  1. 對一個Channel的傳送操作(send) happens-before 相應Channel的接收操作完成
  2. 關閉一個Channel happens-before 從該Channel接收到最後的返回值0
  3. 不帶緩衝的Channel的接收操作(receive) happens-before 相應Channel的傳送操作完成
  1. var c = make(chan int, 10)
  2. var a string
  3. func f() {
  4. a = "hello, world"
  5. c <- 0
  6. }
  7. func main() {
  8. go f()
  9. <-c
  10. print(a)
  11. }

上述程式碼可以確保輸出hello, world,因為a = "hello, world" happens-before c <- 0print(a) happens-after <-c, 根據上面的規則1)以及happens-before的可傳遞性,a = "hello, world" happens-beforeprint(a)

根據規則2)把c<-0替換成close(c)也能保證輸出hello,world,因為關閉操作在<-c接收到0之前傳送。

  1. var c = make(chan int)
  2. var a string
  3. func f() {
  4. a = "hello, world"
  5. <-c
  6. }
  7. func main() {
  8. go f()
  9. c <- 0
  10. print(a)
  11. }

根據規則3),因為c是不帶緩衝的Channel,a = "hello, world" happens-before <-c happens-beforec <- 0 happens-before print(a), 但如果c是緩衝佇列,如定義c = make(chan int, 1), 那結果就不確定了。

sync 包實現了兩種鎖資料結構:

  1. sync.Mutex -> java.util.concurrent.ReentrantLock
  2. sync.RWMutex -> java.util.concurrent.locks.ReadWriteLock

其happens-before規則和Java的也類似:

  1. 任何sync.Mutex或sync.RWMutex 變數(l),定義 n < m, 第n次 l.Unlock() happens-before 第m次l.lock()呼叫返回。
  1. var l sync.Mutex
  2. var a string
  3. func f() {
  4. a = "hello, world"
  5. l.Unlock()
  6. }
  7. func main() {
  8. l.Lock()
  9. go f()
  10. l.Lock()
  11. print(a)
  12. }

a = "hello, world" happens-before l.Unlock() happens-before 第二個 l.Lock() happens-beforeprint(a)

Once

sync包還提供了一個安全的初始化工具Once。還記得Java的Singleton設計模式,double-check,甚至triple-check的各種單例初始化方法嗎?Go則提供了一個標準的方法。

  1. once.Do(f)中的f() happens-before 任何多個once.Do(f)呼叫的返回,且f()有且只有一次呼叫。
  1. var a string
  2. var once sync.Once
  3. func setup() {
  4. a = "hello, world"
  5. }
  6. func doprint() {
  7. once.Do(setup)
  8. print(a)
  9. }
  10. func twoprint() {
  11. go doprint()
  12. go doprint()
  13. }

上面的程式碼雖然呼叫兩次doprint(),但實際上setup只會執行一次,並且併發的once.Do(setup)都會等待setup返回後再繼續執行。

參考連結

  1. http://golang.org/ref/mem
  2. http://en.wikipedia.org/wiki/Java_Memory_Model
  3. http://ifeve.com/java-memory-model-1/
  4. http://code.google.com/p/golang-china/wiki/go_mem

相關文章