Go 記憶體模型
2022 年 6 月 6 日版
引言
Go 記憶體模型規定了在什麼條件下,可以保證一個 goroutine 中對某個變數的讀取能觀察到由另一個 goroutine 對同一變數寫入產生的值。
建議
修改同時被多個 goroutine 訪問的資料的程式必須序列化此類訪問。
為了序列化訪問,請使用通道操作或 sync
和 sync/atomic
包中的其他同步原語來保護資料。
如果你必須閱讀本文件的其餘部分才能理解你的程式行為,那麼你太聰明瞭。
不要耍小聰明。
非正式概述
Go 處理其記憶體模型的方式與處理語言其他部分的方式大體相同,旨在保持語義的簡單、易懂和實用。本節概述了這種方法,對大多數程式設計師來說應該足夠了。記憶體模型將在下一節中更正式地說明。
資料競爭的定義是:對一個記憶體位置的寫入與對同一位置的另一個讀取或寫入同時發生,除非所有涉及的訪問都是由 sync/atomic
包提供的原子資料訪問。正如已經指出的,強烈鼓勵程式設計師使用適當的同步來避免資料競爭。在沒有資料競爭的情況下,Go 程式表現得好像所有 goroutine 都多路複用到一個處理器上。這個屬性有時被稱為 DRF-SC:無資料競爭的程式以順序一致的方式執行。
雖然程式設計師應該編寫沒有資料競爭的 Go 程式,但 Go 實現對資料競爭的響應能力是有限的。實現總是可以透過報告競爭並終止程式來響應資料競爭。否則,對單字大小或子字大小記憶體位置的每次讀取必須觀察到實際寫入該位置(可能由併發執行的 goroutine 寫入)且尚未被覆蓋的值。這些實現約束使得 Go 更像 Java 或 JavaScript,因為大多數競爭只有有限數量的結果,而不像 C 和 C++,其中任何有競爭的程式的含義都是完全未定義的,並且編譯器可以做任何事情。Go 的方法旨在使錯誤程式更可靠、更易於除錯,同時仍然堅持競爭是錯誤,並且工具可以診斷和報告它們。
記憶體模型
以下 Go 記憶體模型的正式定義緊隨 Hans-J. Boehm 和 Sarita V. Adve 在 PLDI 2008 上發表的“C++ 併發記憶體模型基礎”中提出的方法。無資料競爭程式的定義以及無競爭程式的順序一致性保證與該著作中的定義相同。
記憶體模型描述了對程式執行的要求,程式執行由 goroutine 執行組成,而 goroutine 執行又由記憶體操作組成。
一個記憶體操作由四個細節建模:
- 它的型別,指示它是普通資料讀取、普通資料寫入,還是同步操作,例如原子資料訪問、互斥操作或通道操作,
- 它在程式中的位置,
- 被訪問的記憶體位置或變數,以及
- 操作讀取或寫入的值。
某些記憶體操作是讀取型的,包括讀取、原子讀取、互斥鎖和通道接收。其他記憶體操作是寫入型的,包括寫入、原子寫入、互斥解鎖、通道傳送和通道關閉。有些,例如原子比較並交換,既是讀取型又是寫入型。
一個goroutine 執行被建模為單個 goroutine 執行的一組記憶體操作。
要求 1:給定從記憶體讀取和寫入的值,每個 goroutine 中的記憶體操作必須與該 goroutine 的正確順序執行相對應。該執行必須與在…之前排序關係一致,該關係定義為 Go 語言規範中針對 Go 的控制流結構以及表示式求值順序所規定的偏序要求。
一個 Go 程式執行被建模為一組 goroutine 執行,以及一個對映 W,它指定每個讀取型操作從中讀取的寫入型操作。(同一程式的多次執行可以有不同的程式執行。)
要求 2:對於給定的程式執行,對映 W,當限於同步操作時,必須可以用同步操作的某個隱式全序來解釋,該全序與這些操作的排序以及讀取和寫入的值一致。
同步之前關係是同步記憶體操作上的偏序,它源自 W。如果一個同步讀取型記憶體操作 r 觀察到一個同步寫入型記憶體操作 w(也就是說,如果 W(r) = w),那麼 w 在 r 之前同步。非正式地說,同步之前關係是上一個段落中提到的隱式全序的一個子集,限於 W 直接觀察到的資訊。
先行發生關係被定義為“在…之前排序”和“同步之前”關係的並集的傳遞閉包。
要求 3:對於記憶體位置 x 上的普通(非同步)資料讀取 r,W(r) 必須是寫入 w,且 w 對 r 是可見的,其中可見意味著以下兩點都成立:
- w 在 r 之前發生。
- w 在任何其他在 r 之前發生的寫入 w'(到 x)之前不發生。
記憶體位置 x 上的讀寫資料競爭由 x 上的一個讀取型記憶體操作 r 和 x 上的一個寫入型記憶體操作 w 組成,其中至少一個是非同步的,並且它們在“先行發生”關係中是無序的(即,既沒有 r 先行發生 w,也沒有 w 先行發生 r)。
記憶體位置 x 上的寫寫資料競爭由 x 上的兩個寫入型記憶體操作 w 和 w' 組成,其中至少一個是非同步的,並且它們在“先行發生”關係中是無序的。
請注意,如果記憶體位置 x 上沒有讀寫或寫寫資料競爭,那麼對 x 的任何讀取 r 只有一個可能的 W(r):在“先行發生”順序中緊接其前的唯一 w。
更一般地,可以證明,任何無資料競爭的 Go 程式,即沒有讀寫或寫寫資料競爭的程式執行,其結果只能透過 goroutine 執行的某種順序一致的交錯來解釋。(證明與上述 Boehm 和 Adve 論文的第 7 節相同。)這個屬性被稱為 DRF-SC。
正式定義的目的是與 C、C++、Java、JavaScript、Rust 和 Swift 等其他語言為無競爭程式提供的 DRF-SC 保證相匹配。
某些 Go 語言操作,如 goroutine 建立和記憶體分配,充當同步操作。這些操作對同步前偏序的影響記錄在下面的“同步”部分。各個包負責為其自身的操作提供類似的文件。
包含資料競爭的程式的實現限制
上一節給出了無資料競爭程式執行的正式定義。本節非正式地描述了實現必須為包含競爭的程式提供的語義。
任何實現都可以在檢測到資料競爭時報告競爭並中止程式執行。使用 ThreadSanitizer(透過“go
build
-race
”訪問)的實現正是如此。
對陣列、結構體或複數的讀取可以實現為對每個單獨子值(陣列元素、結構體欄位或實/虛部)的讀取,順序不限。同樣,對陣列、結構體或複數的寫入可以實現為對每個單獨子值的寫入,順序不限。
對儲存不大於機器字的值的記憶體位置 x 的讀取 r 必須觀察到某個寫入 w,使得 r 不在 w 之前發生,並且不存在寫入 w' 使得 w 在 w' 之前發生且 w' 在 r 之前發生。也就是說,每次讀取都必須觀察到由先行發生或併發寫入寫入的值。
此外,禁止觀察非因果和“憑空出現”的寫入。
對於大於單個機器字的記憶體位置的讀取,鼓勵但不要求其滿足與字大小記憶體位置相同的語義,即觀察單個允許的寫入 w。出於效能原因,實現可以改為將大型操作視為一組以未指定順序進行的單個機器字大小操作。這意味著多字資料結構上的競爭可能導致與單個寫入不對應的不一致值。當值依賴於內部(指標、長度)或(指標、型別)對的一致性時,如在大多數 Go 實現中介面值、對映、切片和字串可能出現的情況,此類競爭反過來可能導致任意記憶體損壞。
不正確同步的示例將在下面的“不正確同步”部分給出。
實現限制的示例將在下面的“不正確編譯”部分給出。
同步
初始化
程式初始化在單個 goroutine 中執行,但該 goroutine 可能會建立其他 goroutine,它們併發執行。
如果包 p
匯入包 q
,則 q
的 init
函式的完成在 p
的任何函式開始之前發生。
所有 init
函式的完成在函式 main.main
開始之前同步。
Goroutine 建立
啟動新 goroutine 的 go
語句在 goroutine 執行開始之前同步。
例如,在此程式中
var a string func f() { print(a) } func hello() { a = "hello, world" go f() }
呼叫 hello
將在未來某個時間點列印 "hello, world"
(可能在 hello
返回之後)。
Goroutine 銷燬
goroutine 的退出不保證在程式中的任何事件之前同步。例如,在此程式中
var a string func hello() { go func() { a = "hello" }() print(a) }
對 a
的賦值之後沒有任何同步事件,因此不保證被任何其他 goroutine 觀察到。事實上,一個激進的編譯器可能會刪除整個 go
語句。
如果一個 goroutine 的效果必須被另一個 goroutine 觀察到,請使用鎖或通道通訊等同步機制來建立相對順序。
通道通訊
通道通訊是 goroutine 之間主要的同步方法。對特定通道的每個傳送都與來自該通道的相應接收匹配,通常在不同的 goroutine 中。
在通道上的傳送在相應接收完成之前同步。
此程式
var c = make(chan int, 10) var a string func f() { a = "hello, world" c <- 0 } func main() { go f() <-c print(a) }
保證列印 "hello, world"
。對 a
的寫入在對 c
的傳送之前排序,該傳送在對 c
的相應接收完成之前同步,該接收在 print
之前排序。
通道的關閉在接收到零值(因為通道已關閉)之前同步。
在前面的示例中,用 close(c)
替換 c <- 0
會產生具有相同保證行為的程式。
從無緩衝通道接收在相應傳送完成之前同步。
此程式(如上所示,但交換了傳送和接收語句並使用了無緩衝通道)
var c = make(chan int) var a string func f() { a = "hello, world" <-c } func main() { go f() c <- 0 print(a) }
也保證列印 "hello, world"
。對 a
的寫入在對 c
的接收之前排序,該接收在對 c
的相應傳送完成之前同步,該傳送在 print
之前排序。
如果通道是帶緩衝的(例如,c = make(chan int, 1)
),那麼程式就不保證列印 "hello, world"
。(它可能列印空字串、崩潰或執行其他操作。)
從容量為 C 的通道進行的第 k 次接收在對該通道進行的第 k+C 次傳送完成之前同步。
此規則將上一條規則推廣到帶緩衝的通道。它允許用帶緩衝的通道來模擬計數訊號量:通道中的專案數對應於活動使用的數量,通道的容量對應於同時使用的最大數量,傳送一個專案獲取訊號量,接收一個專案釋放訊號量。這是限制併發的常見習語。
此程式為工作列表中的每個條目啟動一個 goroutine,但 goroutine 使用 limit
通道進行協調,以確保最多三個 goroutine 同時執行工作函式。
var limit = make(chan int, 3) func main() { for _, w := range work { go func(w func()) { limit <- 1 w() <-limit }(w) } select{} }
鎖
sync
包實現了兩種鎖資料型別:sync.Mutex
和 sync.RWMutex
。
對於任何 sync.Mutex
或 sync.RWMutex
變數 l
和 n < m,對 l.Unlock()
的第 n 次呼叫在對 l.Lock()
的第 m 次呼叫返回之前同步。
此程式
var l sync.Mutex var a string func f() { a = "hello, world" l.Unlock() } func main() { l.Lock() go f() l.Lock() print(a) }
保證列印 "hello, world"
。對 l.Unlock()
的第一次呼叫(在 f
中)在對 l.Lock()
的第二次呼叫(在 main
中)返回之前同步,該返回在 print
之前排序。
對於對 sync.RWMutex
變數 l
呼叫 l.RLock
,存在一個 n,使得對 l.Unlock
的第 n 次呼叫在 l.RLock
返回之前同步,並且對 l.RUnlock
的匹配呼叫在對 l.Lock
的第 n+1 次呼叫返回之前同步。
成功呼叫 l.TryLock
(或 l.TryRLock
) 等同於呼叫 l.Lock
(或 l.RLock
)。不成功的呼叫根本沒有同步效果。就記憶體模型而言,l.TryLock
(或 l.TryRLock
) 即使在互斥鎖 l 未解鎖時,也可能返回 false。
Once
sync
包透過使用 Once
型別提供了一種在存在多個 goroutine 的情況下進行安全初始化的機制。多個執行緒可以為特定的 f
執行 once.Do(f)
,但只有一個會執行 f()
,其他呼叫會阻塞直到 f()
返回。
once.Do(f)
中對 f()
的單次呼叫完成在任何 once.Do(f)
呼叫返回之前同步。
在此程式中
var a string var once sync.Once func setup() { a = "hello, world" } func doprint() { once.Do(setup) print(a) } func twoprint() { go doprint() go doprint() }
呼叫 twoprint
將只調用 setup
一次。setup
函式將在兩次 print
呼叫之前完成。結果是 "hello, world"
將列印兩次。
原子值
sync/atomic
包中的 API 統稱為“原子操作”,可用於同步不同 goroutine 的執行。如果原子操作 A 的效果被原子操作 B 觀察到,則 A 在 B 之前同步。程式中執行的所有原子操作的行為都好像以某種順序一致的方式執行。
上述定義與 C++ 的順序一致原子操作和 Java 的 volatile
變數具有相同的語義。
終結器
runtime
包提供了一個 SetFinalizer
函式,該函式在特定物件不再被程式可訪問時新增一個終結器來呼叫。對 SetFinalizer(x, f)
的呼叫在終結器呼叫 f(x)
之前同步。
附加機制
sync
包提供了額外的同步抽象,包括條件變數、無鎖對映、分配池和等待組。每個這些抽象的文件都規定了它在同步方面提供的保證。
提供同步抽象的其他包也應記錄它們所做的保證。
不正確的同步
帶有競爭的程式是不正確的,並且可能表現出非順序一致的執行。特別地,請注意讀取 r 可能會觀察到與 r 併發執行的任何寫入 w 寫入的值。即使發生這種情況,也不意味著在 r 之後發生的讀取會觀察到在 w 之前發生的寫入。
在此程式中
var a, b int func f() { a = 1 b = 2 } func g() { print(b) print(a) } func main() { go f() g() }
可能發生 g
列印 2
然後列印 0
的情況。
這個事實使得一些常見的慣用法失效。
雙重檢查鎖定是一種避免同步開銷的嘗試。例如,twoprint
程式可能被錯誤地編寫為
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() }
但不能保證在 doprint
中,觀察到對 done
的寫入就意味著觀察到對 a
的寫入。此版本可能(不正確地)列印空字串而不是 "hello, world"
。
另一個不正確的慣用法是忙等待一個值,如下所示
var a string var done bool func setup() { a = "hello, world" done = true } func main() { go setup() for !done { } print(a) }
如前所述,不能保證在 main
中,觀察到對 done
的寫入就意味著觀察到對 a
的寫入,因此此程式也可能列印空字串。更糟糕的是,不能保證對 done
的寫入會被 main
觀察到,因為兩個執行緒之間沒有同步事件。main
中的迴圈不保證會結束。
這個主題還有更微妙的變體,例如這個程式。
type T struct { msg string } var g *T func setup() { t := new(T) t.msg = "hello, world" g = t } func main() { go setup() for g == nil { } print(g.msg) }
即使 main
觀察到 g != nil
並退出其迴圈,也不能保證它會觀察到 g.msg
的初始化值。
在所有這些示例中,解決方案都是相同的:使用顯式同步。
不正確的編譯
Go 記憶體模型對編譯器最佳化和 Go 程式本身的限制一樣嚴格。某些在單執行緒程式中有效的編譯器最佳化在所有 Go 程式中都不有效。特別是,編譯器不能引入原始程式中不存在的寫入,不能允許單個讀取觀察多個值,也不能允許單個寫入寫入多個值。
以下所有示例都假設 `*p` 和 `*q` 指的是多個 goroutine 可訪問的記憶體位置。
不將資料競爭引入無競爭程式意味著不將寫入移出它們出現的條件語句。例如,編譯器不得在此程式中反轉條件
*p = 1 if cond { *p = 2 }
也就是說,編譯器不得將程式重寫為這個程式
*p = 2 if !cond { *p = 1 }
如果 cond
為假並且另一個 goroutine 正在讀取 *p
,那麼在原始程式中,另一個 goroutine 只能觀察 *p
的任何先前值和 1
。在重寫程式中,另一個 goroutine 可以觀察到 2
,這以前是不可能的。
不引入資料競爭也意味著不假設迴圈會終止。例如,編譯器通常不得將對 *p
或 *q
的訪問移到此程式中的迴圈之前
n := 0 for e := list; e != nil; e = e.next { n++ } i := *p *q = 1
如果 `list` 指向一個迴圈列表,那麼原始程式將永遠不會訪問 `*p` 或 `*q`,但重寫後的程式會。 (如果編譯器能夠證明 `*p` 不會引發 panic,那麼將 `*p` 移到前面是安全的;將 `*q` 移到前面還需要編譯器證明沒有其他 goroutine 可以訪問 `*q`。)
不引入資料競爭還意味著不假設被呼叫的函式總是返回或沒有同步操作。例如,編譯器不得將對 *p
或 *q
的訪問移到此程式中的函式呼叫之前(至少在不直接瞭解 f
的精確行為的情況下)
f() i := *p *q = 1
如果呼叫從未返回,那麼原始程式將再次永遠不會訪問 `*p` 或 `*q`,但重寫後的程式會。而且,如果呼叫包含同步操作,那麼原始程式可以建立在訪問 `*p` 和 `*q` 之前的先行發生邊,但重寫後的程式則不能。
不允許單個讀取觀察多個值意味著不從共享記憶體重新載入區域性變數。例如,編譯器不得在此程式中丟棄 i
並第二次從 *p
重新載入它
i := *p if i < 0 || i >= len(funcs) { panic("invalid function index") } ... complex code ... // compiler must NOT reload i = *p here funcs[i]()
如果複雜的程式碼需要很多暫存器,單執行緒程式的編譯器可以在不儲存副本的情況下丟棄 i
,然後在 funcs[i]()
之前重新載入 i = *p
。Go 編譯器不得這樣做,因為 *p
的值可能已經改變。(相反,編譯器可以將 i
溢位到棧上。)
不允許一次寫入寫入多個值也意味著在寫入之前不使用區域性變數將被寫入的記憶體作為臨時儲存。例如,編譯器不得在此程式中使用 *p
作為臨時儲存。
*p = i + *p/2
也就是說,它不能將程式重寫為這個程式
*p /= 2 *p += i
如果 i
和 *p
最初等於 2,則原始程式碼執行 *p = 3
,因此競爭執行緒只能從 *p
讀取 2 或 3。重寫後的程式碼執行 *p = 1
然後 *p = 3
,允許競爭執行緒也讀取 1。
請注意,所有這些最佳化在 C/C++ 編譯器中都是允許的:與 C/C++ 編譯器共享後端(或作為其後端)的 Go 編譯器必須注意停用對 Go 無效的最佳化。
請注意,如果編譯器可以證明競爭不會影響目標平臺上的正確執行,則禁止引入資料競爭的規則不適用。例如,在幾乎所有 CPU 上,將
n := 0 for i := 0; i < m; i++ { n += *shared }重寫為
n := 0 local := *shared for i := 0; i < m; i++ { n += local }
是有效的,前提是可以證明 *shared
在訪問時不會發生故障,因為潛在的額外讀取不會影響任何現有的併發讀取或寫入。另一方面,這種重寫在源到源轉換器中是無效的。
結論
編寫無資料競爭程式的 Go 程式設計師可以依賴這些程式的順序一致執行,就像幾乎所有其他現代程式語言一樣。
對於有競爭的程式,程式設計師和編譯器都應牢記建議:不要耍小聰明。