Published on

Go 記憶體空間模型

Authors

譯序

本文譯自 The Go Memory Model,翻譯時原文刊載於 Go Programming Language Official Website,受 Creative Commons Attribution 3.0 License 保護。

本繁體中文版翻譯時參考了柴树杉與 Bian Jiang 合譯的簡體中文版本,該文刊載於 Go 语言中文论坛

曾經有人問為何會翻這篇文章,其實在翻譯當時我只想知道 Go 的執行期 memory layout 長成什麼樣子,但顯然這篇主要討論的方向不在此,於是一切都只是一場美麗的誤會 :P

Introduction – 簡介

本《Go 記憶體空間模型》一文所描述的內容為,針對同一變數,在一個 goroutine 上對該變數進行寫入,如何得以保證在另一 goroutine 上得以正確讀取前述寫入值。

Happens Before – 在…之前

就單一 goroutine 的執行,只保證所有的讀/寫行為就結果上應等同於其原始碼設計;也就是說,如果表現的行為不變,編譯器和處理器得以任意排列同一 goroutine 內的所有記憶體讀/寫順序。也因為允許如此重新排序,即使執行的內容相同,每個不同的 goroutine 上彼此看見對方的執行順序都可能有所不同。舉例來說,如果一個 goroutine 的程式碼寫著 a = 1; b = 2;,實際執行時在另一個 goroutine 上回頭一望,可能會看到 b 的值先被更新,而後 a 的值才被改變。

以下為了描述程式對於讀/寫記憶體的請求,對於部份程式區段的記憶體操作,我們先定義何為「在…前發生」。如果事件 e1 在事件 e2 之前發生,那麼我們也可以說 e2 在 e1 之後發生。同理,如果 e1 不在 e2 之前發生,且亦不在 e2 之後發生,那麼我們說 e1 與 e2 同時發生。

在單一的 goroutine 內,記憶體操作的順序是與原始碼相同的。

在以下條件下,針對變數 v 的讀取事件 r,可以取得同對變數 v 的寫入事件 w 的內容:

  1. w 在 r 之前發生。
  2. 在 w 之後、 r 之前的時間區間內,沒有其它對於 v 的寫入事件 w’ 發生。

為了保證一項對於變數 v 的讀取事件 r 不但可以感知寫入事件 w、且 r 實際讀取的值即為 w 所寫入的值,我們還必須保證 w 是唯一一個可以被 r 感知的寫入事件:

  1. w 在 r 之前發生。
  2. 所有其它對於共享變數 v 的寫入事件都發生在 w 之前或 r 之後。

後述這組條件較前述一組來得強健;它進一步限制其它寫入事件不得與 w 或 r 同時發生。

在單一 goroutine 內,沒有任何事件可以同時發生,所以此二條款等價於以下敘述:同對於變數 v,一個讀取事件 r 將讀取最近一次寫入事件 w 之結果。而在多 goroutine 同對共享變數 v 進行操作時,必須利用一些同步操作才得以建立前述的有序環境,保證對於變數的讀取事件皆對讀到相應的寫入事件。

對於變數 v 的零值初始化動作,在本記憶體模型內為一寫入事件。

如果讀/寫事件的內容值比單機器字長(machine word)來得長,那麼視為多次、與機器字長等長的不定順序讀/寫操作。

Synchronization – 同步

Initialization – 初始化

整個程式的所有初始化行為都在單一 goroutine 內進行,而其中產生的新的 goroutine 會在整個初始化結束之後才真正開始運行。

如果一個 package p 引用了另一個 package q,則 q 的所有 init 函式將在 p 的任何 init 函式開始前結束。

主函式 main.main 在所有 init 函式結束後才開始執行。

在某一 init 函式內的 goroutine 創建,會等到所有 init 函式全部結束後才真正開始創建。

Goroutine creation – 創建 Goroutine

go 描述式會在 goroutine 內的程序開始執行之前創建新的 goroutine。

舉例而言,在下列的程式中:

var a string

func f() {
	print(a)
}

func hello() {
	a = "哈囉,世界"
	go f()
}

呼叫 hello 會在稍後時間點印出「哈囉,世界」(甚至可能在 hello 返回之後)

Channel communication – Channel 通訊

channel 通訊是 goroutine 之間主要的同步手段。對等定的通道的所有發送動作都一定要有相應的接收,而這一送一收的動作通常發生在兩個不同的 goroutine 上。

對一個 channel 的發送行為一定會在相應的接收行為完成之前完成。

