1. 程式人生 > >程式設計師的踩坑經驗總結(四):死鎖

程式設計師的踩坑經驗總結(四):死鎖

死鎖也是程式設計師最常見的問題之一了,但是死鎖跟記憶體洩露不同,原理和原因都相對簡單,簡單說就是你等我,我也等你,就這麼耗著!

但死鎖的影響有時比記憶體洩露更嚴重。記憶體洩露主要是漸進式的,可能重啟一下就可以從頭開始了。而死鎖是重啟不了,這只是直接影響而已。死鎖一般會出現某個功能或者操作無反應,可能進一步沒有了心跳而下線,服務停止。而一般的看門狗也發現不了,程序還在。一般都需要手動殺程序。所以對於絕大多數的業務都是不可以接受的。

而造成死鎖的原因差別也比較大,有的可能只是程式設計師的一時疏忽,可有的也會讓你頭痛。

我們以前平臺的死鎖也是家常便飯,我記得的常見的有兩種情況。

(一)鎖跨度很大,程式碼的跨度,看上去兩個不怎麼相關的類,竟然在互相呼叫!還帶著鎖。我印象中我們的流媒體出現過的一次死鎖,就是有兩個TCP session各自的兩個函式在巢狀呼叫。

(二)一把鎖,涉及範圍很大,就是四五種情況,例如四五個函式,都用同一把鎖。雖然也在一個類裡面,但是類很長,帶有同一把鎖的函式之間就可能出現互相呼叫。

一看就知道都是設計的問題,不出問題才怪。可是問題要解決啊,針對這些問題,後面我琢磨出了一套方法。

 

案例分析

案例有點久遠,當時沒有留下文件,所幸程式碼還在,針對上面第二種情況的。所以只能是稍微描述下當時的情況和截圖看看最後是如何解決的。

首先我們看下這個類有多長:

    

有沒有傻眼。這又要勾起我多少痛苦的回憶。也好吧,讓你們開心一下。不過你們也開心不了多久,我都有解決之道:)

看看我留下的痕跡:

     

改動了31行,這還只是關於關鍵字的搜尋。有多少個函式,你猜,哈。我們主要看後面的註釋,有兩次提到“可能同時”呼叫或者進來。你也可以看到,我的解決方法是使用了位運算。

這一招又是從上一家學來的。其實現在看很多開源庫和核心都是大量使用了位運算,很多文件也提到了,像Redis、檔案系統、虛擬記憶體等。

我們再來看看定義:

    

老的鎖已經放註釋裡面的了,鎖的物件是一個連結串列list。新添加了一個整型變數,把變數的幾個值定義成一個列舉型別。

所以這幾個情況就代表了幾種功能,這裡是四種情況,可是實現類裡面卻有31處!你說能不死鎖嗎?

 

我們再次還原下當時的情景。

這個list是檔案列表,而它的業務無非是增刪改查。如果設計簡單的話,一把鎖也夠了。但是真正簡單設計有這麼容易嗎?

我們又回到這個類,第一個截圖顯示2500行,根據設計基本原則,一般一個類不能超過1000行。這裡早就可以劃分至少三個類了。

怎麼劃分,有人會建議把這個list單獨拿出去,是,我也想過。但是關係複雜了,所以我們又到了第二張圖,你看涉及到的函式只會有增刪改查嗎?

和其他的物件和方法交織在一起了!要想抽絲剝離,只能重構!事實上,後面都重構了。

但是問題要解決。重構是後面的事,一旦出現這種嚴重問題,當下就是解決問題。所以我後面去掉了鎖,重現定義了新的變數。具體怎麼弄? 

見最後這張圖,一個變數四個值,但是這四個值可不是連續的,看到了嗎,0、1、2、4,為什麼?

因為要實現二進位制運算,所以他們的的二進位制位對應就是,0000、0001、0010、0100。每個值用一位表示一種操作,互不干擾。該位為1表示佔用,如果是0表示未佔用。代表了以前的鎖狀態。

所以雖然鎖沒有了,但是(鎖的)功能還是有的。這是一個方面,不能影響原有的功能,原來的樣子(雖然不好看,但是不能再引發其他問題了)。另一方面,問題也要解決,仍然是利用了這幾個位!

上面的四個值,對應的不完全是增刪改查,具體對應了:初始化、查、刪、刪並且加四個狀態,但實際上操作是後三種。事實上初始化值0也可以說沒有佔位。

