go語言happens-before原則及應用

瞭解go中happens-before規則,尋找併發程序不肯定性中的肯定性。程序員

引言

先拋開你所熟知的信號量、鎖、同步原語等技術,思考這個問題:如何保證併發讀寫的準確性?一個沒有任何併發編程經驗的程序員可能會以爲很簡單:這有什麼問題呢,同時讀寫能有什麼問題,最多就是讀到過時的數據而已。一個理想的世界固然是這樣,只惋惜實際上的機器世界每每隱藏了不少不容易被察覺的事情。至少有兩個行爲會影響這個結論:golang

  • 編譯器每每有指令重排序的優化;例如程序員看到的源代碼是a=3; b=4;,而實際上執行的順序多是b=4; a=3;,這是由於編譯器爲了優化執行效率可能對指令進行重排序;
  • 高級編程語言所支持的運算每每不是原子化的;例如a += 3實際上包含了讀變量、加運算和寫變量三次原子操做。既然整個過程並非原子化的,就意味着隨時有其它「入侵者」侵入修改數據。更爲隱藏的例子:對於變量的讀寫甚至可能都不是原子化的。不一樣機器讀寫變量的過程多是不一樣的,有些機器多是64位數據一次性讀寫,而有些機器是32位數據一次讀寫。這就意味着一個64位的數據在後者的讀寫上其實是分紅兩次完成的!試想,若是你試圖讀取一個64位數據的值,先讀取了低32的數據,這時另外一個線程切進來修改了整個數據的值,最後你再讀取高32的值,將高32和低32的數據拼成完整的值,很明顯會獲得一個預期之外的數據。

看起來,整個併發編程的世界裏一切都是不肯定的,咱們不知道每次讀取的變量究竟是不是及時、準確的數據。幸運的是,不少語言都有一個happens-before的規則,能幫助咱們在不肯定的併發世界裏尋找一絲肯定性。編程

happens-before

你能夠把happens-before看做一種特殊的比較運算,就好像><同樣。對應的,還有happens-after,它們之間的關係也好像><同樣:segmentfault

若是a happens-before b,那麼b happens-after a

那是否存在既不知足a happens-before b,也不知足b happens-before a的狀況呢,就好像既不知足a>b,也不知足b>a(意味着b==a)?固然是確定的,這種狀況稱爲:a和b happen concurrently,也就是同時發生,這就回到咱們以前所熟知的世界裏了。數據結構

happens-before有什麼用呢?它能夠用來幫助咱們釐清兩個併發讀寫之間的關係。對於併發讀寫問題,咱們最關心的常常是reader是否能準確觀察到writer寫入的值。happens-before正是爲這個問題設計的,具體來講,要想讓某次讀取r準確觀察到某次寫入w,只需知足:併發

  1. w happens-before r;
  2. 對變量的其它寫入w1,要麼w1 happens-before w,要麼r happens-before w1;簡單理解就是沒有其它寫入覆蓋此次寫入;

只要知足這兩個條件,那咱們就能夠自信地確定咱們必定能讀取到正確的值。app

一個新的問題隨之誕生:那如何判斷a happens-before b是否成立呢?你能夠類比思考數學裏如何判斷a > b是否成立的過程,咱們的作法很簡單:編程語言

  1. 基於一些簡單的公理;例如天然數的天然大小:3>2>1
  2. 基於比較運算符的傳遞性,也就是若是a>b且b>c,則a>c

判斷a happens-before b的過程也是相似的:根據一些簡單的明確的happens-before關係,再結合happens-before的傳遞性,推導出咱們所關心的w和r之間的happens-before關係。函數

happens-before傳遞性:若是a happens-before b,且b happens-before c,則a happens-before c

所以咱們只須要了解這些明確的happens-before關係,就能在併發世界裏尋找到寶貴的肯定性了。工具

go語言中的happens-before關係

具體的happens-before關係是因語言而異的,這裏只介紹go語言相關的規則,感興趣能夠直接閱讀官方文檔,有更完整、準確的說明。

天然執行

首先,最簡單也是最直觀的happens-before規則:

同一個goroutine裏,書寫在前的代碼 happens-before書寫在後的代碼。

例如:

a = 3; // (1)
b = 4; // (2)

則(1) happens-before (2)。咱們上面提到指令重排序,也就是實際執行的順序與書寫的順序可能不一致,但happens-before與指令重排序並不矛盾,即便可能發生指令重排序,咱們依然能夠說(1) happens-before (2)。

初始化

每一個go文件均可以有一個init方法,用於執行某些初始化邏輯。當咱們開始執行某個main方法時,go會先在一個goroutine裏作初始化工做,也就是執行全部go文件的init方法,這個過程當中go可能建立多個goroutine併發地執行,所以一般狀況下各個init方法是沒有happens-before關係的。關於init方法有兩條happens-before規則:

1.a 包導入了 b包,此時b包的 init方法 happens-before a包的全部代碼;
2.全部 init方法 happens-before main方法;

goroutine

goroutine相關的規則主要是其建立和銷燬的:

1.goroutine的建立 happens-before 其執行;
2.goroutine的完成 不保證 happens-before任何代碼;

第一條規則舉個簡單的例子便可:

var a string

func f() {
    fmt.Println(a) // (1)
}

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

由於goroutine的建立 happens-before 其執行,因此(3) happens-before (1),又由於天然執行的規則(2) happens-before (3),根據傳遞性,因此(2) happens-before (1),這樣保證了咱們每次打印出來的都是"hello world"而不是空字符串。

第二條規則是少見的否認句式,一樣舉個簡單的例子:

var a string

func hello() {
    go func() { a = "hello" }() // (1)
    fmt.Println(a) // (2)
}

因爲goroutine的完成不保證happens-before任何代碼,所以(1) happens-before (2)不成立,這樣咱們就不能保證每次打印的結果都是"hello"。

通道

通道channel是go語言中用於goroutine之間通訊的主要渠道,所以理解通道之間的happens-before規則也相當重要。

1.對於緩衝通道,向通道發送數據 happens-before從通道接收到數據

結合一個例子:

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

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

func main() {
    go f() // (3)
    <-c // (4)
    fmt.Println(a) // (5)
}

c是一個緩衝通道,所以向通道發送數據happens-before從通道接收到數據,也就是(2) happens-before (4),再結合天然執行規則以及傳遞性不難推導出(1) happens-before (5),也就是打印的結果保證是"hello world"。
有趣的是,若是咱們把c的定義改成var c = make(chan int)也就是無緩衝通道,上面的結論就不存在了(注1),打印的結果不必定爲"hello world",這是由於:

2.對於無緩衝通道,從通道接收數據 happens-before向通道發送數據

咱們能夠將上述例子稍微調整下:

var c = make(chan int)
var a string

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

func main() {
    go f() // (3)
    c <- 10 // (4)
    fmt.Println(a) // (5)
}

對於無緩衝通道,(2) happens-before (4),再根據傳遞性,(1) happens-before (5),所以依然能夠保證打印的結果是"hello world"。

能夠這麼理解這二者的差別,緩衝通道的目的是緩衝發送方發送的數據,這就意味着發送方極可能先發送數據,過一段時間後接收方纔接收,或者發送方發送的速度超過接收方接收的速度,由於緩衝通道的發送happens-before接收就天然而然了;相反,非緩衝通道是沒有緩衝區的,先發起的發送方和接收方都會阻塞至另外一方準備好,若是咱們使用了非緩衝通道,則意味着咱們認爲咱們的場景下接收發生在發送以前,不然咱們就會使用緩衝通道了,所以非緩衝通道的接收happens-before發送。

3.對於緩衝通道,第k次接收 happens-beforek+C次發送, C是緩衝通道的容量

這條規則是緩衝通道的通用規則(有趣的是,上面針對非緩衝通道的第2條規則也能夠當作這個規則的特例:C取0)。這個規則看起來複雜,咱們看個例子就清晰了:

var limit = make(chan int, 3)

func main() {
    // work是一個worker列表,其中的元素w都是可執行函數
    for _, w := range work {
        go func(w func()) {
            limit <- 1 // (1)
            w() // (2)
            <-limit // (3)
        }(w)
    }
    select{}
}

咱們先套用一下上面的規則,則:「第1次(3)happens-before第4次(1)」、「第2次(3)happens-before第5次(1)」、「第3次(3)happens-before第6次(1)」……,再結合傳遞性:「第1次(2)happens-before第1次(3)happens-before第4次(1)happens-before第4次(2)」、「第2次(2)happens-before第2次(3)happens-before第5次(1)happens-before第5次(2)」……,簡單地說:「第1次(2)happens-before第4次(2)」、「第2次(2)happens-before第5次(2)」、「第3次(2)happens-before第6次(2)」……這樣咱們雖然沒有作任何分批,卻事實上將workers分紅三個一批、每批併發地執行。這就是經過這條happens-before規則保證的。

