記一次CachedStorage中死鎖的調試經歷

 

在整合FISCO BCOS非國密單測與國密單測的工作中,我們發現CachedStorage的單測偶然會陷入卡死的狀態,且可在本地持續復現。

復現方式爲循環執行CachedStorage單測200次左右,便會發生一次所有線程均陷入等待狀態、單測無法繼續執行的情況,我們懷疑在CachedStroage中發生了死鎖,故對此進行調試。

 

Debug思路

中醫治病講究望聞問切,調試bug同樣需要遵循尋找線索、合理推斷、驗證解決的思路。

 

觀察線程棧

在死鎖發生時,使用/usr/bin/sample工具(mac平臺環境下)將所有的線程的棧打印出來,觀察各線程的工作狀態。

從所有線程的線程棧中觀察到有一個線程(此處稱爲T1)卡在CachedStorage.cpp的第698行的touchCache函數中,具體的代碼實現可以參考:

https://github.com/FISCO-BCOS/FISCO-BCOS/blob/release-2.3.0-bsn/libstorage/CachedStorage.cpp

 


 

從代碼片段中可以看到,T1在第691行已經獲得了m_cachesMutex的讀鎖:代碼RWMutexScoped(some_rw_mutex, false)的意思是獲取某個讀寫鎖的讀鎖;相應地,代碼RWMutexScoped(some_rw_mutex, true)的意思是獲取某個讀寫鎖的寫鎖,這裏的RWMutex是一個Spin Lock。

隨後在第698行處嘗試獲取某個cache的寫鎖。

除了T1,還有另外一個線程(此處稱爲T2)卡在CachedStorage.cpp的第691行的touchCache函數中:

 

從代碼片段中可以看到,T2在第681行已經獲得了某個cache的寫鎖,隨後在第691行處嘗試獲取m_cachesMutex的讀鎖。

繼續觀察後還發現若干線程卡在CachedStorage.cpp第673行的touchCache函數中:

 

 

最後還有一個Cache清理線程(此處稱爲T3)卡在CachedStorage.cpp的第734行的removeCache函數中:

 


 

從代碼片段中可以看到,這些線程均沒有持任何鎖資源,只是在單純地嘗試獲取m_cachesMutex的寫鎖。

 

讀寫飢餓問題

初期分析問題時,最詭譎的莫過於:在T1已經獲取到m_cachesMutex讀鎖的情況下,其他同樣試圖獲取m_cachesMutex讀鎖的線程竟然無法獲取到。

但是看到T3線程此時正努力嘗試獲取m_cachesMutex寫鎖,聯想到讀寫鎖飢餓問題,我們認爲其他線程獲取不到讀鎖的問題根源很可能就在T3。

所謂讀寫鎖飢餓問題是指,在多線程共用一個讀寫鎖的環境中,如果設定只要有讀線程獲取讀鎖,後續想獲取讀鎖的讀線程都能共享此讀鎖,則可能導致想獲取寫鎖的寫線程永遠無法獲得執行機會(因爲讀寫鎖一直被其他讀線程搶佔)。

爲了解決飢餓問題,部分讀寫鎖會在某些情況下提高寫線程的優先級,即由寫線程先佔用寫鎖,而其他讀線程只能在寫線程後乖乖排隊直到寫線程將讀寫鎖釋放出來。

在上述問題中, T1已經獲取了m_cachesMutex的讀鎖,若此時T3恰好獲得時間片並執行到CachedStorage.cpp的第734行,會因獲取不到m_cachesMutex的寫鎖而卡住,隨後其他線程也開始執行併到了獲取m_cachesMutex讀鎖的代碼行。

若讀寫防飢餓策略真的存在,那這些線程(包括T2)的確會在獲取讀鎖階段卡住,進而導致T2無法釋放cache鎖,從而T1無法獲取到cache鎖,此時所有線程均會陷入等待中。

在這個前提下,似乎一切都能解釋得通。上述流程的時序圖如下所示:

 

我們找到了TBB中Spin RW Lock的實現代碼,如下圖所示:

獲取寫鎖:

獲取讀鎖:

