天天看點

從一個data race問題學到的

前幾天我在學習記憶體屏障的時候搜到一篇文章「Golang Memory Model」,其中在介紹 CPU 緩存一緻性的時候提到一個例子,帶給我一些困惑,本文記錄下解惑過程。

既然是在介紹 CPU 緩存一緻性的時候舉的例子,那麼理所應當與此有關,看代碼:

package main

import "time"

func main() {
	running := true
	go func() {
		println("start thread1")
		count := 1
		for running {
			count++
		}
		println("end thread1: count =", count)
	}()
	go func() {
		println("start thread2")
		for {
			running = false
		}
	}()
	time.Sleep(time.Hour)
}           

複制

當我們通過「go run main.go」運作代碼的時候,會發現第一個 goroutine 永遠不會結束,就好像 running = false 沒有生效一樣。對此,文章把原因歸結為 CPU 緩存一緻性中的線程可見性問題,可是我前後看了幾遍也沒有看出個是以然來。細心的小夥伴不難發現代碼存在 data race 問題:多個 goroutine 并發讀寫 running 變量,不過當我們通過「go run -race main.go」再次運作代碼的時候,有趣的事情發生了,第一個 goroutine 正常結束了!

理論上,既然存在 data race 問題,那麼出現什麼結果都可能,但是好奇心驅使我繼續研究了一下,這次使用的工具是 SSA(how to read),它可以展現出從源代碼到彙編的過程中,編譯器都做了哪些工作,并且可以把結果生成 html 檔案:

shell> GOSSAFUNC=main go build -gcflags="-N -l" ./main.go           

複制

SSA 工具最友善的地方是它可以把源代碼和彙編通過顔色對應起來:

從一個data race問題學到的

main 函數的 ssa

說明:Golang 中的彙編一般指 Plan9 彙編,推薦閱讀「plan9 assembly 完全解析」。

不過為什麼「running = false」這行源代碼沒有對應的彙編呢?這是因為 SSA 的工作機關是函數,上面的結果是 main 函數的結果,而「running = false」實際上屬于 main 函數裡第 2 個 goroutine,相當于 main.func2,重新運作 SSA:

shell> GOSSAFUNC=main.func2 go build -gcflags="-N -l" ./main.go           

複制

如此一來就能看到「running = false」這行源代碼對應的彙編了:

從一個data race問題學到的

main.func2 函數的 ssa

其中,PCDATA 是編譯器插入的和 GC 相關的資訊,在本例中可以忽略,剩下的幾個 JMP 跳來跳去,好像是個圈哦,就是一個空 for,和「running = false」完全沒有關系。

不過既然帶有 race 檢測的代碼工作正常,那麼不妨一并生成 SSA 看看結果如何:

shell> GOSSAFUNC=main.func2 go build -race -gcflags="-N -l" ./main.go           

複制

結果如下圖所示,除了 JMP,還有 MOV 操作,正好對應「running = false」:

從一個data race問題學到的

main.func2 函數的 ssa

如此一來,我們的困惑終于解開了。問題代碼中的循環之是以不會結束,和所謂的「CPU 緩存一緻性中的線程可見性問題」并沒有任何關系,隻是因為編譯器把部分代碼看成死代碼,直接優化掉了,這個過程稱之為「Dead code elimination」,不過當激活 race 檢測的時候,編譯器并沒有執行優化死代碼的流程,是以看上去又正常了。

最後,推薦一篇文章,和本文的例子相似:談談 Golang 中的 Data Race(及續)。