首頁 > 軟體

Golang 記憶體模型The Go Memory Model

2022-11-13 14:00:54

1. 簡介(Introduction)

官方原文

本文翻譯了原文並加入了自己的理解。

主要介紹多個 Go協程之間對同一個變數並行讀寫時需要注意的同步措施和執行順序問題。並列出幾個常見錯誤。

Go 記憶體模型涉及到多個 Go協程之間對同一個變數的讀寫。

假如有一個變數,其中一個 Go協程(a) 寫這個變數,另一個 Go協程(b) 讀這個變數;Go 記憶體模型定義了什麼情況下 Go協程(b) 能夠確保讀取到由 Go協程(a) 寫入的值。

2. 建議(Advice)

  • 如果多協程並行修改資料,必須保證各個步驟序列執行(序列化存取)。
  • 為了序列執行,可以使用 channel 或其他同步原語( 如 sync 和 sync/atomic 兩個包裡的那些)來保護被共用的資料。

3. 發生在…之前(Happens Before)

除了重排序需要理解,其餘概念其實沒那麼重要,看後面的例子就懂了。

3.1 重排序

當只有一個 Go協程時,對同一個變數的讀寫必然是按照程式碼編寫的順序來執行的。對於多個變數的讀寫,如果重新排序不影響程式碼邏輯的正常執行,編譯器和處理器可能會對多個變數的讀寫過程重新排序。

比如對於 a = 1; b = 2 這兩個語句,在同一個 Go協程裡先執行 哪個其實是沒有區別的,只要最後執行結果正確就行。

a := 1//1
b := 2//2
c := a + b //3

但是,因為重新排列執行順序的情況的存在,會導致**某個 Go協程所觀察到的執行順序可能與另一個 Go協程觀察到的執行順序不一樣。**可能另一個 Go協程 觀察到的事實是 b 的值先被更新,而 a 的值被後更新。

3.2 happens-before

為了表徵讀寫需求,我們可以定義 happens-before,用來表示 Go 語言中某一小段記憶體命令的執行順序。

  • 如果事件 e1 發生在事件 e2 之前,此時我們就認為 e2 發生在 e1 之後。
  • 如果事件 e1 既不發生在事件 e2 之前,也不發生在 e2 之後,此時我們就認為 e1 和 e2 同時發生(並行)(並行 ≠ 並行)。

3.3 規則

在只有一個 Go協程的內部,happens-before的順序就是程式碼顯式定義的順序。當 Go協程 不僅僅侷限在一個的時候,存在下面兩個規則:

  • 如果存在一個變數 v,下面的兩個條件都滿足,則讀操作 r 允許觀察到(可能觀察到,也可能觀察不到)寫操作 w 寫入的值。
  • r 不在 w 之前發生;
  • 不存在其他的 w’ 在 w 之後發生,也不存在 w’ 在 之前發生。
  • 為了保證讀操作 r 讀取到的是寫操作 w 寫入的值,需要確保 w 是唯一允許被 r 觀察到的寫操作。如果下面的兩個條件都滿足,則 r 保證能夠觀察到 w 寫入的值:
  • w 發生在 r 之前;
  • 其他對共用變數 v 的寫操作要麼發生在 w 之前,要麼發生在 r 之後。

規則二的條件比規則一的條件更為嚴格,它要求沒有其他的寫操作和 w、r 並行地發生。

在一個 Go協程 裡是不存在並行的,因此規則一和規則二是等效的:讀操作 r 可以觀察到最近一次寫操作 w 寫入的值。

但是,當多個協程存取一個共用變數時,就必須使用同步事件來構建 happens-before 的條件,從而保證讀操作觀察到的一定是想要的寫操作。

在記憶體模型中,變數 v 的零值初始化操作等同於一個寫操作。

如果變數的值大於單機器字(CPU 從記憶體單次讀取的位元組數),那麼 CPU 在讀和寫這個變數的時候是以一種不可預知順序的多次執行單機器字的操作,這也是 sync/atomic 包存在的價值。

4. 同步(Synchronization)

4.1 初始化(Initialization)

程式的初始化是在一個單獨的 Go協程 中進行的,但是這個協程可以建立其他的 Go協程 並且二者並行執行。

每個包都允許有一個 init 函數,當這個包被匯入時,會執行該包的這個 init 函數,做一些初始化任務。

  • 如果一個包 p 匯入了包 q, 那麼 q 的 init 函數的執行發生在 p的所有 init 函數的執行之前。(即包的參照鏈)
  • 函數 main.main 的執行發生在所有的 init 函數執行完成之後。

4.2 Go協程的建立(Goroutine creation)

通過 go 語句啟動新的 Go協程這個動作,發生在新的 Go協程的執行之前。比如下面的例子:

var a string
func f() {
  print(a)
}
func hello() {
  a = "hello, world"
  go f()
}

