考慮到各種原因,以後技術類的文章暫定每周四發布。
Mutex系列是根據我對晁嶽攀老師的《Go 並發編程實戰課》的吸收和理解整理而成,如有偏差,歡迎指正~
把 Mutex 設計原理看完之後,再看這一章,就簡單很多了。Mutex 的實現比較複雜,但是使用起來還是非常簡單的,它只有 Lock/Unlock 兩個方法。
但是很多時候,由於疏忽,或者因為對代碼的上下文不了解,就會出現一些bug。這一篇中,我們一起來看一下使用 Mutex 常犯的幾個錯誤。
Lock和Unlock 不成對出現Lock/Unlock 操作不成對出現,有以下兩種情況:
1)只有 Lock 的情況
如果有一個協程只進行了 Lock 操作,沒有進行 Unlock 操作,那麼其它協程就會一直被阻塞(拿不到鎖),整個系統隨著阻塞的協程越來越多,最終崩潰。
2)只有 Unlock 的情況
如果一個協程只進行了 Unlock 操作,會出現兩種情況:
如果有其它協程佔有鎖,鎖會被釋放
如果鎖處於未被佔有狀態,會拋 panic
前面提過,Mutex 不記錄協程的信息,所以不管是哪個協程執行 Unlock 操作,都會產生解鎖的效果。
大家可能會覺得這麼簡單的錯誤,怎麼可能會出現呢?但是,後面會介紹到,哪怕是大型知名的開源項目,也會犯這些錯誤。
Copy 已使用的 Mutex先介紹一個知識點:sync 包中的同步原語是不能複製的,Mutex 作為最常用的同步原語,當然也不能複製。
原因在於 Mutex 是一個有狀態的對象。如果複製一個 Mutex,會將相應的狀態也複製過來。這樣一個新的 Mutex 可能莫名處於持有鎖、喚醒或者飢餓狀態,甚至等阻塞等待數量遠遠大於0。
不過 Go 有死鎖檢查機制,如果出現了這種情況,直接運行,是會報錯的。除此以外,還可以用 go vet 工具主動檢查死鎖。
示例:
package main
import "sync"
func copyF(m sync.Mutex) { m.Lock() defer m.Unlock()
return}
func main() { var m sync.Mutex m.Lock() defer m.Unlock()
copyF(m) return}直接 go run 會報錯,通過 go vet main.go,結果如下:
main.go:11:14: copyF passes lock by value: sync.Mutexmain.go:26:8: call of copyF copies lock value: sync.Mutex如果只是單純的複製 Mutex,不使用的話,go run 可以正常運行,只有 go vet 能檢測出來。重入先解釋一下什麼是」可重入鎖「。我們都知道,一個協程搶佔鎖之後,其它協程無法搶佔鎖,但是如果該協程自己再次加鎖呢?可重入鎖是指當前協程還能再次搶佔鎖。可重入鎖一般也叫做」遞歸鎖「
Mutex 不是可重入鎖! 前面提過,Mutex 不會記錄持有鎖的協程的信息,所以它也無法區分是不是重入這種場景。
既然 Mutex 因為不記錄持有鎖的協程信息,我們怎麼讓 Mutex 具備重入的特性呢?答案就是想辦法讓 Mutex 帶上協程標誌信息。。
方案有兩種:
獲取 goroutine id,用這個 id 來唯一標識協程。這個有開源的包能幹這個事情。
協程自己提供 token,用這個 token 來唯一標識協程。
這兩種方案思路都是獲取協程的唯一標識,這樣重入加鎖的時候,才能判斷是不是同一個協程在加鎖。
type RecursiveMutex struct { sync.Mutex owner int64 recursion int32 }
func (m *RecursiveMutex) Lock() { gid := goid.Get() if atomic.LoadInt64(&m.owner) == gid { m.recursion++ return } m.Mutex.Lock() atomic.StoreInt64(&m.owner, gid) m.recursion = 1}
func (m *RecursiveMutex) Unlock() { gid := goid.Get() if atomic.LoadInt64(&m.owner) != gid { panic(fmt.Sprintf("wrong the owner(%d): %d!", m.owner, gid)) } m.recursion-- if m.recursion != 0 { return } atomic.StoreInt64(&m.owner, -1) m.Mutex.Unlock()}死鎖
其實這一段代碼只要看2-6行的 RecursiveMutex 的定義就能猜出來大概的邏輯:標誌鎖的持有者,並用一個計數器來記錄重入的次數。重入的時候,實際操作就是計數器+1,解鎖的時候計數器-1,直到最後一次,真正釋放鎖。死鎖就是兩個及兩個以上的協程在執行過程中,因為爭搶資源而處於一種相互等待的情況。如果沒有外部幹涉,它們都將無法推進下去,這種情況就稱之為死鎖。
死鎖產生的必要條件
互斥:被爭搶的資源是排他不共享的
持有和等待:某協程持有一個資源,並且還在請求其它協程持有的資源。
不可剝奪:資源只能等持有它的協程釋放
環路等待:n個協程P1-Pn,P1等待P2,...,Pn-1等待Pn,最後Pn等待P1
如果要避免產生死鎖,只要破壞上面4個條件中的1個或多個就行。
其實前面 Copy 已使用的 Mutex 的示例就符合上面的4個條件(把 main 函數和 copyF 想像成兩個協程)。尤其是最後一個條件,main 函數得等 copyF 完成才能進行下一步;而 copyF 又因為拿不到鎖(等 main 釋放鎖),就阻塞了,最終形成了死鎖。
大型知名項目踩坑記上面列舉了4種 Mutex 使用中易錯的場景,也許你覺得這種簡單錯誤怎麼會犯呢?然而不少知名的項目,都或多或少犯過上面的錯誤。下面讓我們逐一欣賞一下。
gRPCgRPC 是 Google 發起的一個 Go 實現的 RPC 框架。但即使是 Google 官方的框架,也出現了前面說的 Mutex 的使用問題。比如下面這個 issue (https://github.com/grpc/grpc-go/pull/795):

沒想到吧,竟然出現了把 Unlock 寫成 Lock 的錯誤🤭
dockerdocker 的 issue-34881 本來是修復一個簡單的問題,如果不滿足條件判斷,就直接返回。你能看出有什麼問題嗎?
是的,你沒看錯,34行前面沒有解鎖 Unlock 就直接返回了。
當然,docker 還有許多 issue 都和 Mutex 有關。除此之外,其它的一些知名項目都踩過這種坑,所以,不能因為這幾種錯誤比較簡單就忽視它,還是要引起重視。
最後最開始說了,這門課是晁嶽攀老師的《Go 並發編程實戰課》。3個月前就在極客時間上買了這個課程,一直拖到現在才開始看。剛看了前三講,很不錯,所以順帶推薦一下~~
都看到這裡了,不如順手點個 贊/在看?