白話Go內(nèi)存模型&Happen-Before
回復(fù)“Go語言”即可獲贈Python從入門到進階共10本電子書
Go內(nèi)存模型明確指出,一個goroutine如何才能觀察到其他goroutine對同一變量的寫操作。
當(dāng)多個goroutine并發(fā)同時存取同一個數(shù)據(jù)時必須把并發(fā)的存取操作序列化。在Go中保證讀寫的序列化可以通過channel通信或者其他同步原語(例如sync包中的互斥鎖、讀寫鎖和sync/atomic中的原子操作)。
Happens Before
在單goroutine中,讀取和寫入的行為一定是和程序指定的執(zhí)行順序表現(xiàn)一致。換言之,編譯器和處理器在不改變語言規(guī)范所定義的行為前提下才可以對單個goroutine中的指令進行重排序。
a := 1
b := 2
由于指令重排序,b := 2可能先于a := 1執(zhí)行。單goroutine中,該執(zhí)行順序的調(diào)整并不會影響最終結(jié)果。但多個goroutine場景下可能就會出現(xiàn)問題。
var a, b int
// goroutine A
go func() {
a := 5
b := 1
}()
// goroutine B
go func() {
for b == 1 {}
fmt.Println(a)
}()
執(zhí)行上述代碼時,預(yù)期goroutine B能夠正常輸出5,但因為指令重排序,b := 1可能先于a := 5執(zhí)行,最終goroutine B可能輸出0。
“注:上述例子是個不正確的示例,僅作說明用。
”
為了明確讀寫的操作的要求,Go中引入了happens before,它表示執(zhí)行內(nèi)存操作的一種偏序關(guān)系。
happens-before的作用
多個goroutine訪問共享變量時,它們必須建立同步事件來確保happens-before條件,以此確保讀能夠觀察預(yù)期的寫。
什么是Happens Before
如果事件e1發(fā)生在事件e2之前,那么我們說e2發(fā)生在e1之后。同樣,如果e1不在e2之前發(fā)生也沒有在e2之后發(fā)生,那么我們說e1和e2同時發(fā)生。
在單個goroutine中,happens-before的順序就是程序執(zhí)行的順序。那happens-before到底是什么順序呢?我們看看下面的條件。
如果對于一個變量v的讀操作r和寫操作w滿足下述兩個條件,r才允許觀察到w:
r沒有發(fā)生在w之前。 沒有其他寫操作發(fā)生在w之后和r之前。
為了保證變量v的一個讀操作r能夠觀察到一個特定的寫操作w,需要確保w是唯一允許被r觀察的寫操作。那么,如果 r、w 都滿足以下條件,r就能確保觀察到w:
w發(fā)生在r之前。 其他寫操作發(fā)生在w之前后者r之后。
單goroutine中不存在并發(fā),這兩個條件是等價的。老許在此基礎(chǔ)上擴展一下,對于單核心的運行環(huán)境這兩組條件同樣等價。并發(fā)情況下,后一組條件比第一組更加嚴格。
假如你很疑惑,那就對了!老許最開始也很疑惑,這兩組條件就是一樣的呀。為此老許特地和原文進行了反復(fù)對比確保上述的理解是沒有問題的。
我們換個思路,進行反向推理。如果這兩組條件一樣,那原文沒必要寫兩次,果然此事并不簡單。
在繼續(xù)分析之前,要先感謝一下我的語文老師,沒有你我就無法發(fā)現(xiàn)它們的不同。
r沒有發(fā)生在w之前,則r可能的情況是r發(fā)生在w之后或者和w同時發(fā)生,如下圖(實心表示可同時)。
沒有其他寫操作發(fā)生在w之后和r之前,則其他寫w'可能發(fā)生在w之前或者和w同時發(fā)生,也可能發(fā)生在r之后或者和r同時發(fā)生,如下圖(實心表示可同時)。
第二組條件就很明確了,w發(fā)生在r之前且其他寫操作只能發(fā)生在w之前或者r之后,如下圖(空心表示不可同時)。
到這兒應(yīng)該明白為什么第二組條件比第一組條件更加嚴格了吧。在第一組的條件下是允許觀察到w,第二組是保證能觀察到w。
Go中的同步
下面是Go中約定好的一些同步事件,它們能確保程序遵循h(huán)appens-before原則,從而使并發(fā)的goroutine相對有序。
Go的初始化
程序初始化運行在單個goroutine中,但是該goroutine可以創(chuàng)建其他并發(fā)運行的goroutine。
如果包p導(dǎo)入了包q,則q包init函數(shù)執(zhí)行結(jié)束先于p包init函數(shù)的執(zhí)行。main函數(shù)的執(zhí)行發(fā)生在所有init函數(shù)執(zhí)行完成之后。
goroutine的創(chuàng)建結(jié)束
goroutine的創(chuàng)建先于goroutine的執(zhí)行。老許覺得這基本就是廢話,但事情總是沒有那么簡單,其隱含之意大概是goroutine的創(chuàng)建是阻塞的。
func sleep() bool {
time.Sleep(time.Second)
return true
}
go fmt.Println(sleep())
上述代碼會阻塞主goroutine一秒,然后才創(chuàng)建子goroutine。
goroutine的退出是無法預(yù)測的。如果用一個goroutine觀察另一個goroutine,請使用鎖或者Channel來保證相對有序。
Channel的發(fā)送和接收
Channel通信是goroutine之間同步的主要方式。
Channel的發(fā)送動作先于相應(yīng)的接受動作完成之前。
無緩沖Channel的接受先于該Channel上的發(fā)送完成之前。
這兩點總結(jié)起來分別是開始發(fā)送、開始接受、發(fā)送完成和接受完成四個動作,其時序關(guān)系如下。
開始發(fā)送 > 接受完成
開始接受 > 發(fā)送完成
“注意:開始發(fā)送和開始接受并無明確的先后關(guān)系
”
Channel的關(guān)閉發(fā)生在由于通道關(guān)閉而返回零值接受之前。
容量為C的Channel第k個接受先于該Channel上的第k+C個發(fā)送完成之前。
這里使用極限法應(yīng)該更加易于理解,如果C為0,k為1則其含義和無緩沖Channel的一致。
Lock
對于任何sync.Mutex或sync.RWMutex變量l以及n < m,第n次l.Unlock()的調(diào)用先于第m次l.Lock()的調(diào)用返回。
假設(shè)n為1,m為2,則第二次調(diào)用l.Lock()返回前一定要先調(diào)用l.UnLock()。
對于sync.RWMutex的變量l存在這樣一個n,使得l.RLock()的調(diào)用返回在第n次l.Unlock()之后發(fā)生,而與之匹配的l.RUnlock()發(fā)生在第n + 1次l.Lock()之前。
不得不說,上面這句話簡直不是人能理解的。老許將其翻譯成人話:
有寫鎖時:l.RLock()的調(diào)用返回發(fā)生在l.Unlock()之后。
有讀鎖時:l.RUnlock()的調(diào)用發(fā)生在l.Lock()之前。
“注意:調(diào)用l.RUnlock()前不調(diào)用l.RLock()和調(diào)用l.Unlock()前不調(diào)用l.Lock()會引起panic。
”
Once
once.Do(f)中f的返回先于任意其他once.Do的返回。
不正確的同步
錯誤示范一
var a, b int
func f() {
a = 1
b = 2
}
func g() {
print(b)
print(a)
}
func main() {
go f()
g()
}
這個例子看起來挺簡單,但是老許相信大部分人應(yīng)該會忽略指令重排序引起的異常輸出。假如goroutine f指令重排序后,b=2先于a=1發(fā)生,此時主goroutine觀察到b發(fā)生變化而未觀察到a變化,因此有可能輸出20。
“老許在本地實驗了多次結(jié)果都是輸出
”00,20這個輸出估計只活在理論之中了。
錯誤示范二
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()
}
這種雙重檢測本意是為了避免同步的開銷,但是依舊有可能打印出空字符串而不是“hello, world”。說實話老許自己都不敢保證以前沒有寫過這樣的代碼?,F(xiàn)在唯一能想到的場景就是其中一個goroutine doprint執(zhí)行到done = true(指令重排序?qū)е?code style="font-size: 14px;overflow-wrap: break-word;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;color: rgb(255, 93, 108);">done=true先于a="hello, world"執(zhí)行)時,另一個goroutine doprint剛開始執(zhí)行并觀察到done的值為true從而打印空字符串。
最后,衷心希望本文能夠?qū)Ω魑蛔x者有一定的幫助。當(dāng)然,發(fā)現(xiàn)錯誤也還請及時聯(lián)系老許改正。
------------------- End -------------------
往期精彩文章推薦:

歡迎大家點贊,轉(zhuǎn)發(fā),轉(zhuǎn)載,感謝大家的相伴與支持
想加入學(xué)習(xí)群請在后臺回復(fù)【入群】
萬水千山總是情,點個【在看】行不行





