Go?Singleflight導致死鎖問題解決分析
Dump 堆棧很重要
線上某個環(huán)境發(fā)現(xiàn) S3 上傳請求卡住,請求不返回,卡了30分鐘,長時間沒有發(fā)現(xiàn)有效日志。一般來講,死鎖問題還是好排查的,因為現(xiàn)場一般都在。類似于 c 程序,遇到死鎖問題都會用 pstack 看一把。golang 死鎖排查思路也類似(golang 不適合使用 pstack,因為 golang 調(diào)度的是協(xié)程,pstack 只能看到線程棧),我們其實是需要知道 S3 程序里 goroutine 的棧狀態(tài)。golang 遇到這個問題我們有兩個辦法:
- 方法一:條件允許的話,gcore 出一個堆棧,這個是最有效的方法,因為是把整個 golang 程序的內(nèi)存鏡像 dump 出來,然后用 dlv 分析;
- 方法二:如果你提前開啟 net/pprof 庫的引用,開啟了 debug 接口,那么就可以調(diào)用 curl 接口,通過 http 接口獲取進程的狀態(tài)信息;
需要注意到,golang 程序和 c 程序還是有點區(qū)別,goroutine 非常多,成百上千個 goroutine 是常態(tài),甚至上萬個也不稀奇。所以我們一般無法在終端上直接看完所有的棧,一般都是把所有的 goroutine 棧 dump 到文件,然用 vi 打開慢慢分析。
調(diào)試這個 core 文件,意圖從堆棧里找到些東西,由于堆棧太多了,所以就使用 gorouties -t -u
這個命令,并且把輸出 dump 到文件;
curl xxx/debug/pprof/goroutine
關鍵思路
成千上萬個 goroutine ,直接顯示到終端是不合適的,我們 dump 到文件 test.txt,然后分析 test.txt 這個文件。
去查找發(fā)現(xiàn)了一些可疑堆棧,那么什么是可疑堆棧?重點關注加鎖等待的堆棧,關鍵字是 runtime_notifyListWait
、semaphore
、sync.(*Cond).Wait
、Acquire
這些阻塞場景才會用到的,如果業(yè)務堆棧上出現(xiàn)這個加鎖調(diào)用,就非常可疑。
劃重點:
- 留意阻塞關鍵字
runtime_notifyListWait
、semaphore
、sync.(*Cond).Wait
、Acquire
; - 業(yè)務堆棧(非 runtime 的一些內(nèi)部堆棧)
統(tǒng)計分析發(fā)現(xiàn),有 11 個這個堆棧都在這同一個地方,都是在等同一把鎖 blockingKeyCountLimit.lock
,所以基本確認了阻塞的位置,就是這個地方阻塞到了所有的請求,但是這把鎖我們使用 defer 釋放的,使用姿勢如下:
// do someting lock.Acquire(key) defer lock.Release(key) // 以下為鎖內(nèi)操作;
blockingKeyCountLimit 是我們封裝針對 key 操作流控的組件。舉個例子,如果 limit == 1,key為 "test" 在 g1 上 Acquire 成功,g2 acquire("test") 就會等待,這個可以算是我們優(yōu)化的一個邏輯。如果 limit == 2,那么就允許兩個人加鎖到,后面的人都等待。
從代碼來看,函數(shù)退出一定會釋放的,但是偏偏現(xiàn)在鎖就卡在這個地方,所以就非常奇怪。我們先找哪個 goroutine 占著這把鎖不釋放,看看能不能搞清楚怎樣導致這里搶不到鎖的原因。
通過審查業(yè)務代碼分析,發(fā)現(xiàn)可能的源頭函數(shù)(這個函數(shù)是向后端請求的函數(shù)):
api.(*Client).getBytesNolc
確認是 getBytesNolc
這個函數(shù)執(zhí)行的操作,那么大概率就是卡在這個地方了。用這個 getBytesNolc
字符串搜索堆棧,找下是哪個堆棧 ?搜索到這個堆棧 goroutine 19458
大概率就是第 1 個堆棧了,也就是其他的 11 個 goroutine 都在等這 goroutine 19458
來放鎖,仔細看這個堆棧。那么為啥這個堆棧不放鎖呢?這里有個細節(jié)要注意下,這里是卡到 gihub.com/golang/groupcache/singleflight/singleflight.go:48
這一行:
singleflight 實現(xiàn)了緩存防擊穿的功能
終于找到你
這是一個開源庫,singleflight 實現(xiàn)了緩存防擊穿的功能。
簡單介紹下 singleflight 的功能,這是一個非常有效的工具。在緩存大量失效的場景,如果針對同一個 key ,其實只需要有一個人穿透到后端請求數(shù)據(jù),其他人等待他完成,然后取緩存結果即可。這個就是 singleflight 實現(xiàn)的功能。具體實現(xiàn)就是:來了請求之后,把 key 插入到 map 里,后面的請求如果發(fā)現(xiàn)同名 key 在 map 里面,那么就等待它完成就好;
截屏顯示卡到 c.wg.Wait()
這一行,那么說明 map 里面肯定有已經(jīng)存在的 key,說明 goroutine 19458
不是第一個人?但是外面還有一個 blockingKeyCountLimit
的互斥呢,按道理其他的人也進不來(因為 limit == 1),這里這么講來肯定要是源頭才對?
思路整理
偽代碼顯示如下:
func xxx () { // 大部分協(xié)程都卡在這里(11個) // 這個鎖的效果主要是流控,limit 值初始化賦值,可以是 1,也可以是其他; // locker 為 blockingKeyCountLimit 類型 limitLocker.Acquire( key ) defer limitLocker.Release( key ) // 獲取數(shù)據(jù) getBytesNolc( key , ...) } func getBytesNolc () { // ... // 下面就是 singleflight.Group 的用法,防穿透 // 同一時間只允許一個人去后端更新 ret, err = x.Group.Do(id, func() (interface{}, error) { // 去服務后臺獲取,更新數(shù)據(jù); }) // ... }
圖示顯示當前的現(xiàn)狀:
現(xiàn)狀小結:
- 大量的協(xié)程都在等
blockingKeyCountLimit
這把鎖釋放; - 協(xié)程
goroutine 19458
持有blockingKeyCountLimit
這把鎖; - 協(xié)程
goroutine 19458
卻在等一個相同 key 名字的任務的完成( singleflight 一個防擊穿的庫,同一時間相同 key 只允許放到一個后端去執(zhí)行),卻永遠沒等到,協(xié)程因此呈現(xiàn)死鎖;
當前的疑問就是第一個 key 的任務為啥永遠完不成,堆棧也找不到了,去哪里了?
發(fā)現(xiàn)蛛絲馬跡
我們再仔細審一下 singleflight 的代碼:
func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) { g.mu.Lock() if g.m == nil { g.m = make(map[string]*call) } // 如果找到同名 key 已經(jīng)存在; if c, ok := g.m[key]; ok { g.mu.Unlock() // 等待者走到這個分支:等待第一個人執(zhí)行完成,最后直接返回它的結果就行了; c.wg.Wait() return c.val, c.err } // 如果同名 key 不存在(第一個人走到這個分支) c := new(call) c.wg.Add(1) // map 里放置 key g.m[key] = c g.mu.Unlock() // 執(zhí)行任務 c.val, c.err = fn() // 喚醒所有的等待者 c.wg.Done() g.mu.Lock() // 刪除 map 里的 key delete(g.m, key) g.mu.Unlock() return c.val, c.err }
發(fā)現(xiàn)有個線索,我們的 S3 服務程序一個 http 請求對應一個協(xié)程處理,為了提高服務端進程的可用性,在框架里會捕捉 panic,這樣確保單個協(xié)程處理不會影響到其他的請求?;谶@個前提,我們假設:如果 fn()
執(zhí)行異常,panic 掉了,那么就不會走 delete(g.m, key)
的代碼,那么 key 就永遠都殘留在 map 里面,而進程卻又還活著?;腥淮笪?。
完整的推理流程
第一個協(xié)程 g1 來了,加了
blockingKeyCountLimit
鎖,然后準備穿透到后端,調(diào)用函數(shù)getBytesNolc
獲取數(shù)據(jù),并走進了singlelight
,添加了一個 key:x, 準備干活;干活發(fā)生了一些不可預期的異常(后面發(fā)現(xiàn)是配置的異常),nil 指針引用之類的, panic 堆棧了,panic 導致后面 delete key 操作沒有執(zhí)行;
雖然 g1 現(xiàn)在 panic 了,但是由于在函數(shù) func xxx 里面
blockingKeyCountLimit
是 defer 執(zhí)行的,所以這把鎖還是,但是singlelight
的 key 還存在,于是殘留在 map 里面;但是由于我們服務程序為了高可用是 recover 了 panic 的,單個請求的失敗不會導致整個進程掛掉,所以進程還是好好的;
第二個
goroutine 19458
協(xié)程來了,blockingKeyCountLimit
加鎖,然后走到singlelight
的時候,發(fā)現(xiàn)有 key: x 了,于是就等待;并且等待的是一個永遠得不到的鎖,因為 g1 早就沒了;
后續(xù)的 11 個 協(xié)程來了,于是被
blockingKeyCountLimit
阻塞住,并且永遠不能釋放;
實錘:后續(xù)基于這個猜想,再去搜索一遍日志,發(fā)現(xiàn)確實是有一條 panic 相關的日志。這個時間點后面的請求全部被卡住。
思考總結
一般來講 c 語言寫程序容易出現(xiàn)死鎖問題,因為各種異常邏輯可能會導致忘記放鎖,從而導致?lián)屢粋€永遠都不可能得到的鎖。golang 為了解決這個問題,一般是用 defer 機制來實現(xiàn),使用姿勢如下:
func test () { mtx.Lock() defer mtx.Unlock() /* 臨界區(qū) */ }
golang 的 defer 機制是一個經(jīng)過經(jīng)驗沉淀下來的有效功能。我們必須要合理使用。defer 實現(xiàn)原理是和所在函數(shù)綁定,保證函數(shù) return 的時候一定能調(diào)用到( panic 退出也能),所以 golang 加鎖放鎖的有效實踐是寫在相鄰的兩行。
其實思考下,singleflight 作為一個通用開源庫,其實可以把 delete map key 放到 defer 里,這樣就能保證 map 里面的 key 一定是可以被清理的。
還有一點,其實 golang 是不提倡異常-捕捉這樣的方式編程,panic 一般不讓隨便用,如果真是嚴重的問題,掛掉就掛掉,這個估計還好一些。當然這是要看場景的,還是有一些特殊場景的,畢竟 golang 都已經(jīng)提供了 panic-recover 這樣的一個手段,就說明還是有需求。這個就跟 unsafe 庫一樣,你只有明確知道自己的行為影響,才去使用這個工具,否則別用。
以上就是Go Singleflight導致死鎖問題解決分析的詳細內(nèi)容,更多關于Go Singleflight死鎖分析的資料請關注腳本之家其它相關文章!
相關文章
Go?不支持?[]T轉(zhuǎn)換為[]interface類型詳解
這篇文章主要為大家介紹了Go不支持[]T轉(zhuǎn)換為[]interface類型詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-01-01Go語言動態(tài)并發(fā)控制sync.WaitGroup的靈活運用示例詳解
本文將講解 sync.WaitGroup 的使用方法、原理以及在實際項目中的應用場景,用清晰的代碼示例和詳細的注釋,助力讀者掌握并發(fā)編程中等待組的使用技巧2023-11-11