呼叫函數 hello 會在呼叫後的某個時間點列印 “hello, world” ,這個時間點可能在 hello 函數返回之前,也可能在 hello 函數返回之後。

4.3 Go協程的銷燬(Goroutine destruction)

Go協程的退出無法確保發生在程式的某個事件之前。比如下面的例子:

var a string
func hello() {
    go func() { a = "hello" }()
    print(a)
}

其中 a 的賦值語句沒有任何的同步措施,因此無法保證被其他任意的 Go 協程(例如 hello 函數本身)觀察到這個賦值事件的存在。

一些激進的編譯器可能會在編譯階段刪除上面程式碼中的整個 go 語句。

如果某個 Go協程 裡發生的事件必須要被另一個 Go協程 觀察到,需要使用同步機制進行保證,比如使用鎖或者通道(channel)通訊來構建一個相對的事件發生順序。

4.4 通道通訊(Channel communication)

這部分介紹通過 channel 實現並行順序控制。

有快取channel

通道通訊是多個 Go協程 間事件同步的主要方式。在某個特定的通道上傳送一個資料,則對應地可以在這個通道上接收一個資料,一般情況下是在不同的 Go協程 間傳送與接收。

  • 規則一:在某個通道上傳送資料的事件發生在相應的接收事件之前。
    即一定是先傳送資料,才能接收到資料這個順序。
var c = make(chan int, 10)
var a string
func f() {
  a = "hello, world"
  c <- 0
}
func main() {
  go f()
  <-c
  print(a)
}

 上面這段程式碼保證了 `hello, world` 的列印。因為通道的寫入事件 `c <- 0` 發生在讀取事件 `<-c` 之前,而 `<-c` 發生在 `print(a)`之前。通道未被讀取時協程會阻塞。

  • 規則二:通道的關閉事件發生在從通道接收到零值(由通道關閉觸發)之前。
    即一定是先關閉 channel,才能接收到零值。
    在前面的例子中,可以使用 close(c) 來替代 c <- 0 語句來保證同樣的效果。

無快取 channel

  • 規則三:對於沒有快取的通道,資料的接收事件發生在資料傳送完成之前。
    即通道容量為0時,只有傳送的資訊被讀取了才算傳送成功,否則阻塞。
    比如下面的程式碼(類似上面給出的程式碼,但是使用了沒有快取的通道,且傳送和接收的語句交換了一下):
var c = make(chan int) //容量為0,無快取
var a string
func f() {
  a = "hello, world"
  <-c
}
func main() {
  go f()
  c <- 0
  print(a)
}

上面這段程式碼依然可以保證可以列印 `hello, world`。因為通道的寫入事件 `c <- 0` 發生在讀取事件 `<-c` 之前,而 `<-c` 發生在寫入事件 `c <- 0` 完成之前,同時寫入事件 `c <- 0` 的完成發生在 `print` 之前。

上面的程式碼,如果通道是帶快取的(比如 `c = make(chan int, 1)`),程式將不能保證會列印出 `hello, world`,它可能會列印出空字串,也可能崩潰退出,或者表現出一些其他的症狀。

規則抽象

  • 規則四:對於容量為 C 的通道,接收第 k 個元素的事件發生在第 k+C 個元素的傳送之前。
    規則四是規則三在帶快取的通道上的推廣。
  • 它使得帶快取的通道可以模擬出計數號誌:**通道中元素的個數表示活躍數,通道的容量表示最大的可並行數;傳送一個元素意味著獲取一個號誌,接收一個元素意味著釋放這個號誌。**這是一種常見的限制並行的用法。
  • 下面的程式碼給工作列表中的每個入口都開啟一個 Go協程,但是通過配合一個固定長度的通道保證了同時最多有 3 個執行的工作(最多 3 個並行)。
var limit = make(chan int, 3)
func main() {
  for _, w := range work {
    go func(w func()) {
      limit <- 1  // channel裡達到3個即阻塞
      w()
      <-limit  // 取出後channel裡小於3個即可繼續
    }(w)
  }
  select{}
}

4. 鎖

包 sync 實現了兩類鎖資料型別,分別是 sync.Mutex 和 sync.RWMutex,即互斥鎖和讀寫鎖。

  • 規則一:對於型別為 sync.Mutex 和 sync.RWMutex 的變數 l,如果存在 n 和 m 且滿足 n < m,則 l.Unlock() 的第 n 次呼叫返回發生在l.Lock() 的第 m 次呼叫返回之前。

即先解開上一次鎖才能上這一次鎖。

比如下面的程式碼:

var l sync.Mutex
var a string
func f() {
  a = "hello, world"
  l.Unlock()
}
func main() {
  l.Lock()
  go f()
  l.Lock()
  print(a)
}

