The Go Memory Model(Go 內存模型)

原文鏈接The Go Memory Model

Introduction

G 內存模型指定了一些條件,保證了在一個goroutine中如何讀取一個同時被其他goroutine寫入的變量

Advice

如果程序要修改一個被多個goroutine訪問的變量,此類訪問必須被序列化。

爲了序列了訪問,我們可以採用channel操作或者使用syncsync/atomic包下的同步原語來保護數據。

If you must read the rest of this document to understand the behavior of your program, you are being too clever.
Don't be clever.
如果你必須閱讀這篇文檔才能理解你的程序行爲,那麼說明你的程序是有問題的。作者的意思是希望我們用channel或者同步原語來保證數據的併發讀寫,而不是利用內存模型來推斷程序運行行爲

Happens Before

在一個單獨的goroutine中,讀寫必須按照程序指定的順序執行。但是,編譯器和處理器(CPU)可以對讀寫指令進行重排序,僅當這種重排序對程序的執行結果沒有影響[1]。由於這種重排序,一個 goroutine 觀察到的執行順序可能與另一個 goroutine 感知到的順序不同。例如,如果一個 goroutine 執行a = 1; b = 2;,另一個 goroutine 可能會在 a 的值更新之前觀察到 b 的更新值。

爲了規定這種讀寫順序,我們定義了Happen Before(先行發生)原則[2],規定了在Go程序中操作內存的局部順序。如果一個事件e1發生在事件e2之前(happen before),那麼我們說e2發生在e1之後(happen after)。如果e1既不happen before也不happen after事件e2,那麼e1e2是併發發生的(happening concurrently)。

在一個單獨的goroutine中,happen before順序就是程序表達的順序

observe:當讀操作r讀取到的值剛好是寫操作w執行的結果,那麼我們說wr觀察(observe)到

在滿足下麪條件下,對於變量v的讀操作r,可以observe到對應的寫操作w

  • r沒有happen before w(happen after or happen concurrency)
  • 沒有其他的寫操作w' happen after whappen before r (在wr之間沒有另外的確定的寫操作,可以是happening concurrently)

爲了保證r一定能observe到特定的w,即確保wr唯一可以observe的結果,也就是r被確保能obersew,必須保證以下條件:

  • w happen before r
  • 任何其他的寫操作,要麼happen before w,要麼happen after r(即,在wr之間,沒有任何其他的寫事件,包括happen before 和 happening concurrently)

這兩個條件比上一對嚴格多了,它要求在wr之間沒有另外的寫操作happening concurrently

在一個單獨的goroutine中,沒有併發,所以這兩組條件是相同的:一個讀操作r必然observe到最近的寫操作w。當多個goroutine同時訪問一個共享變量v,必須使用同步事件建立happen before條件確保robserve到特定的w

變量初始化爲零值,在內存模型中表現爲寫操作

對於大於單個機器字的值的讀取和寫入操作,表現爲以未指定順序進行的多個機器字大小的操作。 即對於一個大於單個機器字(32位機器爲4byte,64位機器爲8byte)的對象,其讀取和寫入都是多個操作,且是happening concurrently,其順序是不可預測的。

Synchronization

Initialization

程序的初始化在單個 goroutine 中運行,但該 goroutine 可能會創建其他併發運行的 goroutine。

如果包 p 導入包 q,則 q 的 init 函數在包 p 的任何代碼開始之前完成。

函數 main.main 在所有的 init 函數完成後開始執行。

Goroutine creation

go語句開始一個新的goroutine happen before 新goroutine開始執行

例如:

var a string

func f() {
    print(a)
}

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

調用hello()將在未來的某個時刻打印hello world(也許在helloreturn之後)打印出來的一定是hello world,因爲a的賦值操作先行發生於go f()

Goroutine destruction

goroutine的終止並沒有保證happen before程序的任何事件。 例如:

var a string

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

a的賦值並沒有伴隨任何同步事件,所以它並不能確保被其他goroutineobserve到。事實上,激進的編譯器可能會刪掉整個go func() { a = "hello" }() 語句,因爲它不一定會生效。

如果一個goroutine的效果必須被其他goroutineobserve,要使用鎖或者channel通訊這類同步機制建立相對順序。

Channel communication

Channel 通信是不同goroutine的主要同步手段。通常在不同的goroutine中,對應特定channel的每個send操作,都有對應的receive操作

