從 Bug 中學習:六大開源項目告訴你 Go 并發編程的那些坑
并發編程中,go不僅僅支持傳統的通過共享內存的方式來通信,更推崇通過channel來傳遞消息,這種新的并發編程模型會出現不同于以往的bug。從bug中學習,《Understanding Real-World Concurrency Bugs in Go》這篇paper在分析了六大開源項目并發相關的bug之后,為我們總結了go并發編程中常見的坑。別往坑里跳,編程更美妙。
在 go 中,創建 goroutine 非常簡單,在函數調用前加 go 關鍵字,這個函數的調用就在一個單獨的 goroutine 中執行了;go 支持匿名函數,讓創建 goroutine 的操作更加簡潔。另外,在并發編程模型上,go 不僅僅支持傳統的通過共享內存的方式來通信,更推崇通過 channel 來傳遞消息:
Do not communicate by sharing memory; instead, share memory by communicating.
這種新的并發編程模型會帶來新類型的 bug,從 bug 中學習,《Understanding Real-World Concurrency Bugs in Go》這篇 paper 在 Docker、Kubernetes、etcd、gRPC、CockroachDB、BoltDB 六大開源項目的 commit log 中搜索"race"、"deadlock"、"synchronization"、"concurrency"、"lock"、"mutex"、"atomic"、"compete"、"context"、"once"、"goroutine leak"等關鍵字,找出了這六大項目中并發相關的 bug,然后歸類這些 bug,總結出了 go 并發編程中常見的一些坑。通過學習這些坑,可以讓我們在以后的項目里防范類似的錯誤,或者遇到類似問題的時候可以幫助指導快速定位排查。
unbuffered channel 由于 receiver 退出導致 sender 側 block
如下面一個 bug 的例子:
- func finishReq(timeout time.Duration) ob {
- ch := make(chan ob)
- go func() {
- result := fn()
- ch <- result // block
- }()
- select {
- case result = <-ch:
- return result
- case <-time.After(timeout):
- return nil
- }
- }
本意是想調用 fn()時加上超時的功能,如果 fn()在超時時間沒有返回,則返回 nil。但是當超時發生的時候,針對代碼中第二行創建的 ch 來說,由于已經沒有 receiver 了,第 5 行將會被 block 住,導致這個 goroutine 永遠不會退出。
If the capacity is zero or absent, the channel is unbuffered and communication succeeds only when both a sender and receiver are ready. Otherwise, the channel is buffered and communication succeeds without blocking if the buffer is not full (sends) or not empty (receives).
這個 bug 的修復方式也是非常的簡單,把 unbuffered channel 修改成 buffered channel。
- func finishReq(timeout time.Duration) ob {
- ch := make(chan ob, 1)
- go func() {
- result := fn()
- ch <- result // block
- }()
- select {
- case result = <-ch:
- return result
- case <-time.After(timeout):
- return nil
- }
- }
思考:在上面的例子中,雖然這樣不會 block 了,但是 channel 一直沒有被關閉,channel 保持不關閉是否會導致資源的泄漏呢?
WaitGroup 誤用導致阻塞
下面是一個 WaitGroup 誤用導致阻塞的一個 bug 的例子: https:// github.com/moby/moby/pu ll/25384
- var group sync.WaitGroup
- group.Add(len(pm.plugins))
- for _, p := range pm.plugins {
- go func(p *plugin) {
- defer group.Done()
- }(p)
- group.Wait()
- }
當 len(pm.plugins)大于等于 2 時,第 7 行將會被卡住,因為這個時候只啟動了一個異步的 goroutine,group.Done()只會被調用一次,group.Wait()將會永久阻塞。修復如下:
- var group sync.WaitGroup
- group.Add(len(pm.plugins))
- for _, p := range pm.plugins {
- go func(p *plugin) {
- defer group.Done()
- }(p)
- }
- group.Wait()
context 誤用導致資源泄漏
如下面的代碼所示:
- hctx, hcancel := context.WithCancel(ctx)
- if timeout > 0 {
- hctx, hcancel = context.WithTimeout(ctx, timeout)
- }
第一行 context.WithCancel(ctx)有可能會創建一個 goroutine,來等待 ctx 是否 Done,如果 parent 的 ctx.Done()的話,cancel 掉 child 的 context。也就是說 hcancel 綁定了一定的資源,不能直接覆蓋。
Canceling this context releases resources associated with it, so code should call cancel as soon as the operations running in this Context complete.
這個 bug 的修復方式是:
- var hctx context.Context
- var hcancel context.CancelFunc
- if timeout > 0 {
- hctx, hcancel = context.WithTimeout(ctx, timeout)
- } else {
- hctx, hcancel = context.WithCancel(ctx)
- }
或者
- hctx, hcancel := context.WithCancel(ctx)
- if timeout > 0 {
- hcancel.Cancel()
- hctx, hcancel = context.WithTimeout(ctx, timeout)
- }
多個 goroutine 同時讀寫共享變量導致的 bug
如下面的例子:
- for i := 17; i <= 21; i++ { // write
- go func() { /* Create a new goroutine */
- apiVersion := fmt.Sprintf("v1.%d", i) // read
- }()
- }
第二行中的匿名函數形成了一個閉包(closures),在閉包內部可以訪問定義在外面的變量,如上面的例子中,第 1 行在寫 i 這個變量,在第 3 行在讀 i 這個變量。這里的關鍵的問題是對同一個變量的讀寫是在兩個 goroutine 里面同時進行的,因此是不安全的。
Function literals are closures: they may refer to variables defined in a surrounding function. Those variables are then shared between the surrounding function and the function literal, and they survive as long as they are accessible.
可以修改成:
- for i := 17; i <= 21; i++ { // write
- go func(i int) { /* Create a new goroutine */
- apiVersion := fmt.Sprintf("v1.%d", i) // read
- }(i)
- }
通過 passed by value
的方式規避了并發讀寫的問題。
channel 被關閉多次引發的 bug
https:// github.com/moby/moby/pu ll/24007/files
- select {
- case <-c.closed:
- default:
- close(c.closed)
- }
上面這塊代碼可能會被多個 goroutine 同時執行,這段代碼的邏輯是,case 這個分支判斷 closed 這個 channel 是否被關閉了,如果被關閉的話,就什么都不做;如果 closed 沒有被關閉的話,就執行 default 分支關閉這個 channel,多個 goroutine 并發執行的時候,有可能會導致 closed 這個 channel 被關閉多次。
For a channel c, the built-in function close(c) records that no more values will be sent on the channel. It is an error if c is a receive-only channel. Sending to or closing a closed channel causes a run-time panic.
這個 bug 的修復方式是:
- Once.Do(func() {
- close(c.closed)
- })
把整個 select 語句塊換成 Once.Do,保證 channel 只關閉一次。
timer 誤用產生的 bug
如下面的例子:
- timer := time.NewTimer(0)
- if dur > 0 {
- timer = time.NewTimer(dur)
- }
- select {
- case <-timer.C:
- case <-ctx.Done():
- return nil
- }
原意是想 dur 大于 0 的時候,設置 timer 超時時間,但是 timer := time.NewTimer(0)導致 timer.C 立即觸發。修復后:
- var timeout <-chan time.Time
- if dur > 0 {
- timeout = time.NewTimer(dur).C
- }
- select {
- case <-timeout:
- case <-ctx.Done():
- return nil
- }
A nil channel is never ready for communication.
上面的代碼中第一個 case 分支 timeout 有可能是個 nil 的 channel,select 在 nil 的 channel 上,這個分支不會被觸發,因此不會有問題。
讀寫鎖誤用引發的 bug
go 語言中的 RWMutex,write lock 有更高的優先級:
If a goroutine holds a RWMutex for reading and another goroutine might call Lock, no goroutine should expect to be able to acquire a read lock until the initial read lock is released. In particular, this prohibits recursive read locking. This is to ensure that the lock eventually becomes available; a blocked Lock call excludes new readers from acquiring the lock.
如果一個 goroutine 拿到了一個 read lock,然后另外一個 goroutine 調用了 Lock,第一個 goroutine 再調用 read lock 的時候會死鎖,應予以避免。