上面這段程式碼保證能夠列印 `hello, world`。`l.Unlock()`的第 1 次呼叫返回(在函數 f 內部)發生在 `l.Lock()` 的第 2 次呼叫返回之前,後者發生在 `print` 之前。 

  • 規則二:存在型別 sync.RWMutex 的變數 l,如果 l.RLock 的呼叫返回發生在 l.Unlock 的第 n 次呼叫返回之後,那麼其對應的 l.RUnlock 發生在 l.Lock 的第 n+1 次呼叫返回之前。
    即讀鎖可以上多次,但是隻要沒有全解開就不能上寫鎖,寫鎖只能上一個,不解開讀寫鎖都不能上。

5. 單次執行

包 sync 還提供了 Once 型別用來保證多協程的初始化的安全。

多個 Go協程 可以並行執行 once.Do(f) 來執行函數 f, 且只會有一個 Go協程會執行 f(),其他的 Go 協程會阻塞到 f() 執行結束(不再執行 f,但能得到執行結果)

  • 規則一:函數 f() 在 once.Do(f) 的單次呼叫返回發生在其他所有的 once.Do(f) 呼叫返回之前。

比如下面的程式碼:

func setup() {
    time.Sleep(time.Second * 2) //1
    a = "hello, world"
    fmt.Println("setup over") //2
}
func doprint() {
    once.Do(setup) //3
    fmt.Println(a) //4
    wg.Done()
}
func twoprint() {
    go doprint()
    go doprint()
}
func main() {
    wg.Add(2)
    twoprint()
    wg.Wait()
}
setup over
hello, world
hello, world
  • 上面程式碼使用wg sync.WaitGroup等待兩個goroutine執行完畢,由於 setup over只輸出一次,所以setup方法只執行了一次
  • 函數 setup 函數的執行返回發生在所有的 print 呼叫之前,同時會列印出兩次 hello, world,即當一個goroutine在執行setup方法時候,另外一個在阻塞。

6. 不正確的同步方式

6.1 案例一

對某個變數的讀操作 r 一定概率可以觀察到對同一個變數的並行寫操作 w,但是即使這件事情發生了,也並不意味著發生在 r 之後的其他讀操作可以觀察到發生在 w 之前的其他寫操作。(這裡的先後指的是程式碼裡面宣告的操作的先後順序,而不是實際執行時候的)

比如下面的程式碼:

var a, b int
func f() {
  a = 1
  b = 2
}
func g() {
  print(b)
  print(a)
}
func main() {
  go f()
  g()
}

上面的程式碼裡函數 g 可能會先列印 2(b的值),然後列印 0(a的值)。可能大家會認為既然 b 的值已經被賦值為 2 了,那麼 a 的值肯定被賦值為 1 了,但事實是兩個事件的先後在這裡是沒有辦法確定的,因為編譯器會改變執行順序。

上面的事實可以證明下面的幾個常見的錯誤。

6.2 案例二

雙重檢查鎖定嘗試避免同步帶來的開銷。比如下面的例子,twoprint 函數可能會被錯誤地編寫為:

var a string
var done bool
func setup() {
  a = "hello, world"
  done = true
}
func doprint() {
  if !done {
    once.Do(setup)
  }
  print(a)
}
func twoprint() {
  go doprint()
  go doprint()
}

在 doprint 函數中,觀察到對 done 的寫操作並不意味著能夠觀察到對 a 的寫操作。上面的寫法依然有可能列印出空字串。

6.3 案例三

另一個常見的錯誤用法是對某個值的迴圈檢查,比如下面的程式碼:

var a string
var done bool
func setup() {
    a = "hello, world"
    done = true
}
func main() {
    go setup()
    for !done {
    }
    print(a)
}

和上一個例子類似,main函數中觀察到對 done 的寫操作並不意味著可以觀察到對 a 的寫操作,因此上面的程式碼依然可能會列印出空字串。

更糟糕的是,由於兩個 Go協程之間缺少同步事件,main 函數甚至可能永遠無法觀察到對 done 變數的寫操作,導致 main 中的 for 迴圈永遠執行下去。

上面這個錯誤有一種變體,如下面的程式碼所示:

type T struct {
  msg string
}
var g *T
func setup() {
  t := new(T)
  t.msg = "hello, world"
  g = t
}
func main() {
  go setup()
  for g == nil {
  }
  print(g.msg)
}

上面的程式碼即使 main 函數觀察到 g != nil並且退出了它的 for 迴圈,依然沒有辦法保證它可以觀察到被初始化的 g.msg 值。

避免上面幾個錯誤用法的方式是一樣的:顯式使用同步語句。

7. 總結

通過上面所有的例子,不難看出解決多goroutine下共用資料可見性問題的方法是在存取共用資料時候施加一定的同步措施。

以上就是Golang 記憶體模型The Go Memory Model的詳細內容,更多關於Go Memory Model的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com