在獲取寫鎖的代碼中,可以看到寫線程如果沒有獲取到寫鎖,會置一個WRITER_PENDING標誌位,表明此時正有寫線程在等待讀寫鎖的釋放,其他線程請勿打擾。

而獲取的讀鎖代碼中,也可以看到,如果讀線程發現鎖上被置了WRITER_PENDING標誌位,就會老實地循環等待,讓寫線程優先去獲取讀寫鎖。這裏讀寫鎖的行爲完美符合之前對讀寫鎖防飢餓策略的推測,至此真相大白。

既然找到了問題起因,那解決起來就容易多了。在CachedStorage的設計中,Cache清理線程優先級很低,調用頻率也不高(約1次/秒),因此給予它高讀寫鎖優先級是不合理的,故將removeCache函數獲取m_cachesMutex寫鎖方式做如下修改:

 

修改後,獲取寫鎖方式跟獲取讀鎖類似:每次獲取寫鎖時,先try_acquire,如果沒獲取到就放棄本輪時間片下次再嘗試,直到獲取到寫鎖爲止,此時寫線程不會再去置WRITER_PENDING標誌位,從而能夠不影響其他讀線程的正常執行。

相關代碼已提交至2.5版本中,該版本將很快與大家見面,敬請期待。

 

實現效果

修改前循環執行CachedStorage單測200次左右便會發生死鎖;修改後循環執行2000+次仍未發生死鎖,且各個線程均能有條不紊地工作。

 

經驗總結

從這次調試過程中,總結了一些經驗與大家分享。

首先,分析死鎖問題最有效的仍然是“兩步走”方法,即通過pstack、sample、gdb等工具看線程棧,推測導致發生死鎖的線程執行時序。

這裏的第二步需要多發揮一點想象力。以往的死鎖問題往往是兩個線程間交互所導致,教科書上也多以兩個線程來講解死鎖四要素,但在上述問題中,由於讀寫鎖的特殊性質,需要三個線程按照特殊時序交互纔可以引發死鎖,算是較爲少見的情況。

其次,『只要有線程獲取到讀鎖,那其他想獲取讀鎖的線程一定也能獲取讀鎖』的思維定勢是有問題的。

至少在上面的問題中,防飢餓策略的存在導致排在寫線程後的讀線程無法獲取讀鎖。但本文的結論並非放之四海而皆準,要不要防飢餓、怎麼防飢餓在各個多線程庫的實現中有着不同的取捨。有的文章提到過某些庫的實現就是遵循『讀線程絕對優先』規則,那這些庫就不會遇到這類問題,所以仍然需要具體問題具體分析。

 


 

《超話區塊鏈》話題徵集:我的Debug經歷

歡迎與我們聊聊你經歷過的有趣/難忘的debug過程,我們將挑選具備參考意義的debug經歷與更多開發者分享,經社區採納將可獲得FISCO BCOS紀念衫一件。

Debug經歷徵集傳送門

https://jinshuju.net/f/i8m0Il

 

我們鼓勵機構成員、開發者等社區夥伴參與開源共建事業,有你在一起,會更了不起。多樣參與方式:

1、進入微信社羣,隨時隨地與圈內最活躍、最頂尖的團隊暢聊技術話題(進羣請添加小助手微信,

微信ID:FISCOBCOS010);

2、訂閱我們的公衆號:“FISCO BCOS開源社區”,我們爲你準備了開發資料庫、最新FISCO BCOS動態、活動、大賽等信息;

3、來Meetup與開發團隊面對面交流,FISCO BCOS正在全國舉辦巡迴Meetup,深圳、北京、上海、成都……歡迎您公衆號在菜單欄【找活動】中找到附近的Meetup,前往結識技術大咖,暢聊硬核技術;

4、參與代碼貢獻,您可以在Github提交Issue進行問題交流,歡迎向FISCO BCOS提交Pull Request,包括但不限於文檔修改、修復發現的bug、提交新的功能特性。

代碼貢獻指引:

https://github.com/FISCO-BCOS/FISCO-BCOS/blob/master/docs/CONTRIBUTING_CN.md 
 

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