channel的send操作 happen before對應的`receive操作完成之前

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,因爲a的賦值操作先行發生於c的send操作c<-0,c的send操作先行發生於mian中對應的recevie操作<-c,recevie操作先行發生於print(a)

當一個channel關閉後,receive會收到零值

channel的close操作happen before 因爲channel cloase收到零值的receive操作

在上面的例子,把c<-0替換爲close(c),程序的執行順序是一致的。

沒有緩衝區的channel的receive操作happen before對應的send操作完成

下面的程序跟上一個類似,但是send和receive操作交換了,而且使用了unbuffered channel

var c = make(chan int)
var a string

func f() {
    a = "hello, world"
    <-c
}

func main() {
    go f()
    c <- 0
    print(a)
}

這個程序依然能保證打印hello world,因爲a的賦值先行發生於receive操作<-c,receive操作先行發生於send操作c<-0完成,send操作完成先行發生於print操作[3]

如果代碼中的channel是一個帶緩衝區的channel(例如c = make(chan int, 1)),那麼程序將無法保證打印hello world(可能會打印空字符串,崩潰或執行其他操作。)

對於容量C的channel,第k個receive操作happend before第k+C個send操作完成

此規則概括了先前的有緩衝的 channel 的規則。它允許用有緩衝的 channel 建立的計數信號量:channel中items的數量對應於資源當前的使用數量,channel的容量對應於資源同時允許的最大使用數量。send一個item到channel中代表獲取一個信號量,從channel中receive一個item代表釋放一個信號量。這是一個限制併發的通用用法。

下面程序爲work list中的每個entry開啓一個goroutine,但是這些goroutine協調使用limit channel確保最多同時有三個goroutine可以運行work方法。

var limit = make(chan int, 3)

func main() {
    for _, w := range work {
        go func(w func()) {
            limit <- 1
            w()
            <-limit
        }(w)
    }
    select{}
}

Locks

sync包實現了sync.Mutexsync.RWMutex兩種鎖類型。

對於每個sync.Mutex或者sync.RWMutex類型的鎖l,如果n<m,那麼調用第n個l.Unlock() happend before 調用第m個l.Lock()返回

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() happen before 第二個l.Lock()返回。

對於sync.RWMutex類型的鎖l的每個l.RLock()調用,l.RLock()成功返回happen after 第n次l.Unlock(),那麼對應的l.RUnlock()返回 happend before 第n+1次l.Lock()

Once

sync通過Once類型,提供了一種在多個goroutine下初始化的安全機制。多個goroutine通過once.Do(f)執行特定的f(),但只有一個goroutine會真正執行,而其他goroutine會阻塞直到f()執行結束返回。

通過once.Do(f)執行的唯一f()返回 happen before於任意once.Do(f) 返回

var a string
var once sync.Once

func setup() {
    a = "hello, world"
}

func doprint() {
    once.Do(setup)
    print(a)
}

func twoprint() {
    go doprint()
    go doprint()
}

在上面的程序中,調用twoprint()將執行唯一一次setup(),且setup()返回happen before任意一個print,所以程序會打印兩次hello world。

Incorrect synchronization

注意,讀取操作 r 可以觀察到與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()
}

在上面的程序中,可能會打印2和0。f()中的a=1b=2並沒有happen before關係

這個事實使得一些常用習慣性用法失效。

雙重檢查鎖(Double-checked lock)是一種爲了避免同步開銷的用法。例如,上面的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的寫入操作。這個版本可能(錯誤地)打印空字符串而不是"hello,world"。

另一個不正確的慣用語法是忙着等待一個值,如:

var a string
var done bool

func setup() {
    a = "hello, world"
    done = true
}

func main() {
    go setup()
    for !done {
    }
    print(a)
}

跟之前的程序一樣,並不能保證,在main方法中,觀察到done的寫入操作意味着同樣能觀察到對a的寫入操作,所以這個程序也可能打印一個空字符串。更糟糕的是,無法保證main方法可以observe帶done的寫操作,因爲兩個goroutine之間並沒有同步事件,main方法的循環無法保證一定會退出。

這個主題有一些微小的變種,如下:

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能observe到 g!=nil,也無法保證g.msg被初始化。

在這些例子中,解決方案都是一樣的:使用顯示的同步機制

總結

  • Within a single goroutine, the happens-before order is the order expressed by the program.
    在一個單獨的goroutine中,happen before順序就是程序表達的順序
  • If a package p imports package q, the completion of q's init functions happens before the start of any of p's.
    如果包 p 導入包 q,則 q 的 init 函數在包 p 的任何代碼開始之前完成。
  • The start of the function main.main happens after all init functions have finished.
    函數 main.main 在所有的 init 函數完成後開始執行。
  • The go statement that starts a new goroutine happens before the goroutine's execution begins.
    go語句開始一個新的goroutine happen before 新goroutine開始執行
  • The exit of a goroutine is not guaranteed to happen before any event in the program.
    goroutine的終止並沒有保證happen before程序的任何事件。
  • A send on a channel happens before the corresponding receive from that channel completes.
    channel的send操作 happen before對應的`receive操作完成之前
  • The closing of a channel happens before a receive that returns a zero value because the channel is closed.
    channel的close操作happen before 因爲channel cloase收到零值的receive操作
  • A receive from an unbuffered channel happens before the send on that channel completes.
    沒有緩衝區的channel的receive操作happen before對應的send操作完成
  • The kth receive on a channel with capacity C happens before the k+Cth send from that channel completes.
    對於容量C的channel,第k個receive操作happend before第k+C個send操作完成
  • For any sync.Mutex or sync.RWMutex variable l and n < m, call n of l.Unlock() happens before call m of l.Lock() returns.
    對於每個sync.Mutex或者sync.RWMutex類型的鎖l,如果n<m,那麼調用第n個l.Unlock() happend before 調用第m個l.Lock()返回
  • For any call to l.RLock on a sync.RWMutex variable l, there is an n such that the l.RLock happens (returns) after call n to l.Unlock and the matching l.RUnlock happens before call n+1 to l.Lock.
    對於sync.RWMutex類型的鎖l的每個l.RLock()調用,l.RLock()成功返回happen after 第n次l.Unlock(),那麼對應的l.RUnlock()返回 happend before 第n+1次l.Lock()
  • A single call of f() from once.Do(f) happens (returns) before any call of once.Do(f) returns.
    通過once.Do(f)執行的唯一f()返回 happen before於任意once.Do(f) 返回

譯者理解

個人理解,可能存在錯誤,歡迎討論,敬請指教

  • [1] 指令重排序是優化手段,編譯器可能會根據上下文重排序語言編譯後的彙編指令,CPU可能會在運行過程中動態分析進行重排序,目的都是爲了減小內存與CPU之間的速度差距。
  • [2] 在java的內存模型中,也有happen before原則,事實上,二者是類似的,本質上是一個東西,都是在併發讀寫中規定了共享變量讀寫順序,以保證程序能正確運行。
  • [3] unbuffered channel相當於volatile關鍵字的Barrier作用,它在兩個併發的goroutine設置了一個同步點,即channel收發之前的事件必然happen before channel收發之後的事件。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章