開始我們提到了每個位互不干擾,現在確定是三個位互不干擾。所以在進入某種操作時,首先判斷當前狀態,是可重入還是需要等待。

例如說,如果當前只是查,那麼繼續查(另一個查操作)肯定沒問題,而其他兩種需要稍微等一下,這裡的等待是20次sleep的20ms迴圈,只要查操作結束,馬上進入下一步。

但是如果迴圈已經完成,而狀態依然沒變化,那麼這裡不等待了,直接退出。下次再進來詢問。

所以這裡不同的操作對應了不同的方式,因情況而異。這樣就不會導致死鎖。同時,這些改變都需要加日誌跟蹤,可以發現等待了多久,哪個函式佔用時間太長,如果能減少該函式佔用的時間就是最好的了。在實際專案中,能優化的也有。但有的就只能驚訝了,有碰到過一個方法裡面有呼叫兩個while巢狀迴圈,簡單的計算也行了,有些迴圈裡面還呼叫多個方法。所以只能用這種方法了。

 

當然這個解決方法是有點抽象,所以為了說清楚這個方法,我想了很久,其他部分早寫完了,剩下這裡反覆改,希望你能看明白。

我後面再看分散式的鎖的實現,原理和複雜程度也不過如此:),因為我們這些程式碼早就把我給臣服了:(

 

總結和建議

(一)原理與依據

我們上面提到了解決方法,那麼它的理論依據是什麼?

我們稍微窺視一下鎖的實現。linux 2.6 kernel的原始碼,互斥鎖所使用的資料結構:

這裡只是列出了核心中,鎖的定義,其實它的實現還有很多。有興趣的可以看原始碼。我們回到這個主題,不知大家發現沒有,其實鎖的本質也是一個整型變數。

而我就是利用了這個特性,當然也有一點自旋鎖的特性。你可以再往會看,第二張圖,其中有三處for迴圈,就是說我會根據情況進行判斷和等待一會,但不是忙等待,就是說到了一定的時間後,我會強制改變狀態和退出。所以和自旋鎖又有不同。

所以總結一下,原理很重要!

(二)死鎖的預防

和記憶體洩露一樣,死鎖的預防也在於設計。所以程式碼的質量在於設計!這裡同樣只針對死鎖的問題提幾個建議。

1.減少鎖定程式碼的範圍

鎖定的程式碼行數,一定用到的時候才用,只將相關的變數括起來。而不是鎖定整個函式。

寫段虛擬碼說明下。

std::mutex  m_mutex;

int  g_diff = 3;

int funA()

{

unique_lock<mutex> lock(m_mutex);

int a = 5;

//中間省去若干

return a+g_diff; 

} 

int funB()

{

int a = 5;

int b = 0;

  {

    unique_lock<mutex> lock(m_mutex);

    b = a+g_diff; 

  }

//中間省去若干

return b;

} 

函式funB肯定比函式funA更好。

2.降低鎖的粒度

通常,一個變數一把鎖,或者一個功能點一把鎖,而不是一個類一把鎖。

那有的人會說如果要鎖住一個類,怎麼辦?

我見過的只有在一種情況下一個類才需要用到鎖,就是把這個類當變數使用。所以這種情況也可以歸納到一個變數,或者說一個物件。而這種情況一般用在單例模式中,所以即使鎖住也不可能出現方法的巢狀而導致死鎖。如果你還沒明白過來,我建議你看下單例模式的定義,我後面也還會有文章將會介紹。很快,後面第二篇吧。

而且這裡說的一個變數,或者一個功能點要職責單一。一個類何嘗不是如此!

案例裡面其實就是函式的功能模糊,類的職責模糊,估計當時都沒有設計,反正把相關的都放一起,一鍋亂燉!

所以這是設計和開發裡面的大忌!後面就是改不完的Bug、踩不完的坑。。

3.減少鎖的使用

儘量不用鎖、少用鎖。非用不可才用鎖。

一方面因為多了容易造成死鎖,另一方面鎖有一定的消耗。上面提到的原始碼只是一個定義而已,而它的實現不僅僅有幾處迴圈,還有回撥函式。

當然,這一點說起來容易,做起來難!具體怎麼少用,有沒有好的方法?

我的回答當然是有,請聽下回分解。

&n