死鎖也是程序員最常見的問題之一了,但是死鎖跟內存泄露不同,原理和原因都相對簡單,簡單說就是你等我,我也等你,就這么耗着!
但死鎖的影響有時比內存泄露更嚴重。內存泄露主要是漸進式的,可能重啟一下就可以從頭開始了。而死鎖是重啟不了,這只是直接影響而已。死鎖一般會出現某個功能或者操作無反應,可能進一步沒有了心跳而下線,服務停止。而一般的看門狗也發現不了,進程還在。一般都需要手動殺進程。所以對於絕大多數的業務都是不可以接受的。
而造成死鎖的原因差別也比較大,有的可能只是程序員的一時疏忽,可有的也會讓你頭痛。
我們以前平台的死鎖也是家常便飯,我記得的常見的有兩種情況。
(一)鎖跨度很大,代碼的跨度,看上去兩個不怎么相關的類,竟然在互相調用!還帶着鎖。我印象中我們的流媒體出現過的一次死鎖,就是有兩個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.減少鎖的使用
盡量不用鎖、少用鎖。非用不可才用鎖。
一方面因為多了容易造成死鎖,另一方面鎖有一定的消耗。上面提到的源碼只是一個定義而已,而它的實現不僅僅有幾處循環,還有回調函數。
當然,這一點說起來容易,做起來難!具體怎么少用,有沒有好的方法?
我的回答當然是有,請聽下回分解。