golang自帶的死鎖檢測并非銀彈
網上總是能看到有人說go自帶了死鎖檢測,只要有死鎖發生runtime就能檢測到并及時報錯退出,因此go不會被死鎖問題困擾。
這說明了口口相傳知識的有效性是日常值得懷疑的,同時也再一次證明了沒有銀彈這句話的含金量。
這個說法的殺傷力在于它雖然不對,但也不是全錯,真真假假很容易讓人失去判斷力。
死鎖檢測失靈
死鎖我就不多解釋了,我們先來看個簡單例子:
package main
import (
"fmt"
)
func main() {
c := make(chan int, 1)
fmt.Println(<-c)
}
這段代碼會觸發golang的死鎖報錯:
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
main.main()
/tmp/deadlock.go:9 +0x32
exit status 2
這個例子為啥鎖死了,因為沒人給chan發數據,所以接收端永久阻塞在接收操作上了。
這說明了go確實有死鎖檢測。只不過你要是覺得它什么樣的死鎖都檢測到那就大錯特錯了:
package main
import (
"fmt"
"time"
)
func main() {
c := make(chan int, 1)
for {
go func() {
fmt.Println(<-c)
}()
time.Sleep(10 * time.Millisecond)
}
}
根據示例1我們可以知道如果一個chan沒有發送者,那么所有的接收者都會阻塞,在我們的例子里這些協程是永久阻塞的,理論上應該會被檢測到然后報錯。
遺憾的是這個程序會持續運行下去,直到內存耗盡為止:

