Golang內存模型


Ref: https://golang.org/ref/mem

 

  1. 簡介

golang內存模型,主要說明了如下問題。在一個goroutine中讀取變量,而該變量是由其他goroutine賦值的,這種情況下如何能夠安全正確的讀取。

  1. 建議

對於有多個goroutine在使用的變量,修改時需要序列化的讀取。

主要方式包括,通過channel的方式、sync/atomic等原子同步操作等

如果你想讀完以下內容,以便理解你的程序內在運行機制,說明你很聰明。

但是不建議你這么聰明~

  1. 歷史經驗

只有一個goroutine的時候,讀寫操作都會如程序定義的順序執行。這是因為,盡管編譯器和中央處理器是可能會改變執行順序,但並不會影響編程語言定義的goroutine中的行為邏輯。但也是因為可能改變執行順序,同樣的操作在不同的goroutine中觀察到的執行順序並不一致。比如A goroutine執行了a=1;b=2;另一個goroutine觀察到的結果可能是b先於a被賦值了

為具體說明讀寫操作的要求,我們定義了之前某個版本中Golang程序中內存操作的一部分邏輯如下。如果事件e1發生在事件e2之前,我們說e2發生在e1之后.如果e1並不發生在e2之前,也不發生e2在之后,我們說e1和e2是同步發生的。

在一個單goroutine的程序中,事件發生的順序就是程序描述的順序。

滿足如下兩個條件的情況下,對變量v的讀取操作r,是能夠觀察到對v的寫入操作w的:

  1. r並不發生在w之前;
  2. r之前,w之后,再沒有其他對v的寫操作;

為了保證對變量v對讀取操作r,能夠觀察到特定的對v得寫操作w,需要保證w是r唯一能夠觀察到寫操作。因此,要保證r能夠觀察到w需要滿足如下兩個條件:

  1. w發生在r之前;
  2. 其他對v的寫操作,要么發生在w之前,要么發生在r之后;

這對條件是比第一個條件更加嚴格,它要求r和w的同時,沒有其它的寫操作(即和r或w同步的寫操作);

在單一goroutine里面,沒有同步操作,所以以上兩組條件是等價的。但是對於多goroutine,需要通過同步事件來確定順序發生,從而保證讀操作能夠觀察到寫操作。

在內存模型里面,變量v初始化為零值,也是一種寫操作。

讀寫大於單一機器碼的變量的動作,實際操作順序不定,

 

  1. 同步
    • 初始化

程序初始化在單一goroutine里面,但是goroutine會創建其他goroutine,然后多個goroutine同步執行。

如果package p引用了package q,q的init函數的執行,會先於所有p的函數執行。

Main.main函數的執行,在所有init函數執行完后。

    • Goroutine的創建

go表達式,會創建一個goroutine,然后該goroutine才能開始執行。

var a string

func f() {
        print(a)
}

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

 

以上代碼示例,調用hello函數,可能在hello已經return到時候,f才回執行print。

    • Goroutine的銷毀

goroutine的退出時機並沒有保證一定會在某個事件之前。

var a string

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

比如以上示例,對a的賦值,並不保證與hello本身的任何動作保持同步關系,所以也不能保證被其他任何goroutine的讀操作觀察到。事實上,任何一個激進的編譯器都會把這里整個go表達式直接刪掉,不做編譯。

如果一個goroutine的影響想被其他的goroutine觀察到,必須通過同步機制(比如鎖、channel)來確定相對順序關系。

    • Channel通信

channel通信是goroutines之間主要的同步方式。一般來說channel上的每一次send都會相應有另一個goroutine從此channel受到消息。

同一個channel上,send操作總是先於相應的receive操作完成。

var c = make(chan int, 10)
var a string

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

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

 

以上示例,能夠保證print出『hello, world』。對a的寫,是先於往c中發送0,而從c中接收值,先於print。

channel的關閉,先於接收到該channel關閉而發出來的0.

在上面這個例子中用close(c)代替 c<-0,其效果是一樣的。

對於沒有緩存的channel,receive發生在send完成之前。

var c = make(chan int)
var a string

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

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

 

以上示例,依舊能夠保證print出『hello, world』。對a的寫,先於從c接收;從c接收,先於 c <- 0執行完 c <- 0執行完,先於print執行。

 

但如果channel是緩存的(例如c = make(chan int, 1)),那么以上程序不能保證print出『hello, world』,甚至有可能出現空值、crash等情況;

對於緩存容量為C的channel,第k次接收,先於K+C次發送完成

這條規則概括了緩存和非緩存channel的規則。因此基於帶緩存的channel,可以實現令牌策略:在channel中緩存的數量代表active的數量;channel的緩存容量表示最大可以使用的數量;發送消息表示申請了一個令牌,接收消息表示釋放了一塊令牌。這是限制並發常用的一種手段。

var limit = make(chan int, 3)

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

 

以上示例程序,對於work list中的每一條,都創建了一個goroutine,但是用limit這個帶緩存的channel來限制了,最多同時只能有3個goroutines來執行work方法

 

    • 鎖機制

sync包中實現了兩個鎖的數據類型,分別是sync.Mutex和sync.RWMutex

對於任何的sync.Mutex和sync.RWMutex類型變量l,和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)
}

 

如上示例能夠保證print出『hello, world』。f中第一個l.Unlock()的調用,先於main中第二個l.Lock()的調用;第二個l.Lock()的調用先於print的調用;

任何對於l.Rlock的調用(其中l為sync.RWMutex類型變量),總是有一個n,l.Lock在調用n執行l.Unlock之后才能return;對應的,l.RUnlock的執行在調用n+1執行l.Unlock之前

 

    • 單例(Once

Sync包提供了一種安全的多goroutine種初始化機制,那就是Once類型。對於特定的方法f,多個線程都能調用Once.Do(f),但是只有一個線程會執行f,其他線程的調用都會阻塞住,直到f執行完。

對於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()
}

 

這里print兩次『hello, world』,但只有第一次調用doprint會執行setup賦值。

 

  1. 不正確的同步

對於同步發生的讀操作r和寫操作w,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,然后是0.這個事實顛覆了我們的一些習慣認知

 

對於同步問題加鎖一定要double check

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()
}

如這個例子,不能保證觀察到done的寫操作時候,也能觀察到對a的寫操作。其中一個goroutine可能打印出空字符串。

 

另外一種錯誤的典型如下:

var a string
var done bool

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

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

 

同上一個例子一樣,這里對done得寫觀察,不能保證對a的寫觀察,所以也可能打印出空字符串。

更甚,由於main和setup兩個線程間沒有同步事件,並不能保證main中一定能觀察到done的寫操作,因此main中的一直循環下去沒有結束。(這里不是很理解,只能說setup的執行時機和main中for循環沒有明確的相對先后和相對距離,所以可能導致循環很久setup還沒執行,或執行了但是沒有更新到main所讀取的done

還有以上風格的一些變體,如下:

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的賦值而退出循環,但是也不能保證觀察到g.msg的初始化值

 

對於以上所有例子,解決方案是一樣的,定義明確的同步機制。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM