瞭解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-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,只需知足:併發
happens-before
r;happens-before
w,要麼r happens-before
w1;簡單理解就是沒有其它寫入覆蓋此次寫入;只要知足這兩個條件,那咱們就能夠自信地確定咱們必定能讀取到正確的值。app
一個新的問題隨之誕生:那如何判斷a happens-before b
是否成立呢?你能夠類比思考數學裏如何判斷a > b
是否成立的過程,咱們的作法很簡單:編程語言
3>2>1
a>b且b>c
,則a>c
判斷a happens-before b
的過程也是相似的:根據一些簡單的明確的happens-before
關係,再結合happens-before
的傳遞性,推導出咱們所關心的w和r之間的happens-before
關係。函數
happens-before
傳遞性:若是ahappens-before
b,且bhappens-before
c,則ahappens-before
c
所以咱們只須要了解這些明確的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相關的規則主要是其建立和銷燬的:
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-before
第k+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-before
第k+C
次發送,那通道的緩衝就不夠用了。
注1:以上是官方文檔給的規則和例子,可是筆者在嘗試將第一個例子的c
改爲無緩衝通道後發現每次打印的依然穩定是"hello world",並無出現預期的空字符串,也就是看起來happens-before
規則依然成立。但既然官方文檔說沒法保證,那咱們開發時仍是按照happens-before
不成立比較好。
鎖也是併發編程裏很是經常使用的一個數據結構。go語言中支持的鎖主要有兩種:sync.Mutex
和sync.RWMutex
,即普通鎖和讀寫鎖(讀寫鎖的原理能夠參見另外一篇文章)。普通鎖的happens-before
規則也很直觀:
1.對鎖實例調用n
次Unlock
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-after
的RLock
調用對應的RUnlock
調用happens-before
下一次Lock
調用。
其實本質就是讀寫鎖的原理:讀寫互斥,簡單地理解就是寫鎖釋放後先獲取了讀鎖,則讀鎖的釋放會happens-before
下一次寫鎖的獲取。注意上面的規則是「存在」,而不是「任意」。
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"一次。Once
的happens-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.對鎖實例調用n
次Unlock
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
關係,那麼最好使用同步工具明確它們之間的關係,例如鎖或者通道。不要給程序留下不肯定的可能,畢竟肯定性就是編程的魅力!