程序員的踩坑經驗總結(四):死鎖


死鎖也是程序員最常見的問題之一了,但是死鎖跟內存泄露不同,原理和原因都相對簡單,簡單說就是你等我,我也等你,就這么耗着!

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

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

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

(一)鎖跨度很大,代碼的跨度,看上去兩個不怎么相關的類,竟然在互相調用!還帶着鎖。我印象中我們的流媒體出現過的一次死鎖,就是有兩個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.減少鎖的使用

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

一方面因為多了容易造成死鎖,另一方面鎖有一定的消耗。上面提到的源碼只是一個定義而已,而它的實現不僅僅有幾處循環,還有回調函數。

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

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

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM