這篇博客主要記錄的是關於可重入性的相關定義,以及關於併發安全的思考。node
在不一樣語言中,因爲語言標準以及運行期環境規定的不一樣,可重入性的具體定義可能有所不一樣。這裏聊的是C++語言中的可重入性。程序員
所謂可重入性(reetrant
),指的是同時具有併發安全和中斷安全的特徵,這是目前爲止我對可重入性的認識,也是這篇博客在寫下時給可重入性下的定義。編程
這個認知可能並不許確,由於在wiki上的定義是這樣的。設計模式
若一個程序或子程序能夠「在任意時刻被中斷而後操做系統調度執行另一段代碼,這段代碼又調用了該子程序不會出錯」,則稱其爲可重入(reentrant或re-entrant)的。即當該子程序正在運行時,執行線程能夠再次進入並執行它,仍然得到符合設計時預期的結果。與多線程併發執行的線程安全不一樣,可重入強調對單個線程執行時從新進入同一個子程序仍然是安全的。安全
可是在不少中文博客裏,聊到可重入性的時候每每也會把併發安全混爲一談。實際上來講的話......一個可重入的函數,經常也是併發安全的。多線程
那麼先從併發安全講起吧。併發
所謂併發安全已是老生常談了。編程語言
以一段很是簡單的代碼爲例,咱們打算初始化一個對象,這個對象被兩個線程共享。ide
void initialize(Something** someshit) { if(!*someshit) { *someshit = createSomeShit(); } }
顯而易見,若是線程在執行到特定環節時發生了切換函數
void initialize(Something** someshit) { if(!*someshit) { // <-------- 線程切換 // 線程2() { // initialize(something); // } // 線程切換 ---------> *someshit = createSomeShit(); } }
那麼 createSomeShit
這段代碼就會被執行兩次。
顯然這和咱們預期的行爲不符。
這裏要聊的不是併發,而是......可重入性。因此咱們再看看這個函數可否被重入。
按照 wiki 提供的定義,函數可重入指的是
在任意時刻被中斷而後操做系統調度執行另一段代碼,這段代碼又調用了該子程序不會出錯。
符合嗎?不。爲何?由於一樣在那個線程切換的位置上中斷,而後再另外一段代碼裏再次執行這個函數,也會觸發一樣的問題,致使createSomeShit
被執行兩次。
void initialize(Something** someshit) { if(!*someshit) { // <-------- 被中斷 // 中斷處理函數() { // initialize(something); // } // 中斷結束 -------- *someshit = createSomeShit(); } }
能夠看出,那些線程不安全的代碼,都是不可重入的。
那麼,線程安全的代碼,就必定是可重入的嗎?
中斷這個東西對其餘編程語言的用戶來講可能會少見一些,在C/C++語言裏,中斷並非什麼新鮮話題。
在C標準庫中,規定了一系列的信號和信號處理方法。關於信號的定義能夠參考這個。
當進程接收到信號的時候,當前正在執行的代碼就會被中斷——注意了,這回,鎖救不了你。
在C/C++中,中斷處理是由一個函數進行。在函數裏可能會調用到中斷時正在執行的函數。那麼問題來了——一個線程安全的函數,是中斷安全的函數嗎?
void initialize(Something** someshit, std::mutex& realshit) { std::lock_guard<std::mutex>(realshit); if(!*someshit) { *someshit = createSomeShit(); } }
看上去歲月靜好~一切線程切換的問題,都被那句std::lock_guard<std::mutex>(realshit)
給擋在了牆的另外一邊。
可是......
void initialize(Something** someshit, std::mutex& realshit) { std::lock_guard<std::mutex>(realshit); if(!*someshit) { // <----- 調皮的用戶按下了 Ctrl-C // 中斷處理函數() { // initialize(someshit, realshit); // // inside initialize { // // std::lock_guard<std::mutex>(realshit); // DEAD LOCK // // } // } *someshit = createSomeShit(); } }
看這裏~
std::lock_guard<std::mutex>(realshit); // 進入信號處理 std::lock_guard<std::mutex>(realshit);
好了,GG。死鎖在這個時候發生了。
經驗豐富的大佬可能注意到了,咱還能夠用std::recursive_mutex
啊!
這裏就要提到一個很遺憾的問題了:C/C++的語言標準給了哪些保證。
C對信號處理函數的定義很粗暴,除了abort
、_Exit
、quick_exit
、signal
、stdatomic.h的免鎖原子函數
、atomic_is_lock_free與任何類型的原子參數
這些函數之外,任何標準庫函數的調用,行爲都是未定義的。
C++對信號處理函數的定義則更加複雜,限制比之C更加嚴格。畢竟標準庫要龐大得多......也不是不能理解。
標準中有個一個地方的描述很微妙:......免鎖的。
換言之,誰又保證了信號處理函數必然和你但願的那個線程是同一個線程呢?
std::recursive_mutex
的實現依賴於平臺提供的系統API,反正我沒有找到語言標準中相關的規定要求信號處理函數必須和main
函數在同一個線程,因此我認爲這是平臺相關的問題:這樣的代碼是不可移植的。
按照設計模式原則,咱們是面向接口——也就是標準文檔編程,而不是面對實現——Visual C++、GCC、MinGW或者哪一個中東土豪在將來某天突發奇想送我一臺MIPS的超算的話。
到業務層面的話會更靈活一些——反正我只在某環境下跑,等公司何時全面換平臺了,咱再能改則改,改不了就跑路。
遞歸和重入有必定的類似性,但又有所不一樣。
一個遞歸函數,直覺上來說,好像應該是可重入的:由於它要調用本身。
那麼......事實上呢?
寫個比較騷的遞歸刪除鏈表節點的例子。
void removeNode(Node* node, int length) { if(length > 0) { Node* tmp = node.prev; node.next.prev = tmp; // <------ 出現了!中斷獸! // 不用看了,Node之間的聯結已經被破壞了 // 離開了!中斷獸!--------> tmp.next = node.next; freeNode(node); removeNode(tmp.next, length-1); } }
輕易地否認了遞歸函數=可重入函數的直覺想法。
深究下去,又到了線程安全——而後是死鎖——而後提出了std::recursive_mutex
或者其餘相似的操做——最後走到平臺相關的API和保證——失去可移植性。
爲何我一直在提可移植性?
emmmm,大概是裝逼如風,常伴吾身吧。
C/C++語言的標準庫是出了名的——但不是好的方面,而是他們總在修修補補又一年。
C標準庫還好說——畢竟語言自己沒啥特性,全靠各類平臺提供API撐着。標準庫改來改去也只是割個雙眼皮的程度。
C++要更騷氣一些,每隔幾年就整個容,簡直不給人活路。
就中斷安全來講,雖然不知道內部怎麼實現的,可是......printf這樣的函數在信號處理函數裏調用的話,也算是未定義行爲。
認輸吧,你是鬥不過標準的。該依賴平臺行爲的時候,就去依賴平臺行爲吧。
懶得找原文,直接看cppreference對signal的說法就好。有興趣的話能夠找又臭又長的WG14 - N1570 - C11,還有WG21 - N4659 - C++17這兩本標準文檔。
因而這會兒就到了其餘各類語言的用戶慣例吐槽的時候:
...大佬是公司裏惟一用C++寫代碼的人。他對人說話,老是滿口「目標平臺」、「標準」、「可移植性」之類的話,叫人半懂不懂的。由於他老是說「C++天下第一!」,別人便從他說的那些半懂不懂的話裏,替他取下個綽號,叫C++大神。
C++大神一到公司裏,程序員們便看着他笑,有的叫道:「C++大神,你的代碼又編譯出錯了!」
他不回答,對前臺說:「倒上特濃的咖啡,今天也要加班到夜裏。」便拿出員工卡。程序員們又高聲叫嚷道:「你必定又用上新標準了吧?」
C++大神睜大眼睛說,「你怎麼憑空污人清白!」
「什麼清白?我前天親眼看見你的代碼編譯報了錯,整整十幾MB的日誌!」
C++大神便漲紅了臉,額上的青筋條條綻出,爭辯道,「編譯器報錯怎麼能叫錯......C++......編譯器不支持,那能算錯麼?」
接連即是難懂的話,什麼「CONCEPT還不加入標準」、「未定義行爲就該是編譯錯誤」、「SFINAE就是給編譯器開洞」、「boost大法好,天滅std::experimental」,引得衆人都鬨笑起來:店內外充滿了快活的空氣。