帶你了解一些常見的并發(fā)編程錯誤
Go 是一個內(nèi)置支持并發(fā)編程的語言。借助使用 go 關(guān)鍵字去創(chuàng)建協(xié)程(輕量級線程)和在 Go 中提供的 使用 信道 和 其它的并發(fā) 同步方法,使得并發(fā)編程變得很容易、很靈活和很有趣。
另一方面,Go 并不會阻止一些因 Go 程序員粗心大意或者缺乏經(jīng)驗而造成的并發(fā)編程錯誤。在本文的下面部分將展示一些在 Go 編程中常見的并發(fā)編程錯誤,以幫助 Go 程序員們避免再犯類似的錯誤。
需要同步的時候沒有同步
代碼行或許 不是按出現(xiàn)的順序運(yùn)行的。
在下面的程序中有兩個錯誤。
- ***,在
main協(xié)程中讀取b和在新的 協(xié)程 中寫入b可能導(dǎo)致數(shù)據(jù)爭用。 - 第二,條件
b == true并不能保證在main協(xié)程 中的a != nil。在新的協(xié)程中編譯器和 CPU 可能會通過 重排序指令 進(jìn)行優(yōu)化,因此,在運(yùn)行時b賦值可能發(fā)生在a賦值之前,在main協(xié)程 中當(dāng)a被修改后,它將會讓部分a一直保持為nil。
package mainimport ("time""runtime")func main() {var a []int // nilvar b bool // false// a new goroutinego func () {a = make([]int, 3)b = true // write b}()for !b { // read btime.Sleep(time.Second)runtime.Gosched()}a[0], a[1], a[2] = 0, 1, 2 // might panic}
上面的程序或者在一臺計算機(jī)上運(yùn)行的很好,但是在另一臺上可能會引發(fā)異常?;蛘咚赡苓\(yùn)行了 N 次都很好,但是可能在第 (N+1) 次引發(fā)了異常。
我們將使用 sync 標(biāo)準(zhǔn)包中提供的信道或者同步方法去確保內(nèi)存中的順序。例如,
package mainfunc main() {var a []int = nilc := make(chan struct{})// a new goroutinego func () {a = make([]int, 3)c <- struct{}{}}()<-ca[0], a[1], a[2] = 0, 1, 2}
使用 time.Sleep 調(diào)用去做同步
我們先來看一個簡單的例子。
package mainimport ("fmt""time")func main() {var x = 123go func() {x = 789 // write x}()time.Sleep(time.Second)fmt.Println(x) // read x}
我們預(yù)期程序?qū)⒋蛴〕?nbsp;789。如果我們運(yùn)行它,通常情況下,它確定打印的是 789。但是,這個程序使用的同步方式好嗎?No!原因是 Go 運(yùn)行時并不保證 x 的寫入一定會發(fā)生在 x 的讀取之前。在某些條件下,比如在同一個操作系統(tǒng)上,大部分 CPU 資源被其它運(yùn)行的程序所占用的情況下,寫入 x 可能就會發(fā)生在讀取 x 之后。這就是為什么我們在正式的項目中,從來不使用 time.Sleep 調(diào)用去實現(xiàn)同步的原因。
我們來看一下另外一個示例。
package mainimport ("fmt""time")var x = 0func main() {var num = 123var p = &numc := make(chan int)go func() {c <- *p + x}()time.Sleep(time.Second)num = 789fmt.Println(<-c)}
你認(rèn)為程序的預(yù)期輸出是什么?123 還是 789?事實上它的輸出與編譯器有關(guān)。對于標(biāo)準(zhǔn)的 Go 編譯器 1.10 來說,這個程序很有可能輸出是 123。但是在理論上,它可能輸出的是 789,或者其它的隨機(jī)數(shù)。
現(xiàn)在,我們來改變 c <- *p + x 為 c <- *p,然后再次運(yùn)行這個程序。你將會發(fā)現(xiàn)輸出變成了 789 (使用標(biāo)準(zhǔn)的 Go 編譯器 1.10)。這再次說明它的輸出是與編譯器相關(guān)的。
是的,在上面的程序中存在數(shù)據(jù)爭用。表達(dá)式 *p 可能會被先計算、后計算、或者在處理賦值語句 num = 789 時計算。time.Sleep 調(diào)用并不能保證 *p 發(fā)生在賦值語句處理之前進(jìn)行。
對于這個特定的示例,我們將在新的協(xié)程創(chuàng)建之前,將值保存到一個臨時值中,然后在新的協(xié)程中使用臨時值去消除數(shù)據(jù)爭用。
...tmp := *p + xgo func() {c <- tmp}()...
使協(xié)程掛起
掛起協(xié)程是指讓協(xié)程一直處于阻塞狀態(tài)。導(dǎo)致協(xié)程被掛起的原因很多。比如,
- 一個協(xié)程嘗試從一個 nil 信道中或者從一個沒有其它協(xié)程給它發(fā)送值的信道中檢索數(shù)據(jù)。
- 一個協(xié)程嘗試去發(fā)送一個值到 nil 信道,或者發(fā)送到一個沒有其它的協(xié)程接收值的信道中。
- 一個協(xié)程被它自己死鎖。
- 一組協(xié)程彼此死鎖。
- 當(dāng)運(yùn)行一個沒有
default分支的select代碼塊時,一個協(xié)程被阻塞,以及在select代碼塊中case關(guān)鍵字后的所有信道操作保持阻塞狀態(tài)。
除了有時我們?yōu)榱吮苊獬绦蛲顺?,特意讓一個程序中的 main 協(xié)程保持掛起之外,大多數(shù)其它的協(xié)程掛起都是意外情況。Go 運(yùn)行時很難判斷一個協(xié)程到底是處于掛起狀態(tài)還是臨時阻塞。因此,Go 運(yùn)行時并不會去釋放一個掛起的協(xié)程所占用的資源。
在 誰先響應(yīng)誰獲勝 的信道使用案例中,如果使用的 future 信道容量不夠大,當(dāng)嘗試向 Future 信道發(fā)送結(jié)果時,一些響應(yīng)較慢的信道將被掛起。比如,如果調(diào)用下面的函數(shù),將有 4 個協(xié)程處于永遠(yuǎn)阻塞狀態(tài)。
func request() int {c := make(chan int)for i := 0; i < 5; i++ {i := igo func() {c <- i // 4 goroutines will hang here.}()}return <-c}
為避免這 4 個協(xié)程一直處于掛起狀態(tài), c 信道的容量必須至少是 4。
在 實現(xiàn)誰先響應(yīng)誰獲勝的第二種方法 的信道使用案例中,如果將 future 信道用做非緩沖信道,那么有可能這個信息將永遠(yuǎn)也不會有響應(yīng)而掛起。例如,如果在一個協(xié)程中調(diào)用下面的函數(shù),協(xié)程可能會掛起。原因是,如果接收操作 <-c 準(zhǔn)備就緒之前,五個發(fā)送操作全部嘗試發(fā)送,那么所有的嘗試發(fā)送的操作將全部失敗,因此那個調(diào)用者協(xié)程將永遠(yuǎn)也不會接收到值。
func request() int {c := make(chan int)for i := 0; i < 5; i++ {i := igo func() {select {case c <- i:default:}}()}return <-c}
將信道 c 變成緩沖信道將保證五個發(fā)送操作中的至少一個操作會發(fā)送成功,這樣,上面函數(shù)中的那個調(diào)用者協(xié)程將不會被掛起。
在 sync 標(biāo)準(zhǔn)包中拷貝類型值
在實踐中,sync 標(biāo)準(zhǔn)包中的類型值不會被拷貝。我們應(yīng)該只拷貝這個值的指針。
下面是一個錯誤的并發(fā)編程示例。在這個示例中,當(dāng)調(diào)用 Counter.Value 方法時,將拷貝一個 Counter 接收值。作為接收值的一個字段,Counter 接收值的各個 Mutex 字段也會被拷貝。拷貝不是同步發(fā)生的,因此,拷貝的 Mutex 值可能會出錯。即便是沒有錯誤,拷貝的 Counter 接收值的訪問保護(hù)也是沒有意義的。
import "sync"type Counter struct {sync.Mutexn int64}// This method is okay.func (c *Counter) Increase(d int64) (r int64) {c.Lock()c.n += dr = c.nc.Unlock()return}// The method is bad. When it is called, a Counter// receiver value will be copied.func (c Counter) Value() (r int64) {c.Lock()r = c.nc.Unlock()return}
我們只需要改變 Value 接收類型方法為指針類型 *Counter,就可以避免拷貝 Mutex 值。
在官方的 Go SDK 中提供的 go vet 命令將會報告潛在的錯誤值拷貝。
在錯誤的地方調(diào)用 sync.WaitGroup 的方法
每個 sync.WaitGroup 值維護(hù)一個內(nèi)部計數(shù)器,這個計數(shù)器的初始值為 0。如果一個 WaitGroup 計數(shù)器的值是 0,調(diào)用 WaitGroup 值的 Wait 方法就不會被阻塞,否則,在計數(shù)器值為 0 之前,這個調(diào)用會一直被阻塞。
為了讓 WaitGroup 值的使用有意義,當(dāng)一個 WaitGroup 計數(shù)器值為 0 時,必須在相應(yīng)的 WaitGroup 值的 Wait 方法調(diào)用之前,去調(diào)用 WaitGroup 值的 Add 方法。
例如,下面的程序中,在不正確位置調(diào)用了 Add 方法,這將使***打印出的數(shù)字不總是 100。事實上,這個程序***打印的數(shù)字可能是在 [0, 100) 范圍內(nèi)的一個隨意數(shù)字。原因就是 Add 方法的調(diào)用并不保證一定會發(fā)生在 Wait 方法調(diào)用之前。
package mainimport ("fmt""sync""sync/atomic")func main() {var wg sync.WaitGroupvar x int32 = 0for i := 0; i < 100; i++ {go func() {wg.Add(1)atomic.AddInt32(&x, 1)wg.Done()}()}fmt.Println("To wait ...")wg.Wait()fmt.Println(atomic.LoadInt32(&x))}
為讓程序的表現(xiàn)符合預(yù)期,在 for 循環(huán)中,我們將把 Add 方法的調(diào)用移動到創(chuàng)建的新協(xié)程的范圍之外,修改后的代碼如下。
...for i := 0; i < 100; i++ {wg.Add(1)go func() {atomic.AddInt32(&x, 1)wg.Done()}()}...
不正確使用 futures 信道
在 信道使用案例 的文章中,我們知道一些函數(shù)將返回 futures 信道。假設(shè) fa 和 fb 就是這樣的兩個函數(shù),那么下面的調(diào)用就使用了不正確的 future 參數(shù)。
doSomethingWithFutureArguments(<-fa(), <-fb())
在上面的代碼行中,兩個信道接收操作是順序進(jìn)行的,而不是并發(fā)的。我們做如下修改使它變成并發(fā)操作。
ca, cb := fa(), fb()doSomethingWithFutureArguments(<-c1, <-c2)
沒有等協(xié)程的***的活動的發(fā)送結(jié)束就關(guān)閉信道
Go 程序員經(jīng)常犯的一個錯誤是,還有一些其它的協(xié)程可能會發(fā)送值到以前的信道時,這個信道就已經(jīng)被關(guān)閉了。當(dāng)這樣的發(fā)送(發(fā)送到一個已經(jīng)關(guān)閉的信道)真實發(fā)生時,將引發(fā)一個異常。
這種錯誤在一些以往的著名 Go 項目中也有發(fā)生,比如在 Kubernetes 項目中的 這個 bug 和 這個 bug。
如何安全和優(yōu)雅地關(guān)閉信道,請閱讀 這篇文章。
在值上做 64 位原子操作時沒有保證值地址 64 位對齊
到目前為止(Go 1.10),在標(biāo)準(zhǔn)的 Go 編譯器中,在一個 64 位原子操作中涉及到的值的地址要求必須是 64 位對齊的。如果沒有對齊則導(dǎo)致當(dāng)前的協(xié)程異常。對于標(biāo)準(zhǔn)的 Go 編譯器來說,這種失敗僅發(fā)生在 32 位的架構(gòu)上。請閱讀 內(nèi)存布局 去了解如何在一個 32 位操作系統(tǒng)上保證 64 位對齊。
沒有注意到大量的資源被 time.After 函數(shù)調(diào)用占用
在 time 標(biāo)準(zhǔn)包中的 After 函數(shù)返回 一個延遲通知的信道。這個函數(shù)在某些情況下用起來很便捷,但是,每次調(diào)用它將創(chuàng)建一個 time.Timer 類型的新值。這個新創(chuàng)建的 Timer 值在通過傳遞參數(shù)到 After 函數(shù)指定期間保持激活狀態(tài),如果在這個期間過多的調(diào)用了該函數(shù),可能會有太多的 Timer 值保持激活,這將占用大量的內(nèi)存和計算資源。
例如,如果調(diào)用了下列的 longRunning 函數(shù),將在一分鐘內(nèi)產(chǎn)生大量的消息,然后在某些周期內(nèi)將有大量的 Timer 值保持激活,即便是大量的這些 Timer 值已經(jīng)沒用了也是如此。
import ("fmt""time")// The function will return if a message arrival interval// is larger than one minute.func longRunning(messages <-chan string) {for {select {case <-time.After(time.Minute):returncase msg := <-messages:fmt.Println(msg)}}}
為避免在上述代碼中創(chuàng)建過多的 Timer 值,我們將使用一個單一的 Timer 值去完成同樣的任務(wù)。
func longRunning(messages <-chan string) {timer := time.NewTimer(time.Minute)defer timer.Stop()for {select {case <-timer.C:returncase msg := <-messages:fmt.Println(msg)if !timer.Stop() {<-timer.C}}// The above "if" block can also be put here.timer.Reset(time.Minute)}}
不正確地使用 time.Timer 值
在***,我們將展示一個符合語言使用習(xí)慣的 time.Timer 值的使用示例。需要注意的一個細(xì)節(jié)是,那個 Reset 方法總是在停止或者 time.Timer 值釋放時被使用。
在 select 塊的***個 case 分支的結(jié)束部分,time.Timer 值被釋放,因此,我們不需要去停止它。但是必須在第二個分支中停止定時器。如果在第二個分支中 if 代碼塊缺失,它可能至少在 Reset 方法調(diào)用時,會(通過 Go 運(yùn)行時)發(fā)送到 timer.C 信道,并且那個 longRunning 函數(shù)可能會早于預(yù)期返回,對于 Reset 方法來說,它可能僅僅是重置內(nèi)部定時器為 0,它將不會清理(耗盡)那個發(fā)送到 timer.C 信道的值。
例如,下面的程序很有可能在一秒內(nèi)而不是十秒時退出。并且更重要的是,這個程序并不是 DRF 的(LCTT 譯注:data race free,多線程程序的一種同步程度)。
package mainimport ("fmt""time")func main() {start := time.Now()timer := time.NewTimer(time.Second/2)select {case <-timer.C:default:time.Sleep(time.Second) // go here}timer.Reset(time.Second * 10)<-timer.Cfmt.Println(time.Since(start)) // 1.000188181s}
當(dāng) time.Timer 的值不再被其它任何一個東西使用時,它的值可能被停留在一種非停止?fàn)顟B(tài),但是,建議在結(jié)束時停止它。
在多個協(xié)程中如果不按建議使用 time.Timer 值并發(fā),可能會有 bug 隱患。
我們不應(yīng)該依賴一個 Reset 方法調(diào)用的返回值。Reset 方法返回值的存在僅僅是為了兼容性目的。




