這個規則理解起來其實也很簡單,C是通道的容量,若是沒法保證第k次接收happens-beforek+C次發送,那通道的緩衝就不夠用了。

注1:以上是官方文檔給的規則和例子,可是筆者在嘗試將第一個例子的 c改爲無緩衝通道後發現每次打印的依然穩定是"hello world",並無出現預期的空字符串,也就是看起來 happens-before規則依然成立。但既然官方文檔說沒法保證,那咱們開發時仍是按照 happens-before不成立比較好。

鎖也是併發編程裏很是經常使用的一個數據結構。go語言中支持的鎖主要有兩種:sync.Mutexsync.RWMutex,即普通鎖和讀寫鎖(讀寫鎖的原理能夠參見另外一篇文章)。普通鎖的happens-before規則也很直觀:

1.對鎖實例調用 nUnlock happens-before 調用 Lock m次,只要 n < m

請看這個例子:

var l sync.Mutex
var a string

func f() {
    a = "hello, world" // (1)
    l.Unlock() // (2)
}

func main() {
    l.Lock() // (3)
    go f() // (4)
    l.Lock() // (5)
    print(a) // (6)
}

上面調用了Unlock一次,Lock兩次,所以(2) happens-before (5),從而(1) happens-before (6)

而讀寫鎖的規則爲:

2.對讀寫鎖實例的某一次 Unlock調用, happens-afterRLock調用對應的 RUnlock調用 happens-before下一次 Lock調用。

其實本質就是讀寫鎖的原理:讀寫互斥,簡單地理解就是寫鎖釋放後先獲取了讀鎖,則讀鎖的釋放會happens-before 下一次寫鎖的獲取。注意上面的規則是「存在」,而不是「任意」。

Once

sync中還提供了一個Once的數據結構,用於控制併發編程中只執行一次的邏輯,例如:

var a string
var once sync.Once

func setup() {
   a = "hello, world"
   fmt.Println("set up")
}

func doprint() {
   once.Do(setup)
   fmt.Println(a)
}

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

會打印"hello, world"兩次和"set up"一次。Oncehappens-before規則也很直觀:

第一次執行 Once.Do happens-before其他的 Once.Do

應用

掌握了上述的基本happens-before規則,能夠結合起來分析更復雜的場景了,來看這個例子:

var a, b int

func f() {
    a = 1 // (1)
    b = 2 // (2)
}

func g() {
    print(b) // (3)
    print(a) // (4)
}

func main() {
    go f()
    g()
}

這裏(1) happens-before (2),(3) happens-before(4),可是(1)與(3)、(4)之間以及(2)與(3)、(4)之間並無happens-before關係,這時候結果是不肯定的,一種有趣的結果是二、0,也就是(1)、(2)之間發生了指令重排序。如今讓咱們修改一下上面的代碼,讓它按咱們預期的邏輯運行:要麼打印0、0,要麼打印一、2。

使用鎖

var a, b int
var lock sync.Mutex

func f() {
    lock.Lock() // (1)
    a = 1 // (2)
    b = 2 // (3)
    lock.Unlock() // (4)
}

func g() {
    lock.Lock() // (5)
    print(b) // (6)
    print(a) // (7)
    lock.Unlock() // (8)
}

func main() {
    go f()
    g()
}

回想下鎖的規則:

1.對鎖實例調用 nUnlock happens-before 調用 Lock m次,只要 n < m

這裏存在兩種可能:要麼(4) happens-before (5),要麼(8) happens-before (1),會分別推導出兩種結果:(6) happens-before (7) happens-before (2) happens-before (3) ,以及(2) happens-before (3) happens-before (6) happens-before (7),也就分別對應「0、0」和「一、2」兩種結果。

使用通道

var a, b int
var c = make(chan int, 1)

func f() {
   <- c
   a = 1 // (2)
   b = 2 // (3)
   c <- 1
}

func g() {
   <- c
   print(b) // (6)
   print(a) // (7)
   c <- 1
}

func test() {
   wg := sync.WaitGroup{}
   wg.Add(3)
   go func(){
      defer wg.Done()
      f()
   }()
   go func(){
      defer wg.Done()
      g()
   }()
   go func(){
      defer wg.Done()
      c <- 1
   }()
   wg.Wait()
   close(c)
}

總之,若是沒法肯定併發讀寫之間的happens-before關係,那麼最好使用同步工具明確它們之間的關係,例如鎖或者通道。不要給程序留下不肯定的可能,畢竟肯定性就是編程的魅力!

相關文章
相關標籤/搜索