Go 記憶體模型

2022 年 6 月 6 日版本

簡介

Go 記憶體模型明確規範了在一個 goroutine 中讀取變數時,可以保證觀察到在一個不同 goroutine 中,對同一個變數寫入的哪些條件之下。

建議

由多個 goroutine 同時存取的資料修改程式,必須序列化此類存取。

若要序列化存取,請使用通道作業或其他同步原語,例如 syncsync/atomic 套件中的原語,來保護資料。

如果您必須閱讀本文件的其餘部分才能瞭解程式行為,那麼您就太聰明了。

不要太聰明。

非正式概觀

Go 處理其記憶體模型的方式與語言的其他部分非常相似,目標是使語意保持簡單、易懂且有用。本節概述了一般方法,應足以應付大多數的程式設計人員。下一節會針對記憶體模型提供更正式的規範。

資料競爭定義為與對相同位置的其他讀取或寫入同時發生的寫入記憶體位置,除非所有涉及的存取都如同 sync/atomic 套件提供的原子資料存取。如前所述,強烈建議程式設計人員使用適當的同步以避免資料競爭。如果沒有資料競爭,Go 程式會如同所有 goroutine 都集中在單一處理器上一般地運作。此特性有時稱為 DRF-SC:無資料競爭的程式會以順序一致的方式執行。

儘管程式設計人員應撰寫沒有資料競爭的 Go 程式,但對 Go 實作而言,能對資料競爭採取的回應行動也有其限制。實作可能會隨時透過回報競競狀態並終止程式來回應資料競爭。否則,對單一字元組大小或次於單一字元組大小的記憶體位置的每個讀取都必須觀察到實際寫入到該位置的值(可能是由並行執行中的 goroutine 所寫,且尚未覆寫)。這些實作限制使得 Go 更類似於 Java 或 JavaScript,在其中大多數競競況態都有限數量的結果,而且不像 C 和 C++,在其中任何有競競況態的程式的意義完全未定義,且編譯器可以執行任何操作。Go 的方法旨在使錯誤程式更可靠且更容易除錯,同時仍然堅持競競況態是錯誤,而且工具可以診斷並回報它們。

記憶體模型

Hans-J. Boehm 和 Sarita V. Adve 在「C++ Concurrency Memory Model 的基礎」中提出的方法嚴密依照以下 Go 記憶體模型正規定義,該論文是在 2008 年 PLDI 中發表的。無資料競爭程式定義以及對無競競況態程式提供順序一致性的保證,等同於該論文中的定義。

記憶體模型描述程式執行的要求,程式執行包含 goroutine 執行,而 goroutine 執行又包含記憶體操作。

記憶體操作以四個詳細資料為模組

有些記憶體操作是類讀取,包括讀取、原子讀取、mutex 鎖定,以及通道接收。其他記憶體操作是類寫入,包括寫入、原子寫入、mutex 解鎖、通道傳送及通道關閉。有些,例如原子比較並交換,既是類讀取又是類寫入。

goroutine 執行模組為單一 goroutine 執行的記憶體操作集合。

需求 1:假設給定從記憶體中讀取和寫入的值,每個 goroutine 中的記憶體操作必須對應於該 goroutine 正確的順序執行。此執行必須與 順序在先 關係相符,該關係由 Go 語言規範 定義,針對 Go 的控制流程結構以及 運算式的評估順序 而設的部分訂單要求。

Go 的 程式執行 會建模成一組 goroutine 執行,以及一個映對 W,指定每個讀取類似運算從中讀取的寫入類似運算。(同一個程式的多個執行可以有不同的程式執行。)

需求 2:針對給定的程式執行,當映對 W 限於同步運算時,必須可以藉由某些同步運算的隱含總體順序,來解釋 W,而該順序與排序以及這些運算讀取和寫入的值相符。

同步在先 關係是同步記憶體運算的部分順序,由 W 衍生而來。如果同步讀取類似記憶體運算 r 觀察到同步寫入類似記憶體運算 w(也就是如果 W(r) = w),則 w 會同步在 r 之前。非正式地說,同步在先關係是前一段提到的隱含總體順序的子集,限於 W 直接觀察到的資訊。

發生在先 關係定義為順序在先和同步在先關係聯集的傳遞閉包。

需求 3:對於記憶體位置 x 上的普通(非同步)資料讀取 rW(r) 必須是對 r 可見 的寫入 w,而可見意指下列兩項都成立

  1. w 發生在 r 之前。
  2. w 不會 發生在發生在 r 之前的任何其他寫入 w'(至 x)之前。

記憶體位置 x 上的 讀寫資料競爭狀態 包含以下項目:x 上的讀取類似記憶體運算 r 以及 x 上的寫入類似記憶體運算 w,至少其中一個是非同步的,且兩者未由發生在先排序(也就是 r 既不會發生在 w 之前,w 也不會發生在 r 之前)。

