讀寫鎖的死鎖問題該如何預測?滴滴高級專家工程師這樣解決

本文作者:杜雨陽
滴滴 | 高級專家工程師-Linux內核

導讀:死鎖是多線程和分佈式程序中常見的一種嚴重問題。死鎖是毀滅性的,一旦發生,系統很難或者幾乎不可能恢復;死鎖是隨機的,只有滿足特定條件纔會發生,而如果條件複雜,雖然發生概率很低,但是一旦發生就非常難重現和調試。使用鎖而產生的死鎖是死鎖中的一種常見情況。Linux 內核使用 Lockdep 工具來檢測和特別是預測鎖的死鎖場景。然而,目前 Lockdep 只支持處理互斥鎖,不支持更爲複雜的讀寫鎖,尤其是遞歸讀鎖(Recursive-read lock)。因此,Lockdep 既會出現由讀寫鎖引起的假陽性預測錯誤,也會出現假陰性預測錯誤。本工作首先解密 Lockdep工具,然後提出一種通用的鎖的死鎖預測算法設計和實現(互斥鎖可以看做只使用讀寫鎖中的寫鎖),同時證明該算法是正確和全面的解決方案。

今年初,我們相繼解決了對滴滴基礎平臺大規模服務器集羣影響嚴重的三個內核故障,在我們解決這些問題的時候,很多時間和精力都花在去尋找是誰在哪裏構成了死鎖,延誤了故障排除時間,因此當時就想有沒有什麼通用的方法能夠幫助我們對付死鎖問題。但是因爲時間緊迫,只能針對性地探索和處理這幾個具體問題。在最終成功修復了這幾個內核故障後,終於有一些時間靜下來去深入思考死鎖發生的原因和如何去檢測和預測死鎖。隨着對這個問題的深入研究,我相繼做出了一些內核死鎖預測方面的算法優化和算法設計工作,其中部分已經被 Linux 內核接收,其他還在評審階段。在這裏我和大家分享其中的一個比較重要的工作:一個通用的讀寫鎖的死鎖預測算法。這個工作提出了一個通用的鎖的死鎖預測算法,支持所有 Linux 內核讀寫鎖,同時證明該算法是正確和全面的解決方案。這個算法所解決的核心問題已經存在超過10年以上(目前還在社區評審階段)。在介紹這個工作的之前我首先對死鎖問題和 Linux 內核死鎖工具 Lockdep 做簡要的介紹。

1.死鎖(Deadlock)

死鎖在日常生活中並不鮮見。生活在大城市的人都或多或少經歷過下圖所示的場景。在環島或者十字路口出現的這種情況就是死鎖。也許其中有車壞了,但是絕大多數車子是可以運行的。可是因爲每輛車都得等着前車走動它才能走動,所有車都走不動,或者更一般地講它們不能取得進展(Make Forward Progress)。這種情況發生的原因是車輛的等待構成了循環,在這個循環中每輛車的狀態都是等待前車,因此所有車都等不到到它所要等待的。這種車輛死鎖狀態會持續惡化併產生嚴重的後果:首先造成路口交通堵塞,而堵塞如果進一步擴大會導致大面積交通癱瘓。車輛死鎖很難自愈,通過自身走出死鎖狀態非常困難或者需要很長時間,一般都只能通過人工(如交通警察)干預才能解決。

file
圖1:環島道路的交通堵塞(圖片來源於網絡)

在多線程或者分佈式系統程序中,死鎖也會發生。其本質和上述的路口車輛堵塞是一樣的,都是因爲參與者構成了循環等待,使得所有參與者都等不到想要的結果,從而永遠等在那裏不能取得進展。Linux 內核當然也會發生死鎖,如果核心部分(Core),如調度器和內存管理,或者子系統,如文件系統,發生死鎖,都會導致整個系統不可用。

死鎖是隨機發生的。就像上圖中環島的情況一樣,環島就在那裏而死鎖並不是總在發生。但是環島本身就是死鎖隱患,尤其在交通壓力比較大的路口,環島會比較容易產生死鎖。而如果這種路口設計成交通信號燈就會好很多,如果設計成立交橋則又會好很多。在程序中,我們把可能產生死鎖的場景稱作潛在死鎖(Potential Deadlock Scenario),而把即將發生或正在發生的死鎖稱爲死鎖實例(Concrete Deadlock)。

如何對付死鎖一直是學術界和應用領域積極研究和解決的問題。我們可以將對死鎖的解決方案粗略地分爲:死鎖發現(Detection)、死鎖避免(Prevention)和死鎖預測(Prediction)。死鎖發現是指在在程序運行中發現死鎖實例;死鎖避免則是在發現死鎖實例即將生成時進一步防止這個實例;而死鎖預測則是通過靜態或者動態方法找出程序中的潛在死鎖,從而從根本上預先消除死鎖隱患。

2.鎖的死鎖和 Lockdep

在死鎖中,因爲用鎖(Lock)不當而導致的死鎖是一個重要死鎖來源。鎖是同步的一種主要手段,用鎖是不可避免的。對於複雜的同步關係,鎖的使用會比較複雜。如果使用不當很容易造成鎖的死鎖。從等待的角度來說,鎖的死鎖是由於參與線程等待鎖的釋放,而這種等待構成了等待循環,如 ABBA 死鎖:

file
圖2:兩線程ABBA死鎖

其中,線程中的黑色箭頭代表線程當前執行語句,紅色箭頭表示線程語句之間的等待關係。可以看到,紅色箭頭構成了一個圓圈(或者循環)。再一次回顧潛在死鎖和死鎖實例,如果這兩個線程執行的時間稍有改變,那麼很有可能不會發生死鎖實例,比如如果讓 Thread1 執行完這一段代碼 Thread2 纔開始執行。但是這樣的用鎖行爲(Locking Behavior)毫無疑問是一個潛在死鎖。

進一步可以看出,如果我們能夠追蹤並分析程序的用鎖行爲就有可能預測死鎖或者找出潛在死鎖,而不是等死鎖發生時才能檢測出死鎖實例。Linux 內核的 Lockdep 工具就是去刻畫內核的用鎖行爲進而預測潛在死鎖並報告出來。

Lockdep 能夠刻畫出一類鎖(Lock Class)的行爲,主要是通過記錄一類鎖中所有鎖實例的加鎖順序(Locking Order),即如果一個線程拿着鎖A,在沒有釋放前又去拿鎖B,那麼鎖A和鎖B就有一個 A->B 的加鎖順序,在 Lockdep 中這個加鎖順序被稱爲:鎖依賴 (Lock Dependency)。同樣的,對於 ABBA 類型的死鎖,我們並不需要 Thread1 和 Thread2 恰好產生一個死鎖實例,只要有線程產生了 A->B 加鎖順序行爲,又有線程產生了一個 B->A 的加鎖順序行爲,那麼這就構成了一個潛在死鎖,如下圖所示:

file
圖3:線程ABBA加鎖順序

由此推廣開來,我們可以把所有的加鎖順序(即鎖依賴)記錄和保存下來,構成一個加鎖順序圖(Graph)。其中,如果有鎖依賴 A->B ,又有鎖依賴 B->C ,那麼由於鎖依賴的關係(Relation)是傳遞的(Transitive),因此我們還可以得到鎖依賴 A->C 。 A->B 和 B->C 稱爲直接依賴(Direct Dependency),而 A->C 稱爲間接依賴(Indirect Dependency)。對於每一個新的直接鎖依賴,我們去檢查這個依賴是否和圖中已經存在的鎖依賴構成一個循環,如果是的話,那麼我們就可以預測產生了一個潛在死鎖。

3.讀寫鎖(Read-write Lock)

剛纔我們所指的鎖都是互斥鎖(Exclusive Lock)。讀寫鎖是一種更復雜的鎖,或者說一種通用的鎖(General Lock),我們可以認爲互斥鎖是隻用寫鎖的讀寫鎖。只要沒有寫鎖或者寫鎖的爭搶,讀鎖允許讀者(Reader)同時持有。 Linux 內核中有多種讀寫鎖,主要包括: rwsem 、 rwlock 和 qrwlock 等。問題是,讀寫鎖會讓死鎖預測變得異常複雜, Lockdep 就不能支持這幾種讀寫鎖,因此 Lockdep 在使用過程中會產生一些相關的錯誤假陽性(False Positive)死鎖預測和錯誤假陰性(False Negative)死鎖預測。這個問題已經存在超過10年以上,我們提出一個通用的鎖的死鎖預測算法,並證明這個算法解決了讀寫鎖的死鎖預測問題。

4.通用鎖的死鎖預測算法(General Deadlock Prediction For Locks)

在描述這個算法的過程中,我們通過提出幾個引理(Lemma)來解釋或者證明我們所提出的死鎖預測的有效性。

▍引理1:在引入了讀寫鎖後,鎖的加鎖順序循環是潛在死鎖的必要條件,但不是充分條件。並且,一個潛在死鎖能且只能最早在最後一個加鎖順序(或鎖依賴)即將生成死鎖循環的時候被預測出來。

