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

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

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

今年初,咱們相繼解決了對滴滴基礎平臺大規模服務器集羣影響嚴重的三個內核故障,在咱們解決這些問題的時候,不少時間和精力都花在去尋找是誰在哪裏構成了死鎖,延誤了故障排除時間,所以當時就想有沒有什麼通用的方法可以幫助咱們對付死鎖問題。可是由於時間緊迫,只能針對性地探索和處理這幾個具體問題。在最終成功修復了這幾個內核故障後,終於有一些時間靜下來去深刻思考死鎖發生的緣由和如何去檢測和預測死鎖。隨着對這個問題的深刻研究,我相繼作出了一些內核死鎖預測方面的算法優化和算法設計工做,其中部分已經被 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 工具就是去刻畫內核的用鎖行爲進而預測潛在死鎖並報告出來。spa

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/29/167)。鑑於相關性和篇幅所限,算法當中的一些關鍵細節並無所有展示在這裏,有興趣的讀者能夠去上面的連接查找,同時歡迎提出評審意見和建議。

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

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

相關文章
相關標籤/搜索