記憶體位置 x 上的 寫寫資料競爭狀態 包含以下兩項:x 上的兩個寫入類似記憶體運算 ww',其中至少一個是非同步的,且兩者未由發生在先排序。

請注意,如果在記憶體位置 x 上沒有讀寫或寫寫資料競爭狀態,則 x 上的任何讀取 r 只有單一的可能的 W(r):在發生在先順序中緊接在它之前的單一 w

更普遍地,可以證明任何資料競爭安全(data-race-free)的 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,那麼 qinit 函式的完成發生在 p 的函式開始運作之前。

main.main 函式開始運作之前,所有 init 函式的完成會進行同步。

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"c 上的寫入會在序列中排在 c 上發送訊息之前,該訊息會在對應的 c 上接收完成之前進行同步,而接收則會在 print 之前排在序列中。

在接收傳回零值因為通道已關閉之前,會在通道關閉前進行同步。

在前一個範例中,將 c <- 0 取代為 close(c) 會產生一個行為保證相同的程式。

從無緩衝通道接收訊息會在對應的訊息從該通道發送完成之前進行同步。

此程式(如以上範例,不過已將發送和接收陳述式交換,並使用無緩衝通道)

var c = make(chan int)
var a string

func f() {
	a = "hello, world"
	<-c
}

func main() {
	go f()
	c <- 0
	print(a)
}

也保證列印 "hello, world"c 上的寫入會在序列中排在 c 上接收訊息之前,該訊息會在對應的 c 上發送完成之前進行同步,而發送則會在 print 之前排在序列中。

如果通道有緩衝(例如,c = make(chan int, 1)),程式就不會保證列印 "hello, world"。(它可能列印空字串、崩潰或做其他事。)

容量為 C 通道上的第 k 個接收會在該通道上的第 k+C 個發送完成之前進行同步。

此規則延伸上一條規則並適用於緩衝通道。它允許用緩衝通道模擬計數型 semaphore:通道中的項目數對應於 active use 的數量,通道容量對應於同時使用次數的上限,傳送項目會獲得 semaphore,接收項目會釋放 semaphore。這是一種慣用的限制並行作業方式。

這支程式針對 work list 中的每個項目啟動一個 goroutine,但這些 goroutine 透過使用 limit 通道進行協調,以確保一次最多只有三個工作函式在執行作業。

var limit = make(chan int, 3)

func main() {
	for _, w := range work {
		go func(w func()) {
			limit <- 1
			w()
			<-limit
		}(w)
	}
	select{}
}

sync 套件實作了兩種鎖定資料型別:sync.Mutexsync.RWMutex

對於任何 sync.Mutexsync.RWMutex 變數 l,以及 n < m,在 l.Lock()m 次呼叫回傳之前,l.Unlock()n 次呼叫已同步執行。

此程式

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"。在 f 中呼叫 l.Unlock() 第一次 (在 main 中) 會在 l.Lock() 第二次呼叫回傳前同步執行,此動作會在 print 之前依序執行。

對於 sync.RWMutex 變數 l 呼叫 l.RLock,存在一個 n,使得 l.Unlockn 次呼叫會在 l.RLock 回傳前同步執行,且匹配的 l.RUnlock 呼叫會在 l.Lockn+1 次呼叫回傳前同步執行。

成功呼叫 l.TryLock (或 l.TryRLock) 等於呼叫 l.Lock (或 l.RLock)。呼叫失敗則不會產生任何同步效果。就記憶體模型而言,即使互斥鎖 l 已解鎖,也仍可能認為 l.TryLock (或 l.TryRLock) 可以回傳 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。如果會由原子化操作 B 觀察到原子化操作 A,則 A 會在 B 前同步化。所有在程式中執行的原子化操作看起來就像以某種順序一致的方式執行。

前述的定義與 C++ 中的順序一致原子化和 Java 中的 volatile 變數具備相同的語意。

Finalizer

runtime 套件提供一個 SetFinalizer 函數,會在某個特定物件不再被程式存取時新增一個要呼叫的 finalizer。呼叫 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 為 false 而另一個 goroutine 正在讀取 *p,則在原程式中,另一個 goroutine 只能觀察 *p1 的先前值。在重寫的程式中,另一個 goroutine 可以觀察 2,而這在以前是不可能的。

不引入資料競爭也表示不假設迴圈終止。例如,編譯器通常不得將對 *p*q 的存取移到此程式迴圈的前面

n := 0
for e := list; e != nil; e = e.next {
	n++
}
i := *p
*q = 1

如果 list 指向循環清單,則原程式將永遠不會存取 *p*q,但重寫的程式會。假如編譯器可以證明 *p 將不會驚慌,則將 *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 程式設計師能依賴這些程式的連貫執行,這在其他幾乎所有現代程式設計語言都一樣。

當處理有競爭的程式碼時,程式設計師和編譯器都應該記住以下建議:勿耍小聰明。