基於引理1,解決死鎖預測問題就是在最後一個拿鎖順序(即鎖依賴)形成等待圓環(循環)時,通過某種方法計算出這個等待圓環是否構成潛在死鎖,而我們的任務就是找到這個方法(算法)。

▍引理2:兩個虛擬線程 T1 和 T2 可以用來表示所有的死鎖場景。

對於任何一個死鎖實例來說,假定有 n 個線程參與到這個死鎖實例中,這 n 個線程表示爲:

T1,T2,…,Tn

考慮 n 的情況:

如果 n=1:這種死鎖即線程自己等待自己,在 Lockdep 中被稱爲遞歸死鎖(Recursion Deadlock)。由於檢查這種死鎖較爲簡單,因此在下面的算法中忽略這種特殊情況。
如果 n>1:這種死鎖在 Lockdep 中被稱爲翻轉死鎖(Inversion Deadlock)。對於這種情況,我們將這 n 個線程分成兩組,即 T1,T2,…,Tn-1 和 Tn ,然後把前一組中的所有鎖依賴合併在一起並假想所有這些依賴存在於一個虛擬的線程中,於是得到兩個虛擬線程 T1 和 T2 。

這就是引理2中所述的兩個虛擬線程。基於引理2,我們提出一個死鎖檢查雙線程模型(Two-Thread Model)來表示內核的加鎖行爲:

T1 :當前檢查鎖依賴之前的所有鎖依賴,這些依賴形成了一個鎖依賴圖。
T2 :當前的待檢查的直接鎖依賴。

基於引理2和死鎖檢查雙線程模型,我們可以得到如下引理:

▍引理3:任何死鎖都可以轉化成 ABBA 類型。

基於上述3個引理,我們可以進一步將死鎖預測問題描述爲,當我們得到一個新的直接鎖依賴 B->A 時,我們將這個新依賴設想爲 T2 ,而之前的所有鎖依賴都存在於一個設想的 T1 產生的一個鎖依賴圖中,於是死鎖預測就是檢查 T1 中是否存在 A->B 的鎖依賴,如果存在即存在死鎖,否則就沒有死鎖並將 T2 合併到 T1 中。如下圖所示:

file
圖4:T1的鎖依賴圖和T2的直接鎖依賴

在引入了讀寫鎖之後,鎖依賴還取決於其中鎖的類型,即讀或者寫類型。我們根據 Linux 內核中互斥鎖和讀寫鎖的設計特性,引入一個鎖互斥表來表示鎖之間的互斥關係:

file
表1:讀寫鎖互斥關係表

其中,遞歸讀鎖(Recursive-read Lock)是一種特殊的讀鎖,它能夠被同一個線程遞歸地拿。下面我們首先提出一個簡單算法(Simple Algorithm)。基於雙線程模型,給定 T1 和 T2 ,和 ABBA 鎖:

file
圖5:基於雙線程模型的簡單算法

簡單算法的步驟如下:

如果 X1.A 和 X1.B 是互斥的且 X2.A 和 X2.B 是互斥的,那麼 T1 和 T2 構成潛在死鎖。

否則, T1 和 T2 不構成潛在死鎖。

從簡單算法中可以看出,鎖類型決定了鎖之間的互斥關係,而互斥關係是檢查死鎖的關鍵信息。對於讀寫鎖來說,鎖類型可能在程序執行過程中變化,那麼如何記錄所有的鎖類型呢?我們基於鎖類型的互斥性,即鎖類型的互斥性由低到高:遞歸讀鎖 < 讀鎖 < 寫鎖(互斥鎖),提出了鎖類型的升級(Lock Type Promotion)。在程序執行過程中,如果碰到了高互斥性的鎖類型,那麼我們將鎖依賴中的鎖類型升級到高互斥性的鎖依賴。鎖類型升級如圖所示:

file
圖6:鎖類型的升級

其中 RRn 表示遞歸讀鎖n(Recursive-read Lock n) ,Rn表示讀鎖n(Read Lock n),Wn代表寫鎖或者互斥鎖n(Write Lock n)。下面 Xn 則表示任意鎖n (即遞歸讀、讀或者寫鎖)。

但是,如上簡單算法並不能處理所有的死鎖預測情況,比如下面這個案例就會躲過簡單算法,但事實上它是一個潛在死鎖:

file
圖7:簡單算法失敗案例

在這個案例中, X1 和 X3 是互斥的從而這個案例構成了潛在死鎖。但是簡單算法在檢查 RR2->X1 時(即 T2 爲 RR2->X1 ),根據簡單算法能夠找到 T1 中有 X1->RR2 ,但是由於 RR 和 RR 不具有互斥性,因而錯誤認定這個案例不是死鎖。分析這個案例爲什麼得出錯誤結論,是因爲真正的死鎖 X1X3X3X1 中的 X3->X1 是間接鎖依賴,而間接依賴被簡單算法漏掉了。

