理解真實專案中的 Go 併發 Bug(Understanding Real-World Concurrency Bugs in Go)

yudotyang發表於2021-09-06

本文內容源於論文《Understanding Real-World Concurrency Bugs in Go》,從 6 個非常流行的開源專案中,收集了 171 個併發 bug,從傳統的共享記憶體訪問、Go 語言新的併發原語的特性方面入手,研究了併發 bug 產生的原因以及修復的方法,以便使 Go 研發人員更好的理解 Go 併發模型以及使用 Go 語言編寫出更穩定、健壯的軟體系統。

表 1 中列出了選擇的 6 個開源專案包括資料中心容器系統(Docker、Kubernetes)、分散式 key-value 儲存系統(etcd)、資料庫系統(CockroachDB、BoltDB)和 gRPC。從星級(starts)看都是流行的開源專案。研發的年份至少 3 年以上。專案規模從幾千行程式碼到百萬行程式碼不等。 可以看出,選擇的專案非常具有代表性。

表 2 表明各專案中都大量的使用了協程。和最後一行的 gRPC-C(用 C 語言實現的)執行緒相比可知,gRPC-C 的每千行程式碼平均建立 0.03 個執行緒,而用 Go 實現的專案,平均從千行程式碼平均 0.18 個協程,到 0.83 個協程。

表 4 中顯示的是各專案使用的併發原語的佔比統計。其中傳統的共享記憶體訪問中主要集中在 Mutex 原語上,而訊息傳遞原語的使用則主要集中在 Channel 的使用上。由此可以看出,Go 雖然推薦在協程之間 “使用通訊來共享記憶體,而不是通過共享記憶體來通訊”,但由該表可知,Go 同時支援共享記憶體和通道通訊兩種併發模式。而且,在實際專案中,使用共享記憶體相關原語還多於通道通訊的併發模式。

該研究基於這 6 個開源專案,共收集了 171 個併發 bug,並將這 171 個併發 bug 分為兩個維度:引起 bug 的原因和 bug 的表現行為(阻塞 bug 和非阻塞 bug)。

阻塞 bug

表格 6 顯示了阻塞 bug 的原因統計。根據該表顯示,在收集到的 82 個 bug 中共計 36 個 bug 是因為對共享記憶體訪問的保護錯誤導致的,有 46 個是因為誤用訊息傳遞導致的。

  • 對共享記憶體訪問導致的 bug 進一步細化分析:
    • 有 28 個是因為 Mutex 的使用不正確,包括重複獲取鎖,獲取鎖的順序存在衝突,忘記釋放鎖等操作。
    • 5 個在 RWMutex 上。在 Go 中寫鎖比讀鎖有更高的優先順序。如果一個協程 A 先執行一次讀鎖即 sync.RWMutex.RLock(),然後一個協程 B 進行獲取寫鎖操作 sync.RWMutex.Lock(),然後協程 A 再進行獲取讀鎖操作,sync.RWMutex.RLock()。 這樣就會形成一個死鎖。因為 A 第一讀鎖可以獲取成功,然後協程 B 獲取寫鎖時,會被阻塞。然後協程 A 再次獲取讀鎖時,也會被 B 的寫鎖堵塞住。
    • 3 個在 Wait 上。一般是一個程式使用了 Cond.Wait(),但沒有其他協程呼叫 Cond.Signal() 來解除等待。
  • 對訊息傳遞導致的 bug 進一步細化分析:
    • 有 29 個是因為誤用 Channel。一般和通道相關的阻塞 bug 是因為沒有向通道傳送訊息(或從通道接收訊息)或關閉通道,而導致正在等待從通道接收訊息(或等待往通道傳送訊息)的協程阻塞。
    • 有 16 個 bug 是因為通道和其他阻塞原語一起使用造成的。比如一個協程因為通道阻塞,另一個協程因為鎖或 wait 操作阻塞。
    • 有 4 個 bug 是因為誤用 Go 中的訊息庫造成的。

根據以上的阻塞 bug 的原因,那麼對應的修復 bug 的方法一般如下:

  • 通過新增缺少的解鎖操作
  • 移動 lock 或 unlock 操作到合適的未知
  • 移除多餘的鎖操作
  • 在 select 語句中增加 default 分支或在一個不同通道上的 case 操作
  • 將 unbuffered channel 替換成 buffered chanel

