# 5.9 內存一致模型
讀者可能注意到了,無論是在談論 Go 的運行時還是編譯器,直到目前為止我們都有意無意的 嘗試去回避 Go 語言的「內存模型」這個話題。
這有非常多的原因,作為本章的收尾,也是全書對 Go 語言同步原語與同步模式的一個總結, 我們最后來詳細展開內存模型這個話題,解答讀者心中的疑惑。 對為什么到目前為止我們都刻意的回避有關內存模型的內容作出一個相對完整的解釋。
## 5.9.1 內存模型的重要性
內存一致模型,或稱內存模型,是一份語言用戶與語言自身、語言自身與所在的操作系統平臺、 所在操作系統平臺與硬件平臺之間的契約。它定義了并行狀態下擁有確定讀取和寫入的時序的條件, 并回答了一個共享變量是否具有足夠的同步機制來保障一個線程的寫入能否發生在另一個線程的讀取之前這個問題。
在一份 Go 語言的程序被寫成后,將經過編譯器的轉換與優化、所運行操作系統或虛擬機等動態優化器的優化,以及 CPU 硬件平臺對指令流的優化才最終得以被執行。這個過程意味著,對于某一個變量的讀取與寫入操作,可能 被這個過程中任何一個中間步驟進行調整,從而偏離程序員在程序中所指定的原有順序。 沒有內存模型的保障,就無法正確的推演程序在最終被執行時的正確性。
內存模型的策略同樣有著長期影響,并且直接決定了程序的可移植性和可維護性。 例如,過強的內存模型將約束硬件和編譯器優化的空間,從而嚴重降低程序性能上限; 已經選擇了強內存模型的硬件體系結構,無法在不破壞兼容性的情況下向更弱的內存模型進行遷移, 這種兼容性破壞所帶來的代價就是要求其平臺上的程序重新實現其源碼。
這種橫跨用戶、軟件與硬件三大領域的主題使得內存模型的設計愿景變得異常的困難,至今仍是一個開放的研究問題。因此在討論 Go 語言的內存模型之前,我們還需要了解現有的內存模型、歷史上軟硬件平臺之間形成契約的經驗教訓。
## 5.9.2 強序與弱序
令同步模型為對內存訪問的一組約束,這些約束指定了需要如何以及何時完成同步,則當且僅當硬件與遵循該同步模型的所有軟件順序一致時,稱該同步模型對于硬件而言滿足弱序(Weak Ordering)。
## 5.9.3 免數據競爭范式
當一個程序在特定輸入上具有順序一致的執行順序時,且其中兩個相互沖突的操作同時執行,則稱其為無數據競爭(Data-Race-Free, DRF)。
## 5.9.4 歷史實踐
C++ 是一個在內存模型方面實踐優秀的一個例子。
線性一致性:又稱強一致性或原子一致性。它要求任何一次讀操作都能讀到某個數據的最近一次寫的數據,并且所有線程的操作順序與全局時鐘下的順序是一致的。
~~~
x.store(1) x.load()
G1 ---------+----------------+------>
G2 -------------------+------------->
x.store(2)
~~~
在這種情況下線程`G1`,`G2`對`x`的兩次寫操作是原子的,且`x.store(1)`是嚴格的發生在`x.store(2)`之前,`x.store(2)`嚴格的發生在`x.load()`之前。 值得一提的是,線性一致性對全局時鐘的要求是難以實現的,這也是人們不斷研究比這個一致性更弱條件下其他一致性的算法的原因。
順序一致性:同樣要求任何一次讀操作都能讀到數據最近一次寫入的數據,但未要求與全局時鐘的順序一致。
~~~
x.store(1) x.store(3) x.load()
G1 ---------+-----------+----------+----->
G2 ---------------+---------------------->
x.store(2)
~~~
或者
```
x.store(1) x.store(3) x.load()
G1 ---------+-----------+----------+----->
G2 ------+------------------------------->
x.store(2)
```
在順序一致性的要求下,`x.load()`必須讀到最近一次寫入的數據,因此`x.store(2)`與`x.store(1)`并無任何先后保障,即 只要`G2`的`x.store(2)`發生在`x.store(3)`s 之前即可。
因果一致性:它的要求進一步降低,只需要有因果關系的操作順序得到保障,而非因果關系的操作順序則不做要求。
~~~
a = 1 b = 2
G1 ----+-----------+---------------------------->
G2 ------+--------------------+--------+-------->
x.store(3) c = a + b y.load()
~~~
或者
~~~
a = 1 b = 2
G1 ----+-----------+---------------------------->
G2 ------+--------------------+--------+-------->
x.store(3) y.load() c = a + b
~~~
亦或者
~~~
b = 2 a = 1
G1 ----+-----------+---------------------------->
G2 ------+--------------------+--------+-------->
y.load() c = a + b x.store(3)
~~~
上面給出的三種例子都是屬于因果一致的,因為整個過程中,只有`c`對`a`和`b`產生依賴,而`x`和`y`在此例子中表現為沒有關系(但實際情況中我們需要更詳細的信息才能確定`x`與`y`確實無關)
最終一致性:是最弱的一致性要求,它只保障某個操作在未來的某個時間節點上會被觀察到,但并未要求被觀察到的時間。因此我們甚至可以對此條件稍作加強,例如規定某個操作被觀察到的時間總是有界的。當然這已經不在我們的討論范圍之內了。
~~~
x.store(3) x.store(4)
T1 ----+-----------+-------------------------------------------->
T2 ---------+------------+--------------------+--------+-------->
x.read() x.read() x.read() x.read()
~~~
在上面的情況中,如果我們假設 x 的初始值為 0,則 T2 中四次 x.read() 結果可能但不限于以下情況:
~~~
3 4 4 4 // x 的寫操作被很快觀察到
0 3 3 4 // x 的寫操作被觀察到的時間存在一定延遲
0 0 0 4 // 最后一次讀操作讀到了 x 的最終值,但此前的變化并未觀察到
0 0 0 0 // 在當前時間段內 x 的寫操作均未被觀察到,但未來某個時間點上一定能觀察到 x 為 4 的情況
~~~
## 5.9.5 發生序關系
Go 的 Goroutine 采取并發的形式運行在多個并行的線程上, 而其內存模型就明確了**對于一個 Goroutine 而言,一個變量被寫入后一定能夠被讀取到的條件**。 在 Go 的內存模型中有事件時序的概念,并定義了**happens before**,即表示了在 Go 程序中執行內存操作的一個偏序關系。
我們不妨用 *e1*。 同樣,如果*e1*≥*e2*且 \_e1 ≤*e2*,則*e1*與*e2**happen concurrently*(e1 = e2)。 在單個 Goroutine 中,happens-before 順序即程序定義的順序。
我們稍微學院派的描述一下偏序的概念。 (嚴格)偏序在數學上是一個二元關系,它滿足自反、反對稱和傳遞性。happens before(<)被稱之為偏序,如果滿足這三個性質:
1. (反自反性)對于 ?\_e1\_∈{事件},有:非 e1 < e1;
2. (非對稱性)對于?\_e1\_, \_e2\_∈{事件},如果 e1 ≤ e2,e2 ≤ e1 則 e1 = e2,也稱 happens concurrently;
3. (傳遞性)對于?\_e1\_, \_e2\_, \_e3\_ ∈{事件},如果 e1 < e2,e2 < e3,則 e1 < e3。
可能我們會認為這種事件的發生時序的偏序關系僅僅只是在探討并發模型,跟內存無關。 但實際上,它們既然被稱之為內存模型,就是因為它們與內存有著密切關系。 并發操作時間偏序的條件,本質上來說,是定義了內存操作的可見性。
編譯器和 CPU 通常會產生各種優化來影響程序原本定義的執行順序,這包括:編譯器的指令重排、 CPU 的亂序執行。 除此之外,由于緩存的關系,多核 CPU 下,一個 CPU 核心的寫結果僅發生在該核心最近的緩存下, 要想被另一個 CPU 讀到則必須等待內存被置換回低級緩存再置換到另一個核心后才能被讀到。
Go 中的 happens before 有以下保證:
1. 初始化:`main.init`<`main.main`
2. Goroutine 創建:`go`<`Goroutine 開始執行`
3. Goroutine 銷毀:`Goroutine 退出`\= ?`e`
4. channel: 如果 ch 是一個 buffered channel,則`ch<-val`<`val <- ch`
5. channel: 如果 ch 是一個 buffered channel 則`close(ch)`<`val <- ch & val == isZero(val)`
6. channel: 如果 ch 是一個 unbuffered channel 則,`ch<-val`\>`val <- ch`
7. channel: 如果 ch 是一個容量`len(ch) == C`的 buffered channel,則`從 channel 中收到第 k 個值`<`k+C 個值得發送完成`
8. mutex: 如果對于 sync.Mutex/sync.RWMutex 的鎖 l 有 n < m, 則第 n 次調用`l.Unlock()`< 第 m 次調用 l.Lock() 的返回
9. mutex: 任何發生在 sync.RWMutex 上的調用`l.RLock`, 存在一個 n 使得`l.RLock`\> 第 n 次調用`l.Unlock`,且與之匹配的`l.RUnlock`< 第 n+1 次調用 l.Lock
10. once: f() 在 once.Do(f) 中的調用 < once.Do(f) 的返回
那么在 Go 語言發展的著十余年間,真正解決了內存模型的設計嗎?
因此,Go 語言對其用戶的忠告可以歸結為五個字:別自作聰明。
- 第一部分 :基礎篇
- 第1章 Go語言的前世今生
- 1.2 Go語言綜述
- 1.3 順序進程通訊
- 1.4 Plan9匯編語言
- 第2章 程序生命周期
- 2.1 從go命令談起
- 2.2 Go程序編譯流程
- 2.3 Go 程序啟動引導
- 2.4 主Goroutine的生與死
- 第3 章 語言核心
- 3.1 數組.切片與字符串
- 3.2 散列表
- 3.3 函數調用
- 3.4 延遲語句
- 3.5 恐慌與恢復內建函數
- 3.6 通信原語
- 3.7 接口
- 3.8 運行時類型系統
- 3.9 類型別名
- 3.10 進一步閱讀的參考文獻
- 第4章 錯誤
- 4.1 問題的演化
- 4.2 錯誤值檢查
- 4.3 錯誤格式與上下文
- 4.4 錯誤語義
- 4.5 錯誤處理的未來
- 4.6 進一步閱讀的參考文獻
- 第5章 同步模式
- 5.1 共享內存式同步模式
- 5.2 互斥鎖
- 5.3 原子操作
- 5.4 條件變量
- 5.5 同步組
- 5.6 緩存池
- 5.7 并發安全散列表
- 5.8 上下文
- 5.9 內存一致模型
- 5.10 進一步閱讀的文獻參考
- 第二部分 運行時篇
- 第6章 并發調度
- 6.1 隨機調度的基本概念
- 6.2 工作竊取式調度
- 6.3 MPG模型與并發調度單
- 6.4 調度循環
- 6.5 線程管理
- 6.6 信號處理機制
- 6.7 執行棧管理
- 6.8 協作與搶占
- 6.9 系統監控
- 6.10 網絡輪詢器
- 6.11 計時器
- 6.12 非均勻訪存下的調度模型
- 6.13 進一步閱讀的參考文獻
- 第7章 內存分配
- 7.1 設計原則
- 7.2 組件
- 7.3 初始化
- 7.4 大對象分配
- 7.5 小對象分配
- 7.6 微對象分配
- 7.7 頁分配器
- 7.8 內存統計
- 第8章 垃圾回收
- 8.1 垃圾回收的基本想法
- 8.2 寫屏幕技術
- 8.3 調步模型與強弱觸發邊界
- 8.4 掃描標記與標記輔助
- 8.5 免清掃式位圖技術
- 8.6 前進保障與終止檢測
- 8.7 安全點分析
- 8.8 分代假設與代際回收
- 8.9 請求假設與實務制導回收
- 8.10 終結器
- 8.11 過去,現在與未來
- 8.12 垃圾回收統一理論
- 8.13 進一步閱讀的參考文獻
- 第三部分 工具鏈篇
- 第9章 代碼分析
- 9.1 死鎖檢測
- 9.2 競爭檢測
- 9.3 性能追蹤
- 9.4 代碼測試
- 9.5 基準測試
- 9.6 運行時統計量
- 9.7 語言服務協議
- 第10章 依賴管理
- 10.1 依賴管理的難點
- 10.2 語義化版本管理
- 10.3 最小版本選擇算法
- 10.4 Vgo 與dep之爭
- 第12章 泛型
- 12.1 泛型設計的演進
- 12.2 基于合約的泛型
- 12.3 類型檢查技術
- 12.4 泛型的未來
- 12.5 進一步閱讀的的參考文獻
- 第13章 編譯技術
- 13.1 詞法與文法
- 13.2 中間表示
- 13.3 優化器
- 13.4 指針檢查器
- 13.5 逃逸分析
- 13.6 自舉
- 13.7 鏈接器
- 13.8 匯編器
- 13.9 調用規約
- 13.10 cgo與系統調用
- 結束語: Go去向何方?