理解真實世界的併發Bug
Go帶來了新的併發原語和併發模式(其實也不太新),如果沒有深入瞭解這些特性,一樣會寫出併發bug。
在Understanding Real-World Concurrency Bugs in Go 這篇論文裡,作者系統地分析了6個流行的Go專案(Docker、Kubernetes、gRPC-go、etcd、CockroachDB、 BoltD)和其中171個併發bug,通過這些分析我們可以加深對Go的併發模型的理解,從而產出更好、更可靠的程式碼。
Our study shows that it is as easy to make concurrency bugs with message passing as with shared memory,sometimes even more.
例如下面是k8s的一個bug,finishReq
建立了一個子協程來執行fn
然後通過select
等待子協程完成或超時:
func finishReq(timeout time.Duration) r ob { ch :=make(chanob) // ch :=make(chanob, 1) // 修復方案 go func() { result := fn() ch <- result // 阻塞 } select { case result = <- ch return result case <- time.After(timeout) return nil } } }
如果超時先發生,或者子協程和超時同時發生但go執行時選擇了超時分支(非確定性),子協程就會永遠阻塞。
Go併發模式使用情況
這一節分析了6個專案裡goroutine、併發原語的使用情況。
匿名函式的goroutine使用比普通函式要多,基本每1~5千行程式碼建立一個goroutine。
雖然Go鼓勵訊息傳遞,但是在這些大專案裡,共享記憶體的使用比訊息傳遞要多,Mutex基本在channel的兩倍以上。
Bug分類
這篇論文裡,按兩個維度對bug進行分類:
- 行為:阻塞和非阻塞,阻塞bug指goroutine意外地阻塞無法繼續執行的情況(例如死鎖),非阻塞bug通常是資料衝突
- 原因:共享記憶體和訊息傳遞,因為用了這兩種技術之一導致的bug
可以看到,共享記憶體其實導致了更多的bug。
阻塞bug
訊息傳遞和共享記憶體導致的阻塞bug幾乎一樣多,而且訊息傳遞的阻塞bug都和Go的訊息傳遞語義例如channel有關,訊息傳遞和共享記憶體一起使用的時候會很難發現bug。
例如Docker錯誤使用WaitGroup
導致阻塞:
var group sync.WaitGroup group.Add(len(pm.plugins)) for_, p := range pm.plugins { go func(p *plugin) { defer group.Done() } group.Wait() // 阻塞 } // 應該在這裡group.Wait()
錯誤使用channel和mutex導致阻塞:
func goroutine1() { m.Lock() ch <- request // 阻塞 m.Unlock() } func goroutine2() { for{ m.Lock()// 阻塞 m.Unlock() request <- ch } }
非阻塞bug
共享記憶體導致更多的非阻塞bug,幾乎是訊息傳遞的8倍。
例如在下面這段程式碼裡,每當ticker
觸發時執行一次f()
,通過stopCh
退出迴圈:
ticker := time.NewTicker() for { f() select { case <- stopCh return case <- ticker } }
但是select是非確定性的,stopCh
和ticker
同時發生時,不一定會執行stopChan
的分支,正確做法是先檢查一次stopCh
:
ticker := time.NewTicker() for { select{ case <- stopCh: return default: } f() select { case <- stopCh: return case <- ticker: } }
參考
- system-pclub/go-concurrency-bugs :論文資料集,包含各專案真實bug程式碼和修復,是個非常好的學習資源。
- Understanding Real-World Concurrency Bugs in Go :論文字體