如圖表 7 中,展示了對阻塞 bug 的修復策略的總結。從對併發原語的新增、移動位置、改變、移除或混合使用共享記憶體和訊息通訊的併發原語來解決阻塞的併發 bug。

由此可見,在該研究中(傳統的共享記憶體的方式和訊息傳遞的方式)的大部分阻塞 bug 都可以通過簡單的方案修復,並且很多修復都是跟 bug 引起原因相關的。

也就是說,阻塞 bug 引起的原因一般是由對共享記憶體的原語和訊息傳遞到原語使用不當造成的。同時在 Go 中,錯誤的使用訊息傳遞的方式導致的阻塞 bug 多餘錯誤的使用共享記憶體原語,高達 58%。然而在解決阻塞 bug 時的方法也很簡單,一般通過移動、刪除、新增對應解鎖原語即可解決

非阻塞 bug

非阻塞 bug 一般是表現為協程之間產生資料競爭,而引起資料競爭的主要原因還是因為沒有對共享記憶體進行保護或錯誤的保護了共享記憶體訪問。

表 9 統計了非阻塞 bug 引起的原因。在收集的 bug 中,大概有 80% 的是因為沒有保護共享記憶體訪問或保護錯誤。

  • 對共享記憶體訪問導致的 bug 進一步細化分析:
    • 傳統的 bug:大部分是因為類似原子性,順序衝突或資料競爭造成的。
    • 匿名函式:在 Go 中可以通過匿名函式來啟動協程,這樣匿名函式就可以訪問本地的變數,如果使用不當,就加大了資料競爭的機會。
    • 誤用 WaitGroup。這是 Go 中的新特性,由於對 WaitGroup 使用的理解不足,造成在呼叫 Wait 和 Add 的時候順序不一致,造成非阻塞 bug。
    • 對 Go 提供的庫函式理解不足。Go 中提供了很多庫函式,這些庫函式可能會隱式的存在變數共享,如果使用不正確,則會非常容易造成非阻塞 bug。
  • 對訊息傳遞導致的 bug 進一步細化分析

    • 誤用通道: 在 Go 中使用通道需要遵循一些基本原則,比如通道只能關閉一次,select 的 case 語句中都準備好時,是隨機選擇 case 分支的
    • Go 中提供的特殊庫的使用:Go 中有些庫使用了通道,研發人員在使用該庫時如果對其內部不瞭解,也容易因為誤用而造成非阻塞 bug。

    針對以上問題,我們看下對非阻塞 bug 的修復策略,如表 10 所示。

表 10 展示了非阻塞 bug 的修復策略。根據表 10 可知:

  • 69% 的非阻塞 bug 可以通過嚴格的時間順序進行修復,或者通過增加像 Mutex 這樣的同步原語,或移動已有的同步原語到合適的未知,類似於 Add。
  • 通過對共享變數進行私有化
  • 通過移除共享變數訪問的指令。

