併發、可重入性與信號安全 原

併發、可重入性與信號安全

這篇博客主要記錄的是關於可重入性的相關定義,以及關於併發安全的思考。

可重入性

在不同語言中,由於語言標準以及運行期環境規定的不同,可重入性的具體定義可能有所不同。這裏聊的是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_Exitquick_exitsignalstdatomic.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”,引得衆人都鬨笑起來:店內外充滿了快活的空氣。

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