前幾天我在學習記憶體屏障的時候搜到一篇文章「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 工具最友善的地方是它可以把源代碼和彙編通過顔色對應起來:
![](https://img.laitimes.com/img/__Qf2AjLwojIjJCLyojI0JCLiAjM2EzLcd3LcJzLcJzdllmVldWYtl2Pn5GcuADO1QWMmZjMiRjZzEDMmhDOjVDMwYTOwYWN2kjMyEGMvwVNxkzNyMTNtUGall3LcVmdhNXLwRHdo9CXt92YucWbpRWdvx2Yx5yazF2Lc9CX6MHc0RHaiojIsJye.png)
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」這行源代碼對應的彙編了:
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」:
main.func2 函數的 ssa
如此一來,我們的困惑終于解開了。問題代碼中的循環之是以不會結束,和所謂的「CPU 緩存一緻性中的線程可見性問題」并沒有任何關系,隻是因為編譯器把部分代碼看成死代碼,直接優化掉了,這個過程稱之為「Dead code elimination」,不過當激活 race 檢測的時候,編譯器并沒有執行優化死代碼的流程,是以看上去又正常了。
最後,推薦一篇文章,和本文的例子相似:談談 Golang 中的 Data Race(及續)。