死鎖檢測是有足夠的時間執行的,因為10毫秒雖然對人類來說短的可以忽略但對golang運行時來說相當漫長,而且我們在不停創建協程,滿足所有觸發檢測的條件,具體條件后面會細說。
從實驗對照的角度來說,這時合理的猜測應該是會不會主協程被特殊處理了,因為上面的例子里子協程全部死鎖,但主協程并沒有。所以我們再次進行測試:
package main
import (
"fmt"
"time"
)
func main() {
c := make(chan int, 1)
go func() {
for {
fmt.Println("Hello from child.")
time.Sleep(100 * time.Millisecond)
}
}()
<-c
}
這段代碼同樣不會報錯,程序會持續輸出Hello直到你手動終止進程或者關機為止。這正說明了runtime不會在死鎖檢測上特殊對待主協程。
看上去go的死鎖檢測時?!笆ъ`”,這是一件很恐怖的事情,尤其是在你信了文章開頭那個說法在代碼里放飛自我認為只要沒報錯就是沒問題之后。
go的死鎖檢測到底檢測了什么
說這是“失靈”其實有失偏頗,上面的現象解釋起來其實很簡單,三兩段話就能說明白。
首先我們可以把go里的協程分為兩大類,一類是runtime自己的協程,包括sysmon和gc;另一類是用戶創建的協程,包括用戶自己創建的,用戶使用的第三方庫/標準庫創建的所有協程。我們暫且管后者叫“用戶協程”。這只是很粗糙的分類,實際的代碼中有不少出入,不過作為抽象概率幫助理解是沒問題的。死鎖檢測針對的就是“用戶協程”。
知道了檢測范圍,我們還需要知道檢測內容——換句話說,什么情況下能判斷一組協程死鎖了?理想中當然是檢測到一組協程循環等待某些條件或者阻塞在一些永遠不會有數據的chan上?,F實是go只檢測這些:
- 是否有協程處于運行狀態,包括并未實際運行在等待調度的“可運行”用戶協程;
- 沒有上述條件的協程就檢測是否還有未觸發的定時器;
- 都不滿足才會觸發死鎖報錯并終止程序。
檢測的時機其實也是有些反直覺的,go只在創建/退出操作系統級別的線程、這些線程變為空閑狀態時、sysmon檢測到程序處于空閑時才會執行死鎖檢測。也就是說,觸發檢測其實和操作系統線程相關性更強而不是和goroutine。
所以,只要還有一個協程能繼續運行,哪怕其他99999個協程都鎖地死死得,go的死鎖檢測依然不會報錯(更正確的說法是只要還有一個能繼續運行的系統級線程,那就不算死鎖,這樣才能解釋為什么有還未觸發的定時器以及在等待系統調用也不算死鎖)。這樣解釋了為什么示例2和3都能運行,因為2中主協程能正常運行,3中子協程能正常運行,因此其他的協程鎖死了也不會報錯。
檢測還有兩個例外:
- cgo管不了,因此go程序調用的c/c++代碼的線程里鎖死了go這邊也沒有辦法
- 把go代碼編譯成c庫之后死鎖檢測會主動關閉,因為如果c/c++代碼沒調用庫里的函數的話,那就只有runtime協程存在,這時候檢測會發現根本沒有用戶協程,這種檢測沒有意義。所以在這種情況下哪怕go代碼真的全部死鎖了也不會檢測到。
有人估計覺得這是bug或者設計失誤要急著去提issue了,但這不是bug!這只是看待問題的方式不同。
go采用的做法,比較正式的描述是“在期望時間內程序的運行是否能取得進展”,這里的進展當然是指的是否在運行或者有定時器/io要處理。以此標準只要還有用戶協程能動,那說明“整個程序”并沒有死鎖——go里判斷要不要觸發死鎖報錯是以整個程序作為基準的,而我們通常的判斷基準是所有用戶協程都能在“期望時間內獲得進展”才是沒有問題。后者的要求更嚴格。
而且滿足后者的檢測實現起來很復雜,預計也會花費非常多的計算資源,從維護和運行性能的角度來說想做也不是很現實。所以go選擇了前者,前者雖然不能處理所有的問題,但仍然能在早期階段防止出現一部分死鎖問題。
然而把死鎖檢測當成萬金油保險絲的人就要倒霉了:
- 現實的項目中出現一次性鎖死整個程序的情況其實是比較少的,更多的時間是像例子中那樣一部分協程鎖死;
- 鎖死的協程除了不能繼續運行之外,還會造成協程泄漏,更要命的是協程持有的對象都不會釋放,所以還伴隨著內存泄漏;
- 在一些程序里系統的一部分鎖死了可能在短時間內影響不到其他部分,在web應用中很常見,這會讓問題發生難以察覺,往往當你意識到出問題時整個程序已經到萬劫不復的狀態了。
所以死鎖檢測只能偶爾幫你一次,并不能當成救命稻草用。
死鎖檢測的源代碼在"src/runtime/proc.go"的checkdead函數里,感興趣的可以自行把玩。
怎么檢測死鎖
既然報錯不能料理所有情況,我們還能借助哪些工具定位是否有死鎖發生呢?
其實沒啥好辦法,下面每一種方案都需要經驗以及結合實際代碼才能判斷出結果。
第一種是觀察協程數量或者內存占用是否異常。比如你的程序正常需要1000個協程,那么2千個協程也許不是出問題了,但出現2萬個協程那肯定是不對勁的。內存占用同理。
這些數據很好獲取,不管是go自帶的pprof還是trace,或者是第三方的性能監控,都能很輕松的探測到異常。難的是如何定位具體的問題。不過這節說的是如何發現死鎖,所以出現上述異常后把可能存在死鎖放進排查方向里也就夠了。因為協程泄漏雖然不一定都是死鎖造成的,但死鎖最直接的表現就是協程泄漏。
方案1的缺點也很明顯,如果死鎖的協程數量固定,或者產生死鎖協程的速度很慢,那么監控數據上很難發現問題。我們也不可能簡單地用服務沒響應了來判斷是不是出了死鎖,無響應的原因實在是太多了。
此外uber開發的用于檢測協程泄漏的庫goleak也可以幫上一些忙,不過缺點是一樣的。
第二種是用go trace或者調試器dlv看運行時的協程棧。如果棧里出現很多lock類函數或者chan收發函數,那么存在死鎖協程的概率是比較大的。最重要的是要看不同協程的調用棧里是否存在循環依賴或者交叉加鎖的情況。
方案2的缺點也很明顯,第一個是需要在程序運行時獲取調用棧信息,這會影響程序的性能,協程越多影響越明顯;第二是分析調用?,F在沒啥好的自動化工具往往得程序員自己上陣,如果協程數目巨大的話分析會變得極度困難。
而且調用棧里lock函數多不代表一定有死鎖,也可能只是鎖競爭激烈而已。
最后一種方案是借助go trace工具,trace里有個叫block profile的,這個可以統計哪些函數被阻塞住了。如果看到里面有大量的lock、select相關函數、chan相關操作函數,那么死鎖的可能性很大。
但和方案2一樣,方案3依然不能100%確定存在問題,還是要結合實際代碼做分析。而且trace只能分析某個時間段內的程序運行情況,如果你的程序死鎖問題是偶發的,那么很可能抓幾百次trace數據都不一定能抓到案發現場。
最后結論就是沒有銀彈。所以與其期待有個萬能檢測器不如寫代碼的時候就提前預防問題發生。
總結
我之所以寫這篇文章是因為很多golang的布道師居然會以golang有死鎖檢測所以能避免死鎖錯誤為賣點宣傳go語言,信以為真的go用戶也不少。稍加實驗就能證偽的說法如今依然大行其道,令人感嘆。
軟件行業是很難出現銀彈的,因此多動手檢驗才能少踩坑早下班。


浙公網安備 33010602011771號