Go 併發:一些有趣的現象和要避開的 「坑」

如有任何問題或建議,歡迎及時交流和碰撞。個人公衆號是 【腦子進煎魚了】,GitHub 地址: https://github.com/eddycjy

你們好,我是煎魚。git

最近在看 Go 併發相關的內容,發現仍是有很多細節容易讓人迷迷糊糊的,一個不當心就踏入深坑裏,且指不定要在上線跑了一些數據後才能發現,那可真是太人崩潰了。github

今天來分享幾個案例,但願你們在編碼時可以避開這幾個 「坑」。golang

案例一

演示代碼

第一個案例來自 @鳥窩 大佬在極客時間的分享,代碼以下:架構

func main() {
    count := 0
    wg := sync.WaitGroup{}
    wg.Add(10)
    for i := 0; i < 10; i++ {
        go func() {
            defer wg.Done()
            for j := 0; j < 100000; j++ {
                count++
            }
        }()
    }
    wg.Wait()

    fmt.Println(count)
}

思考一下,最後輸出的 count 變量的值是多少?是否是一百萬?併發

輸出結果

在上述代碼中,咱們經過 for-loop 循環起 goroutine 進行自增,並使用了 sync.WaitGroup 來保證全部的 goroutine 都執行完畢才輸出最終的結果值。app

最終的輸出結果以下:微服務

// 第一次執行
638853

// 第二次執行
654473

// 第三次執行
786193

輸出的結果值不是恆定的,也就是每次輸出的都不同,且基本不會達到想象中的一百萬。oop

分析緣由

其緣由在於 count++ 並非一個原子操做,在彙編上就包含了好幾個動做,以下:性能

MOVQ "".count(SB), AX 
LEAQ 1(AX), CX 
MOVQ CX, "".count(SB)

由於可能會同時存在多個 goroutine 同時讀取到 count 的值爲 1212,並各自自增 1,再將其寫回。編碼

與此同時也會有其餘的 goroutine 可能也在其自增時讀到了值,造成了互相覆蓋的狀況,這是一種併發訪問共享數據的錯誤。

發現問題

這類競爭問題能夠經過 Go 語言所提供的的 race 檢測(Go race detector)來進行分析和發現:

$ go run -race main.go 
==================
WARNING: DATA RACE
Read at 0x00c0000c6008 by goroutine 13:
  main.main.func1()
      /Users/eddycjy/go-application/awesomeProject/main.go:28 +0x78

Previous write at 0x00c0000c6008 by goroutine 7:
  main.main.func1()
      /Users/eddycjy/go-application/awesomeProject/main.go:28 +0x91

Goroutine 13 (running) created at:
  main.main()
      /Users/eddycjy/go-application/awesomeProject/main.go:25 +0xe4

Goroutine 7 (running) created at:
  main.main()
      /Users/eddycjy/go-application/awesomeProject/main.go:25 +0xe4
==================
...
489194
Found 3 data race(s)
exit status 66

編譯器會經過探測全部的內存訪問,監聽其內存地址的訪問(讀或寫)。在應用運行時就可以發現對共享變量的訪問和操做,進而發現問題並打印出相關的警告信息。

須要注意的一點是,go run -race 是運行時檢測,並非編譯時。且 race 存在明確的性能開銷,一般是正常程序的十倍,所以不要想不開在生產環境打開這個配置,很容易翻車。

案例二

演示代碼

第二個案例來自煎魚在腦子的分享,代碼以下:

func main() {
    wg := sync.WaitGroup{}
    wg.Add(5)
    for i := 0; i < 5; i++ {
        go func(i int) {
            defer wg.Done()
            fmt.Println(i)
        }(i)
    }
    wg.Wait()
}

思考一下,最後輸出的結果是什麼?值都是 4 嗎?輸出是穩定有序的嗎?

輸出結果

在上述代碼中,咱們經過 for-loop 循環起了多個 goroutine,並將變量 i 做爲形參傳遞給了 goroutine,最後在 goroutine 內輸出了變量 i

最終的輸出結果以下:

// 第一次輸出
0
1
2
4
3

// 第二次輸出
4
0
1
2
3

顯然,從結果上來看,輸出的值都是無序且不穩定的,值更不是 4。這究竟是爲何?

分析緣由

其緣由在於,即便全部的 goroutine 都建立完了,但 goroutine 不必定已經開始運行了。

也就是等到 goroutine 真正去執行輸出時,變量 i (值拷貝)可能已經不是建立時的值了。

其整個程序扭轉實質上分爲了多個階段,也就是各自運行的時間線並不一樣,能夠其拆分爲:

  • 先建立:for-loop 循環建立 goroutine
  • 再調度:協程goroutine 開始調度執行。
  • 才執行:開始執行 goroutine 內的輸出。

同時 goroutine 的調度存在必定的隨機性(建議瞭解一下 GMP 模型),那麼其輸出的結果就勢必是無序且不穩定的。

發現問題

這時候你可能會想,那前面提到的 go run -race 能不能發現這個問題呢。以下:

$ go run -race main.go
0
1
2
3
4

沒有出現警告,顯然是不能的,由於其本質上並非併發訪問共享數據的錯誤,且會致使程序變成了串行,從而矇蔽了你的雙眼。

案例三

演示代碼

第三個案例來自煎魚在夢裏的分享,代碼以下:

func main() {
    wg := sync.WaitGroup{}
    wg.Add(5)
    for i := 0; i < 5; i++ {
        go func() {
            defer wg.Done()
            fmt.Println(i)
        }()
    }
    wg.Wait()
}

思考一下,最後輸出的結果是什麼?值都是 4 嗎?會像案例二同樣亂竄嗎?

輸出結果

在上述代碼中,與案例二大致沒有區別,主要是變量 i 沒有做爲形參傳入。

最終的輸出結果以下:

// 第一次輸出
5
5
5
5
5

初步從輸出的結果上來看都是 5,這時候就會有人迷糊了,爲何不是 4 呢?

很多人會因不是 4 而陷入了迷惑,但千萬不要被一兩次的輸出迷惑了心智,認爲鐵定就是 5 了。能夠再動手多輸出幾回,以下:

// 多輸出幾回
5
3
5
5
5

最終會發現...輸出結果存在隨機性,輸出結果並非 100% 都是 5,更不用提 4 了。這究竟是爲何呢?

分析緣由

其緣由與案例二其實很是接近,理論上理解了案例二也就能解決案例三。

其本質仍是建立 goroutine 與真正執行 fmt.Println 並不一樣步。所以頗有可能在你執行 fmt.Println 時,循環 for-loop 已經運行完畢,所以變量 i 的值最終變成了 5。

那麼相反,其也有可能沒運行完,存在隨機性。寫個 test case 就能發現明顯的不一樣。

總結

在本文中,我分享了幾個近期看到次數最頻繁的一些併發上的小 「坑」,但願對你有所幫助。同時你也能夠回想一下,在你編寫 Go 併發程序有沒有也遇到過什麼問題?

同時你也能夠回想一下,在你編寫 Go 併發程序有沒有也遇到過什麼問題?

個人公衆號

分享 Go 語言、微服務架構和奇怪的系統設計,歡迎你們關注個人公衆號和我進行交流和溝通。

最好的關係是互相成就,各位的點贊就是煎魚創做的最大動力,感謝支持。

相關文章
相關標籤/搜索