併發、可重入性與信號安全
這篇博客主要記錄的是關於可重入性的相關定義,以及關於併發安全的思考。
可重入性
在不同語言中,由於語言標準以及運行期環境規定的不同,可重入性的具體定義可能有所不同。這裏聊的是C++語言中的可重入性。
所謂可重入性(reetrant
),指的是同時具備併發安全和中斷安全的特徵,這是目前爲止我對可重入性的認識,也是這篇博客在寫下時給可重入性下的定義。
這個認知可能並不準確,因爲在wiki上的定義是這樣的。
若一個程序或子程序可以「在任意時刻被中斷然後操作系統調度執行另外一段代碼,這段代碼又調用了該子程序不會出錯」,則稱其爲可重入(reentrant或re-entrant)的。即當該子程序正在運行時,執行線程可以再次進入並執行它,仍然獲得符合設計時預期的結果。與多線程併發執行的線程安全不同,可重入強調對單個線程執行時重新進入同一個子程序仍然是安全的。
但是在很多中文博客裏,聊到可重入性的時候往往也會把併發安全混爲一談。實際上來說的話......一個可重入的函數,常常也是併發安全的。
那麼先從併發安全講起吧。
併發安全性和可重入性
所謂併發安全已經是老生常談了。
以一段非常簡單的代碼爲例,我們打算初始化一個對象,這個對象被兩個線程共享。
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”,引得衆人都鬨笑起來:店內外充滿了快活的空氣。