從 Bug 中學(xué)習(xí):六大開源項(xiàng)目告訴你 Go 并發(fā)編程的那些坑
并發(fā)編程中,go不僅僅支持傳統(tǒng)的通過共享內(nèi)存的方式來通信,更推崇通過channel來傳遞消息,這種新的并發(fā)編程模型會(huì)出現(xiàn)不同于以往的bug。從bug中學(xué)習(xí),《Understanding Real-World Concurrency Bugs in Go》這篇paper在分析了六大開源項(xiàng)目并發(fā)相關(guān)的bug之后,為我們總結(jié)了go并發(fā)編程中常見的坑。別往坑里跳,編程更美妙。
在 go 中,創(chuàng)建 goroutine 非常簡(jiǎn)單,在函數(shù)調(diào)用前加 go 關(guān)鍵字,這個(gè)函數(shù)的調(diào)用就在一個(gè)單獨(dú)的 goroutine 中執(zhí)行了;go 支持匿名函數(shù),讓創(chuàng)建 goroutine 的操作更加簡(jiǎn)潔。另外,在并發(fā)編程模型上,go 不僅僅支持傳統(tǒng)的通過共享內(nèi)存的方式來通信,更推崇通過 channel 來傳遞消息:
Do not communicate by sharing memory; instead, share memory by communicating.
這種新的并發(fā)編程模型會(huì)帶來新類型的 bug,從 bug 中學(xué)習(xí),《Understanding Real-World Concurrency Bugs in Go》這篇 paper 在 Docker、Kubernetes、etcd、gRPC、CockroachDB、BoltDB 六大開源項(xiàng)目的 commit log 中搜索"race"、"deadlock"、"synchronization"、"concurrency"、"lock"、"mutex"、"atomic"、"compete"、"context"、"once"、"goroutine leak"等關(guān)鍵字,找出了這六大項(xiàng)目中并發(fā)相關(guān)的 bug,然后歸類這些 bug,總結(jié)出了 go 并發(fā)編程中常見的一些坑。通過學(xué)習(xí)這些坑,可以讓我們?cè)谝院蟮捻?xiàng)目里防范類似的錯(cuò)誤,或者遇到類似問題的時(shí)候可以幫助指導(dǎo)快速定位排查。
unbuffered channel 由于 receiver 退出導(dǎo)致 sender 側(cè) block
如下面一個(gè) 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
- }
- }
本意是想調(diào)用 fn()時(shí)加上超時(shí)的功能,如果 fn()在超時(shí)時(shí)間沒有返回,則返回 nil。但是當(dāng)超時(shí)發(fā)生的時(shí)候,針對(duì)代碼中第二行創(chuàng)建的 ch 來說,由于已經(jīng)沒有 receiver 了,第 5 行將會(huì)被 block 住,導(dǎo)致這個(gè) goroutine 永遠(yuǎn)不會(huì)退出。
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).
這個(gè) bug 的修復(fù)方式也是非常的簡(jiǎn)單,把 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
- }
- }
思考:在上面的例子中,雖然這樣不會(huì) block 了,但是 channel 一直沒有被關(guān)閉,channel 保持不關(guān)閉是否會(huì)導(dǎo)致資源的泄漏呢?
WaitGroup 誤用導(dǎo)致阻塞
下面是一個(gè) WaitGroup 誤用導(dǎo)致阻塞的一個(gè) 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()
- }
當(dāng) len(pm.plugins)大于等于 2 時(shí),第 7 行將會(huì)被卡住,因?yàn)檫@個(gè)時(shí)候只啟動(dòng)了一個(gè)異步的 goroutine,group.Done()只會(huì)被調(diào)用一次,group.Wait()將會(huì)永久阻塞。修復(fù)如下:
- 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 誤用導(dǎo)致資源泄漏
如下面的代碼所示:
- hctx, hcancel := context.WithCancel(ctx)
- if timeout > 0 {
- hctx, hcancel = context.WithTimeout(ctx, timeout)
- }
第一行 context.WithCancel(ctx)有可能會(huì)創(chuàng)建一個(gè) 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.
這個(gè) bug 的修復(fù)方式是:
- 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)
- }
多個(gè) goroutine 同時(shí)讀寫共享變量導(dǎo)致的 bug
如下面的例子:
- for i := 17; i <= 21; i++ { // write
- go func() { /* Create a new goroutine */
- apiVersion := fmt.Sprintf("v1.%d", i) // read
- }()
- }
第二行中的匿名函數(shù)形成了一個(gè)閉包(closures),在閉包內(nèi)部可以訪問定義在外面的變量,如上面的例子中,第 1 行在寫 i 這個(gè)變量,在第 3 行在讀 i 這個(gè)變量。這里的關(guān)鍵的問題是對(duì)同一個(gè)變量的讀寫是在兩個(gè) goroutine 里面同時(shí)進(jìn)行的,因此是不安全的。
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
的方式規(guī)避了并發(fā)讀寫的問題。
channel 被關(guān)閉多次引發(fā)的 bug
https:// github.com/moby/moby/pu ll/24007/files
- select {
- case <-c.closed:
- default:
- close(c.closed)
- }
上面這塊代碼可能會(huì)被多個(gè) goroutine 同時(shí)執(zhí)行,這段代碼的邏輯是,case 這個(gè)分支判斷 closed 這個(gè) channel 是否被關(guān)閉了,如果被關(guān)閉的話,就什么都不做;如果 closed 沒有被關(guān)閉的話,就執(zhí)行 default 分支關(guān)閉這個(gè) channel,多個(gè) goroutine 并發(fā)執(zhí)行的時(shí)候,有可能會(huì)導(dǎo)致 closed 這個(gè) channel 被關(guān)閉多次。
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.
這個(gè) bug 的修復(fù)方式是:
- Once.Do(func() {
- close(c.closed)
- })
把整個(gè) select 語句塊換成 Once.Do,保證 channel 只關(guān)閉一次。
timer 誤用產(chǎn)生的 bug
如下面的例子:
- timer := time.NewTimer(0)
- if dur > 0 {
- timer = time.NewTimer(dur)
- }
- select {
- case <-timer.C:
- case <-ctx.Done():
- return nil
- }
原意是想 dur 大于 0 的時(shí)候,設(shè)置 timer 超時(shí)時(shí)間,但是 timer := time.NewTimer(0)導(dǎo)致 timer.C 立即觸發(fā)。修復(fù)后:
- 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.
上面的代碼中第一個(gè) case 分支 timeout 有可能是個(gè) nil 的 channel,select 在 nil 的 channel 上,這個(gè)分支不會(huì)被觸發(fā),因此不會(huì)有問題。
讀寫鎖誤用引發(fā)的 bug
go 語言中的 RWMutex,write lock 有更高的優(yōu)先級(jí):
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.
如果一個(gè) goroutine 拿到了一個(gè) read lock,然后另外一個(gè) goroutine 調(diào)用了 Lock,第一個(gè) goroutine 再調(diào)用 read lock 的時(shí)候會(huì)死鎖,應(yīng)予以避免。