參考以下程式碼:

var c = make(chan int, 10)
var a string

func f() {
	a = "哈囉,世界"
	c <- 0
}

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

上述程式將被保證印出「哈囉,世界」。這是因為,對於 a 的寫入行為在對 c 發送訊息之前完成,而對 c 的發送行為必在其接收行為完成之前發生,又接收行為必在 print 之前。

對一不帶緩衝區的 channel,接收行為必在發送完成之前發生。

參考以下程式碼(與前例相似,但將送收角色對換、且改採無緩衝 channel):

var c = make(chan int)
var a string

func f() {
	a = "哈囉,世界"
	<-c
}

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

上述程式亦可保證印出「哈囉,世界」。對 a 的寫入行為早於對 c 的接收行為,而此接收行為必在發送行為完成之前開始,而發送行為又早於 print

如果使用帶緩衝的 channel(意即宣告 c = make(chan int, 1)),則本例無法保證可以正確印出「哈囉,世界」。(它可能頂多印出空字串;它不會印出「再見,宇宙」或是讓程式崩潰。)

Locks – 同步鎖

sync 這個 package 實作了兩種類型的同步鎖,sync.Mutexsync.RWMutex

對於任一 sync.Mutexsync.RWMutex 變數 l,且 n < m,則第 n 次叫用 l.Unlock() 必在第 m 次的 l.Lock() 返回之前發生。

參考以下程式碼:

var l sync.Mutex
var a string

func f() {
	a = "哈囉,世界"
	l.Unlock()
}

func main() {
	l.Lock()
	go f()
	l.Lock()
	print(a)
}

此程式可保證印出「哈囉,世界」。第一次呼叫 l.Unlock()(位於 f 內)必在第二次 l.Lock()(位於 main)返回之前,意即於 print 之前呼叫。

對於任何 sync.RWMutex 變數 l,若在第 n 次呼叫 l.Unlock 之後調用 l.RLock,則其相應的 l.RUnlock 必發生於第 n+1 次呼叫 l.Lock 之前。

Once – 單次執行

sync package 保證多 goroutine 間的得以安全地進行初始化,提供一個變數型態 Once 以控制之。針對特定函式 f,多個執行緒可以同時調用 once.Do(f) 試圖執行之,但 f() 只會被實際執行一次,且在被實際執行的 f() 返回前,其它的 once.Do(f) 調用者都會被鎖住。

利用 once.Do(f) 呼叫的 f() 必在任何 once.Do(f) 返回前單次執行並返回。

參考以下程式碼:

var a string
var once sync.Once

func setup() {
	a = "哈囉,世界"
}

func doprint() {
	once.Do(setup)
	print(a)
}

func twoprint() {
	go doprint()
	go doprint()
}

呼叫 twoprint 會印出兩次「哈囉,世界」。只有第一次呼叫 twoprint 會執行 setup

Incorrect synchronization – 錯誤的同步方式

注意,如果讀取事件 r 和寫入事件 w 同時發生,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。(譯註:參考《Happens Before》一節,「如果表現的行為不變,編譯器和處理器得以任意排列同一 goroutine 內的所有記憶體讀/寫順序」,所以函式 f 可能被重排成 b = 2; a = 1

這個特性會使得一些過去常用的同步做法失效。

二次檢查鎖過去被用來迴避同步所造成的系統負擔。舉例來說,前例的 twoprint 函式可能被錯誤地寫成:

var a string
var done bool

func setup() {
	a = "哈囉,世界"
	done = true
}

func doprint() {
	if !done {
		once.Do(setup)
	}
	print(a)
}

func twoprint() {
	go doprint()
	go doprint()
}

但是這樣的同步機制現在無效了。理想上,在 doprint 裡,籍由觀測 done 是否被寫入值意味著 a 是否完成寫入;實際上,「哈囉,世界」可能不被印出,取而代之的是空字串。

另外一個過去常用的同步手段是忙碌鎖,像是這樣:

var a string
var done bool

func setup() {
	a = "哈囉,世界"
	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 = "哈囉,世界"
	g = t
}

func main() {
	go setup()
	for g == nil {
	}
	print(g.msg)
}

即便 main 觀察到 g != nil 發生而決定是否離開迴圈,g.msg 也不一定已經完成它的初始化。(譯註:因為 setup 可能被重排成 g = new(T); g.msg = "哈囉,世界",因此 g != nilg.msg 可能尚未被賦值。)

綜觀以上數例,其解決方案始終如一:以明確的方式進行同步。