Go: 誤用sync-WaitGroup

sync.WaitGroup 是一種等待 n 個操作完成的機制,通常,我們使用它來等待 n 個 goroutine 完成。下面將學習它的使用方法,然後將看到一個高頻錯誤使用問題,以及這個問題導致的不確定性行爲。

下面的代碼創建了一個 sync.WaitGroup 對象,並且爲默認的零值。

wg := sync.WaitGroup{}

在內部實現上,sync.WaitGroup 擁有一個默認初始化零的內部計數器。我們可以使用 Add(int) 方法增加這個計數器,使用 Done() 或者 Add 一個負數來減小計數器。最後需要知道的一點是,如果想等待計數器爲零,必須使用 Wait() 方法,該方法在計數器不爲零時會阻塞。

「NOTE:sync.WaitGroup 計數器的值不能負數,否則會產生 panic.」

下面的示例程序中,初始化了一個 WaitGroup 對象,啓動 3 個 goroutine 併發的將 v 的值增加 1,通過 WaitGroup 等待這 3 個 goroutine 完成。最後,當 3 個 goroutine 都執行完成後,打印計數器 v 的值 (本應該打印 3)。你能猜測這段代碼是否存在問題?

wg := sync.WaitGroup{}
var v uint64

for i := 0; i < 3; i++ {
        go func() {
                wg.Add(1)
                atomic.AddUint64(&v, 1)
                wg.Done()
        }()
}

wg.Wait()
fmt.Println(v)

如果我們運行上面的代碼,得到的是一個不確定的值,它可能打印 0 到 3 中的任何值。此外,如果加入 - race 啓用數據競爭檢查,在運行時甚至會捕獲到存在數據競爭。我們使用的是 sync/atomic 原子包操作 v 自增,怎麼可能存在數據競爭問題呢?到底是哪裏有問題呢?

下面是上面的程序加入 - race 執行的結果:

─ go run -race example1.go                                                                                         
2
==================
WARNING: DATA RACE
Write at 0x00c00001c108 by goroutine 9:
  sync/atomic.AddInt64()
...
Previous read at 0x00c00001c108 by main goroutine:
  main.main()
...

上面代碼存在的問題是 wg.Add(1) 操作在新的 goroutine 內部執行,而不是在父 goroutine 執行的。因此,這不能保證我們希望在調用 wg.Wait() 之前等待三個 goroutine 的本意。

下面是程序打印輸出 2 的一個可能的執行流程。主 goroutine 啓動了 3 個子 goroutine。然而最後一個子 goroutine 是在前兩個子 goroutine 已經調用 wg.Done() 之後執行的。此時,主 goroutine 調用 wg.Done() 不會被阻塞,當它讀取 v 時,此時 v 的值爲 2. 競爭檢測器會檢查到存在競爭問題,因爲此時主 goroutine 對 v 有訪問操作,而第三個子 goroutine 對 v 有修改操作。

在處理 goroutine 時,需要記住的一點是如果沒有同步機制,goroutine 之間的執行順序是不確定的,像下面的程序可能打印 ab 也可能打印 ba.

go func() {
        fmt.Print("a")
}()
go func() {
        fmt.Print("b")
}()

事實上,上面的兩個 goroutine 可以分配給不同的線程,並且不能保證哪個線程會先執行。CPU 必須使用所謂的內存柵欄(也稱爲內存屏障)來保證順序。Go 語言提供了不同的同步技術實現內存柵欄,例如sync.WaitGroup, 它保證了 wg.Add 和 wg.Wait 之間的 happens-before 關係。

現在回到本文最開始的例子,主要有兩種方法修復它存在的問題。一種處理方法如下,在循環之前調用 wg.Add 操作。

wg := sync.WaitGroup{}
var v uint64

wg.Add(3)
for i := 0; i < 3; i++ {
        go func() {
                // ...
        }()
}

// ...

另一種方法是在每個循環的內部,但在啓動子 goroutine 之前調用 wg.Add 操作,代碼如下。

wg := sync.WaitGroup{}
var v uint64

for i := 0; i < 3; i++ {
        wg.Add(1)
        go func() {
                // ...
        }()
}

// ...

上面的兩種處理方法都是正確的。如果我們提前知道最終要設置的計數器的值是多少,那使用第一種處理方法可以只調用一次 wg.Add,不像第二種處理方法要多次調用 wg.Add 操作,但有一點需要注意,後續操作等待的操作次數要與 wg.Add 添加的相同,這樣可以避免一些細微的錯誤,例如 wg.Add 的值爲 3,但是後續等待的操作次數爲 2,這會導致永久阻塞。

總結,我們在編程時要小心別犯本文討論的這個常見錯誤。在使用 sync.WaitGroup 時,Add 操作必須在啓動子 goroutine 之前,在父 goroutine 中執行完成,而 Done 操作必須在子 goroutine 內部執行完成。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/z2rXGWrVcxDE7u7FEcS-Sw