Happens Before 是記憶體模型中一個通用的概念,Go 中也定義了Happens Before以及各種發生Happens Before關係的操作,因為有了這些Happens Before操作的保證,我們寫的多goroutine的程式才會按照我們期望的方式來工作。
什麼是Happens Before關係
Happens Before定義了兩個操作間的偏序關係,具有傳遞性。對於兩個操作E1和E2:
- 如果E1 Happens Before E2, 則E2 Happens After E1;
- 如果E1 Happens E2, E2 Happens Before E3,則E1 Happens E3;
- 如果 E1 和 E2沒有任何Happens Before關係,則說E1和E2 Happen Concurrently。
Happens Before的作用
Happens Before主要是用來保證記憶體操作的可見度。如果要保證E1的記憶體寫操作能夠被E2讀到,那麼需要滿足:
- E1 Happens Before E2;
- 其他所有針對此記憶體的寫操作,要麼Happens Before E1,要麼Happens After E2。也就是說不能存在其他的一個寫操作E3,這個E3 Happens Concurrently E1/E2。
為什麼需要定義Happens Before關係來保證記憶體操作的可見度呢?原因是沒有限制的情況下,編譯器和CPU使用的各種最佳化,會對此造成影響,具體的來說就是操作重排序和CPU CacheLine緩衝同步:
- 操作重排序。現代CPU通常是流水線架構,且具有多個核心,這樣多條指令就可以同時執行。然而有時候出現一條指令需要等待之前指令的結果,或是其他造成指令執行需要延遲的情況。這個時候可以先執行下一條已經準備好的指令,以儘可能高效的利用CPU。操作重排序可以在兩個階段出現:
- CPU 多核心間獨立Cache Line的同步問題。多核CPU通常有自己的一級緩衝和二級緩衝,訪問緩衝的資料很快。但是如果緩衝沒有同步到主存和其他核心的緩衝,其他核心讀取緩衝就會讀到到期的資料。
舉例來說,看一個多Goroutine的程式:
// Sample Routine 1func happensBeforeMulti(i int) {i += 2 // E1go func() { // G1 goroutine createfmt.Println(i) // E2}() // G2 goroutine destryo}
對此來講解:
- 如果編譯器或者CPU進行了重排序,那麼E1的指令可能在E2之後執行,從而輸出錯誤的值;
- 變數i被CPU緩衝到Cache Line中,E1對i的修改只改寫了Cache Line,沒有寫回主存;而E2在另外的goroutine執行,如果和E1不是在同一個核上,那麼E2輸出的就是錯誤的值。
而Happens Before關係,就是對編譯器和CPU的限制,禁止違反Happens Before關係的指令重排序及亂序執行行為,以及必要的情況下保證CacheLine的資料更新等。
Go 中定義的Happens Before保證
1) 單線程
- 在單線程環境下,所有的運算式,按照代碼中的先後順序,具有Happens Before關係。
CPU和正確實現的編譯器,對單線程情況下的Happens Before關係,都是有保障的。這並不是說編譯器或者CPU不能做重排序,只要最佳化沒有影響到Happens Before關係就是可以的。這個依據在於分析資料的依賴性,資料沒有依賴的操作可以重排序。
比如以下程式:
// Sample Routine 2func happsBefore(i int, j int) {i += 2 // E1j += 10 // E2fmt.Println(i + j) //E3}
E1和E2之間,執行順序是沒有關係的,只要保證E3沒有被亂序到E1和E2之前執行就可以。
2) Init 函數
- 如果包P1中匯入了包P2,則P2中的init函數Happens Before 所有P1中的操作
- main函數Happens After 所有的init函數
3) Goroutine
- Goroutine的建立Happens Before所有此Goroutine中的操作
- Goroutine的銷毀Happens After所有此Goroutine中的操作
我們上面提到的Sample Routine 1,按照規則1, E1 Happens before G1,按照本規則,G1 Happens Before E2,從而E1 Happens Before E2。
4) Channel
- 對一個元素的send操作Happens Before對應的receive 完成操作
- 對channel的close操作Happens Before receive 端的收到關閉通知操作
- 對於Unbuffered Channel,對一個元素的receive 操作Happens Before對應的send完成操作
- 對於Buffered Channel,假設Channel 的buffer 大小為C,那麼對第k個元素的receive操作,Happens Before第k+C個send完成操作。可以看出上一條Unbuffered Channel規則就是這條規則C=0時的特例
首先注意這裡面,send和send完成,這是兩個事件,receive和receive完成也是兩個事件。
然後,Buffered Channel這裡有個坑,它的Happens Before保證比UnBuffered 弱,這個弱只在【在receive之前寫,在send之後讀】這種情況下有問題。而【在send之前寫,在receive之後讀】,這樣用是沒問題的,這也是我們通常寫程式常用的模式,千萬注意這裡不要弄錯!
// Channel routine 1var c = make(chan int)var a stringfunc f() {a = "hello, world"<-c}func main() {go f()c <- 0print(a)}
// Channel routine 2var c = make(chan int, 10)var a stringfunc f() {a = "hello, world"<-c}func main() {go f()c <- 0print(a)}
// Channel routine 3var c = make(chan int, 10)var a stringfunc f() {a = "hello, world"c <- 0}func main() {go f()<-cprint(a)}
比如上面這三個程式,使用channel來做同步,程式1和程式3是能夠保證Happens Before關係的,程式2則不能夠,也就是程式可能不會按照期望輸出"hello, world"。
5) Lock
Go裡面有Mutex和RWMutex兩種鎖,RWMutex除了支援互斥的Lock/Unlock,還支援共用的RLock/RUnlock。
- 對於一個Mutex/RWMutex,設n < m,則第n個Unlock操作Happens Before第m個Lock操作。
- 對於一個RWMutex,存在數值n,RLock操作Happens After 第n個UnLock,其對應的RUnLockHappens Before 第n+1個Lock操作。
簡單理解就是這一次的Lock總是Happens After上一次的Unlock,讀寫鎖的RLock HappensAfter上一次的UnLock,其對應的RUnlock Happens Before 下一次的Lock。
6) Once
once.Do中執行的操作,Happens Before 任何一個once.Do調用的返回。
如果你對JVM的記憶體模型及定義的Happens Before關係都有所瞭解,那麼這裡對Go的記憶體模型的講解與之非常類似,理解起來會非常容易。太陽底下無新鮮事,瞭解了一種語言的記憶體模型設計,其他類似的語言也就都可以很容易的理解了。如果是前端或者使用node的程式員,那麼你壓根就不需要清楚這些,畢竟始終只有一個線程在跑是吧。