規避 Go 中的常見併發 bug 阻塞式bug 非阻塞式bug 總結

Understanding Real-World Concurrency Bugs in Go這篇論文中,幾名研究人員分析了常見的Go併發bug,並在最流行的幾個Go開源項目中進行了驗證。本文梳理了論文中提到的常見的bug並給出解決方法的分析。

論文中對bugs進行了分類,分爲阻塞式和非阻塞式兩種:
阻塞式:goroutine發生阻塞無法繼續執行(例如死鎖)
非阻塞式:不會阻塞執行,但存在潛在的數據衝突(例如併發寫)

阻塞式bug

阻塞式bug發生的根因有兩種,一種是共享內存(例如卡在了意圖保護共享內存的鎖操作上),一種是消息傳遞(比如等待chan)。同時研究發現共享內存和消息傳遞導致的bug數量不想上下,但是共享這種方法的使用量比消息傳遞使用的更頻繁,所以也得出了共享內存方式更不容易導致bug的結論。

讀寫鎖優先級導致的死鎖

在Go中的寫鎖優先級高於讀鎖優先級,假設一個goroutine(goroutine A)連續獲取兩次讀鎖,而另一個goroutine(goroutine B)在gouroutine A兩次獲取讀鎖中間獲取了寫鎖,就會導致死鎖的發生。論文中沒有針對這個bug給出示例代碼,我寫了一個簡單的代碼示意一下。

func gouroutine1() {
    m.RLock()
    m.RLock()
}

func gouroutine2() {
    m.WLock()
}

f1和f2都在goroutine中執行,當f1執行完第一個l.RLock()語句後,假設這時f2的m.WLock執行,由於寫鎖是排它的,WLock本身被f1的第一個m.RLock()阻塞,寫鎖操作本身又會阻塞f1中的第二個m.RLock

WaitGroup誤用導致的死鎖

這種情況就是比較典型的WaitGroup的誤用了,提前執行group.Wait()會導致部分group.Done()無法執行到,進而導致程序被阻塞。

var group sync.WaitGroup
group.Add(len(pm.plugins))
for _, p := range pm.plugins {
    go func(p *plugin) {
        defer group.Done()
    }
    group.Wait()  // blocked
}
// group.Wait() should be here

for循環內的group.Wait()執行到的時候,循環內的部分goroutine還沒有被創建出來,其中的group.Done()也就永遠沒法執行到,所以會導致永遠阻塞在這一句,正確的寫法是將group.Wait()移到for循環外。

Channel的誤用

Channel是go支持併發的一個非常重要的特性,Channel雖然在很多場景下非常解決問題,但是誤用也是不容易發現的。

func goroutine1() {
    m.Lock()
    ch <- request  // blocked
    m.Unlock()
}

func goroutine2() {
    for {
        m.Lock()  // 阻塞
        m.Unlock()
        request <- ch
    }
}

這段代碼的業務語義是goroutine1會通過ch接收goroutine2發送的消息,但是當goroutine1執行到ch <- request時候會阻塞並等待ch,此時由於goroutine1沒有釋放鎖,goroutine2的m.Lock()也會阻塞,形成死鎖。

特殊庫的誤用

hctx, hcancel := context.WithCancel(ctx)
if timeout > 0 {
    hctx, hcancel = context.WithTimeout(ctx, timeout)
}

除了顯式的使用channel,go提供了一些lib來在goroutine之間傳遞消息,上面代碼在執行hctx, hcancel := context.WithCancel(ctx)時會創建一個goroutine出來,而當timeout>0時又回創建新的channel賦給同一個變量hcancel,這會導致第一行創建出的channel不會被關閉,也不能再給這個channel發消息。

非阻塞式bug

和阻塞式bug類似,非阻塞式bug也由共享內存和消息傳遞引起:當試圖保護一個共享變量失敗時候,或消息傳遞使用不當時候,都可能造成非阻塞式的bug。

匿名函數

雖然論文中將這一類錯誤歸結爲匿名函數的不正確使用,但實際上產生這類bug的原因是工程師忽略了實際上在跨goroutine共享的變量。

for i := 17; i <= 21; i++ { // write
    go func() { /* Create a new goroutine */ 
        apiVersion := fmt.Sprintf("v1.%d", i) // read
        ...
    }()
}

如這段代碼(也經常出現在面試中),由於變量i在匿名函數構建出的goroutine和主goroutine共享,又不能保證goroutine什麼時候執行,所以goroutine中拿到的i並不確定(大概率這幾個循環創建出的goroutine拿到的都是21)。

WaitGroup的誤用

func (p *peer) send() {
    p.mu.Lock()
    defer p.mu.Unlock()
    switch p.status {
        case idle:
        go func() {
            p.wg.Add(1)
            ...
            p.wg.Done()
        }()
        case stopped:
    }
}

func (p * peer) stop() {
    p.mu.Lock()
    p.status = stopped
    p.mu.Unlock()
    p.wg.Wait()
}

上面這段代碼中,由於不能保證send方法的goroutine什麼時候執行,所以可能導致stop函數的p.wg.Wait()在send函數的p.wg.Add(1)之前執行。

特殊庫的誤用

諸如context這樣被設計會在多個goroutine間傳遞數據的庫,在使用時也需要特別注意,可能會導致數據競爭。

Channel的誤用

select {
    case <- c.closed:
    default:
        close(c.closed)
}

由於default語句可能被多次觸發,導致一個channel可能被多次關閉,進而造成panic。

ticker := time.NewTicker()
for{
    f()  // heavy function
    select {
        case <- stopCh: return
        case <- ticker:
    }
}

對於上面這段代碼,當f是一個耗時函數時,很可能出現一次for循環後stopCh和ticker兩個case同時滿足,這時是沒法確認先進哪個case的。

特殊庫的誤用

timer := time.NewTimer(0)
if dur > 0 {
    timer = time.NewTimer(dur)
}

select {
    case <- timer.C:
    case <- ctx.Done():
        return nil
}

上面這段代碼中,第一行創建的timer由於超時時間是0,所以會立刻觸發select中的第一個case,導致和期望不符合的行爲。

總結

Go的特性使得線程的創建和數據傳遞都非常容易,但是容易的背後線程間通信的那些坑依然是存在的,論文認爲go的消息傳遞機制會導致更多的bug出現。在我看來,go的消息傳遞機制相比於傳統的共享內存機制,相當於多了一層邏輯層面的封裝,這種特性有時會讓傳統的多線程編程經驗不能直接發揮價值,但是隻要把握住底層的機制,可以很快積累基於go的語言特性的併發編程經驗。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章