這個問題的更深層次原因是因爲互斥鎖之間只有互斥性,因此只要有 ABBA 就是潛在死鎖,並不需要檢查 T2 的間接鎖依賴。而在有讀鎖的情況下,這一條件不復存在,因此就要去考慮 T2 中的間接鎖依賴。

▍引理4:對於直接鎖依賴引入的間接鎖依賴,如果間接鎖依賴構成死鎖,那麼直接鎖依賴仍然是關鍵的。

引理4是引理1的引申,根據引理1,這個直接鎖依賴一定是形成鎖循環的那個最後鎖依賴,而引理4說明通過這個鎖依賴一定可以通過某種方法判斷出鎖循環是否是潛在死鎖。換句話說,通過修改和加強之前提出的簡單算法,新的算法一定能夠解決這個問題。但是問題是,原先 T2 中直接鎖依賴可能進一步生成了很多間接鎖依賴,我們如何才能找到那個最終產生潛在死鎖的間接鎖依賴呢?更進一步,我們首先需要重新定義 T2 ,再在這個 T2 中找出所有的間接鎖依賴,那麼 T2 的邊界是什麼?如果把 T2 擴展到整個鎖依賴圖,那麼算法複雜度會提高非常多,甚至可能超出 Lockdep 的處理能力,讓 Lockdep 變得實際上不可用。

▍引理5:T2 只需要擴展到當前線程的拿鎖棧(Lock Stack)。

根據引理5,我們首先修改之前提出的雙線程模型爲:

T1:當前檢查直接鎖依賴之前的所有鎖依賴,這些依賴形成了一個圖。
T2:當前的待檢查的線程的鎖棧。

根據引理5和新的雙線程模型,我們在簡單算法的基礎上提出如下最終算法(Final Algorithm):

繼續搜索鎖依賴圖即 T1 尋找一個新的鎖依賴循環。
在這個新的循環中,如果有 T2 中的其他鎖存在,那麼這個鎖和 T2 中的直接鎖依賴構成一個間接鎖依賴,檢查這個間接鎖依賴是否構成潛在死鎖。
如果找到潛在死鎖,那麼算法結束,如果沒有到算法轉到1直到搜索完整個鎖依賴圖爲止。

這個最終算法能解決之前出現漏洞的案例嗎?答案是可以的,具體檢查過程如圖所示:

file
圖8:簡單算法的失敗案例解決過程

然而,對於所有其他情況,引理5是正確的嗎?爲什麼最終算法能夠工作呢?我們通過如下兩個引理來證明最終算法中的間接鎖依賴是必要且充分的。

▍引理6:檢查 T2 當中的間接鎖依賴是必要的,否則簡單算法已經解決了所有問題。

引理6說明由於讀寫鎖的存在,不能只檢查直接鎖依賴。

▍引理7:T2 的邊界就是當前線程的鎖棧,這是充分的。

根據引理2和引理3,任何死鎖都可以轉化成雙線程 ABBA 死鎖,並且 T1 只能貢獻 AB,T2 必須貢獻 BA 。在這裏,T2 不僅僅是一個虛擬線程,也是一個實際存在的物理線程,因此 T2 需要且只需要檢查當前線程。

到這裏,一個通用的讀寫鎖死鎖預測算法就描述並非正式證明完畢。這個算法已經實現在 Lockdep 中並提交給 Linux 內存社區去審閱(當前最新版本見https://lkml.org/lkml/2019/8/...)。鑑於相關性和篇幅所限,算法當中的一些關鍵細節並沒有全部展現在這裏,有興趣的讀者可以去上面的鏈接查找,同時歡迎提出評審意見和建議。

回顧從最初處理滴滴基礎平臺大集羣集中爆發的幾個嚴重系統故障,到學習研究內核死鎖預測工具,再到設計和實現新的通用的讀寫鎖死鎖預測算法。其中充滿了不確定性甚至戲劇性,但整個過程以及最後的結果都讓我收穫滿滿。我想,這個經歷正像電影《阿甘正傳》裏的阿甘跑步一樣:跑到了一個目的地,就想再多跑一點,到了下一個目的地,又去設定一個新的更遠的目標。我也想,普通的工作和世界級的工作的區別並不在於起點,而在於終點,在於是否多跑了幾個更遠的目標吧。

同時也歡迎大家關注滴滴技術公衆號,我們會及時發佈最新的開源信息和技術資訊!

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