併發 Bug 示例展示

  • 示例 1:該示例節選自 Docker 專案,是由 WaitGroup 引起的阻塞 Bug

    1 var group sync.WaitGroup
    2 group.Add(len(pm.plugins))
    3 for _, p := range pm.plugins {
    4   go func(p *plugin) {
    5       defer group.Done()
    6   }
    7 - group.Wait()
    8 }
    9 +group.Wait()
    

    該示例中的 bug 是因為 WaitGroup 型別的共享變數 group 引起的。因為在第 2 行,len(pm.plugins) 被用做了 Add 的引數,所有隻有當第 5 行的 group.Done() 被呼叫 len(pm.plugins) 次時,第 7 行的 group.Wait() 才會被解除阻塞。因為 Wait 的呼叫放在了 for 迴圈的內部,所以,它會阻塞 for 迴圈在第 4 行後續的協程的建立,並且也阻塞了每個被建立協程的 Done 函式的呼叫。那麼修復方法就是將 Wait 方法移動到 for 迴圈外,如示例中的第 9 行。

  • 示例 2:由 channel 和 lock 的錯誤使用導致的阻塞 bug

    1 func goroutine1() {
    2   m.Lock()
    3   ch <- request //blocks
    4   select {
    5       case ch <- request
    6       default:
    7   }
    8   m.Unlock()
    9 }
    10 func goroutine2() {
    11  for {
    12      m.Lock() //blocks
    13      m.Unlock()
    14      request <- ch
    15  }
    16 }
    

    該示例中,goroutine1 和 goroutine2 兩個協程,同時共享父協程的非緩衝通道 ch。因為在第 3 中的 ch 輸入,只有在第 14 行 goroutine2 從 ch 讀取 request 之後才能寫入成功,所以 goroutine1 在第 3 行將 request 傳送到 channel 中時被阻塞,同時第 12 行 goroutine2 在 m.Lock() 的位置被阻塞,因為第 2 行 goroutine1 中已經進行了 m.Lock()。所以就造成了死鎖。修復辦法就是將第 3 行去掉,增加 4-7 行的 select-case-default 分支。

  • 示例 3:由匿名函式引起的資料競爭的非阻塞 bug,該 bug 也是來源於 Docker 專案

    1 for i := 17; i <= 21; i++ {// write
    2-   go func() { /*Create a new goroutine*/
    3+   go func(i int) {
    4            apiVersion := fmt.Sprintf("v1.%d", i) //read
    5            ...
    6-      }()
    7+      }(i)
    8}
    

    父程式和第 2 行的子協程共享變數 i,研發者的意圖是每個子協程都用不同的 i 值初始化 apiVersion 變數。然而,在這個程式中 apiVersion 的值是不確定的。這跟 go 中子協程的排程時機有關係。例如,子協程開始執行的時間是在整個 for 迴圈之後,那麼 apiVersion 值就會是"v1.21"。只有當每個子協程在建立字串 apiVersion 變數之後且在變數 i 被分配新值之前就立即初始化 apiVersion 變數,那麼該程式才能得到期望的結果。Docker 研發者就通過每次建立協程的時候就拷貝一個 i 值來修復了此 bug。

  • 示例 4:該示例展示了一個由 Timer 導致的非阻塞 bug

    1 - timer := time.NewTimer(0)
    2 + var timeout <-chan time.Time
    3   if dur > 0 {
    4 -        timer = time.NewTimer(dur)
    5 +        timeout = time.NewTimer(dur).C
    6  }
    7  select {
    8 -            case <- timer.C:
    9 +            case <- timeout:
    10      case <- ctx.Done()
    11        return nil
    12  }
    

    上面示例中,程式的意圖是設計一個計時器。在第 1 行,建立了一個 timer 物件,超時時間是 0。在建立 Timer 物件的同時,Go 執行時環境就會隱式的開啟一個內部的協程,以供倒數計時用。在第 4 行 timer 的超時時間被設定為 dur。開發者意圖是僅當 dur 大於 0 或當 ctx.Done() 的時候從當前函式中返回。然而,當 dur 小於等於 0 時,Go 執行時建立的倒數計時的協程將會在 timer 建立的時候就會給 timer.C 通道傳送訊號量,在第 8 行導致函式過早的返回。

  • 示例 5:該 bug 來自 etcd 專案,由於誤用 WaitGroup 導致的非阻塞 bug

    1 func(p *peer) send() {
    2       p.mu.Lock()
    3       defer p.mu.Unlock()
    4       switch p.status {
    5           case idle:
    6 +         p.wg.Add(1)
    7               go func() {
    8 -             p.wg.Add(1)
    9                   ...
    10              p.wg.Done()
    11          }()
    12      case stopped:
    13  }
    14 }
    15 func (p *peer) stop() {
    16      p.mu.Lock()
    17      p.status = stopped
    18      p.mu.Unlock()
    19      p.wg.Wait()
    20 }
    

該 bug 中,在第 8 行的 Add 函式不一定能夠保證在第 19 行的 Wait 語句之前執行。修復方法是將第 8 行的 Wait 函式移動到第 6 行,這樣就能保證 Add 函式一定能在 Wait 函式之前執行。

更多原創文章乾貨分享,請關注公眾號
  • 理解真實專案中的 Go 併發 Bug(Understanding Real-World Concurrency Bugs in Go)
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章