首頁 >後端開發 >Golang >白話Go記憶體模型Happen-Before

白話Go記憶體模型Happen-Before

Go语言进阶学习
Go语言进阶学习轉載
2023-07-24 15:47:581132瀏覽

當多個goroutine並發同時存取同一個資料時必須把並發的存取操作序列化。在Go中保證讀寫的序列化可以透過channel通訊或其他同步原語(例如sync套件中的互斥鎖、讀寫鎖和sync/atomic中的原子操作)。

Happens Before

在單一goroutine中,讀取和寫入的行為一定是和程式指定的執行順序表現一致。換言之,編譯器和處理器在不改變語言規範所定義的行為前提下才可以對單一goroutine中的指令進行重排序。

a := 1
b := 2

由於指令重新排序,b := 2可能先於a := 1執行。在單goroutine中,該執行順序的調整並不會影響最終結果。但多個goroutine場景下可能就會出現問題。

var a, b int
// goroutine A
go func() {
    a := 5
    b := 1
}()
// goroutine B
go func() {
    for b == 1 {}
    fmt.Println(a)
}()

執行上述程式碼時,預期goroutine B能夠正常輸出5,但因為指令重排序,b := 1可能先於a := 5執行,最終goroutine B可能輸出0。

:上述範例是不正確的範例,僅作說明用。

#為了明確讀取和寫入的操作的要求,Go中引入了happens before,它表示執行記憶體操作的一種偏序關係。

happens-before的作用

#多個goroutine存取共享變數時,它們必須建立同步事件來確保happens-before條件,以此確保讀取能夠觀察預期的寫入。

什麼是Happens Before

#如果事件e1發生在事件e2之前,那麼我們說e2發生在e1之後。同樣,如果e1不在e2之前發生也沒有在e2之後發生,那我們說e1和e2同時發生。

在單一goroutine中,happens-before的順序就是程式執行的順序。那happens-before到底是什麼順序呢?讓我們看看下面的條件。

如果對於一個變數v的讀取操作r和寫入操作w滿足下述兩個條件,r才允許觀察到w:

  1. r沒有發生在w之前。
  2. 沒有其他寫入作業發生在w之後和r之前。

為了保證變數v的一個讀取操作r能夠觀察到一個特定的寫入操作w,需要確保w是唯一允許被r觀察的寫入操作。那麼,如果 r、w 都滿足以下條件,r就能確保觀察到w:

  1. w發生在r之前。
  2. 其他寫入操作發生在w之前後者r之後。

單一goroutine中不存在並發,這兩個條件是等價的。老許在此基礎上擴展一下,對於單核心的運作環境這兩組條件同樣等價。並發情況下,後一組條件比第一組更嚴格。

假如你很疑惑,那就對了!老許最開始也很疑惑,這兩組條件就是一樣的呀。為此老許特地和原文進行了反覆對比確保上述的理解是沒有問題的。

白話Go記憶體模型Happen-Before

我們換個思路,進行反向推理。如果這兩組條件一樣,那原文沒必要寫兩次,果然此事並不簡單。

白話Go記憶體模型Happen-Before

在繼續分析之前,要先感謝我的語文老師,沒有你我就無法發現它們的不同。

r沒有發生在w之前,則r可能的情況是r發生在w之後或是和w同時發生,如下圖(實心表示可同時)。

白話Go記憶體模型Happen-Before

沒有其他寫入作業發生在w之後和r之前,則其他寫w'可能發生在w之前或是和w同時發生,也可能發生在r之後或和r同時發生,如下圖(實心表示可同時)。

白話Go記憶體模型Happen-Before

第二組條件就很明確了,w發生在r之前且其他寫入操作只能發生在w之前或r之後,如下圖(空心表示不可同時)。

白話Go記憶體模型Happen-Before

到這兒應該要明白為什麼第二組條件比第一組條件更嚴格了吧。在第一組的條件下是允許觀察到w,第二組是保證能觀察到w。

Go中的同步

下面是Go中約定好的一些同步事件,它們能確保程式遵循happens-before原則,從而使並發的goroutine相對有序。

Go的初始化

程序初始化运行在单个goroutine中,但是该goroutine可以创建其他并发运行的goroutine。

如果包p导入了包q,则q包init函数执行结束先于p包init函数的执行。main函数的执行发生在所有init函数执行完成之后。

goroutine的创建结束

goroutine的创建先于goroutine的执行。老许觉得这基本就是废话,但事情总是没有那么简单,其隐含之意大概是goroutine的创建是阻塞的。

func sleep() bool {
   time.Sleep(time.Second)
   return true
}

go fmt.Println(sleep())

上述代码会阻塞主goroutine一秒,然后才创建子goroutine。

goroutine的退出是无法预测的。如果用一个goroutine观察另一个goroutine,请使用锁或者Channel来保证相对有序。

Channel的发送和接收

Channel通信是goroutine之间同步的主要方式。

  • Channel的发送动作先于相应的接受动作完成之前。

  • 无缓冲Channel的接受先于该Channel上的发送完成之前。

这两点总结起来分别是开始发送开始接受发送完成接受完成四个动作,其时序关系如下。

开始发送 > 接受完成
开始接受 > 发送完成

注意:开始发送和开始接受并无明确的先后关系

  • Channel的关闭发生在由于通道关闭而返回零值接受之前。

  • 容量为C的Channel第k个接受先于该Channel上的第k+C个发送完成之前。

这里使用极限法应该更加易于理解,如果C为0,k为1则其含义和无缓冲Channel的一致。

Lock

对于任何sync.Mutex或sync.RWMutex变量l以及n < m,第n次l.Unlock()的调用先于第m次l.Lock()的调用返回。

假设n为1,m为2,则第二次调用l.Lock()返回前一定要先调用l.UnLock()。

对于sync.RWMutex的变量l存在这样一个n,使得l.RLock()的调用返回在第n次l.Unlock()之后发生,而与之匹配的l.RUnlock()发生在第n + 1次l.Lock()之前。

不得不说,上面这句话简直不是人能理解的。老许将其翻译成人话:

有写锁时:l.RLock()的调用返回发生在l.Unlock()之后。

有读锁时:l.RUnlock()的调用发生在l.Lock()之前。

注意:调用l.RUnlock()前不调用l.RLock()和调用l.Unlock()前不调用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()
}

这个例子看起来挺简单,但是老许相信大部分人应该会忽略指令重排序引起的异常输出。假如goroutine f指令重排序后,b=2先于a=1发生,此时主goroutine观察到b发生变化而未观察到a变化,因此有可能输出20

老许在本地实验了多次结果都是输出0020这个输出估计只活在理论之中了。

错误示范二

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”。说实话老许自己都不敢保证以前没有写过这样的代码。现在唯一能想到的场景就是其中一个goroutine doprint执行到done = true(指令重排序导致done=true先于a="hello, world"执行)时,另一个goroutine doprint刚开始执行并观察到done的值为true从而打印空字符串。

以上是白話Go記憶體模型Happen-Before的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:Go语言进阶学习。如有侵權,請聯絡admin@php.cn刪除