(不要)使用std::thread

const_cast發表於2012-12-16

這篇文章是關於std::thread的,但是和執行緒或者多執行緒概念無關,而且也不會介紹C++中的執行緒機制。我假設你們已經很熟悉標準庫執行緒元件和非同步式程式設計了。

我見過很多文章在介紹C++執行緒的時候,都會給一些類似下面這段程式碼的示例程式碼:

void run_in_parallel(function<void()> f1, function<void()> f2)
{
  thread thr{f1}; // run f1 in a new thread
  f2();           // run f2 in this thread
  thr.join();     // wait until f1 is done
}

當然了,這段程式碼會讓你對執行緒的建構函式用法有一些直觀的感覺,也會讓你對執行緒的分離(fork)和合並(join)有所瞭解。但是,我感覺這段程式碼沒有足夠的強調它是有多麼的異常-非安全,並且一般而言,這種直接使用裸執行緒的方式是非常不安全的。在這篇文章裡,我會盡力去分析這裡提到的“安全”問題。

如果一個異常被丟擲了,我們該怎麼做?這個問題是(或者至少應該是)C++的核心問題。在C++中,幾乎每個函式都有可能丟擲一個異常,程式碼應該儘量做到即使有可疑函式丟擲了異常,也仍然能保持清晰,並且在可控的範圍內。這裡的“清晰”和“可疑函式”,意味著我們的程式碼必須形式上有異常安全機制保障(如果你想了解更多此類資訊,請看 Dave Abrahams的文章中的例子)。一些人不喜歡異常處理機制,因為這被認為是多餘的責任,你必須採用不同返回值或者類似方法來解決這個問題。但是異常在C++中卻是真實存在的,而且幾乎每個函式都有可能丟擲異常,這一條定理對上面那段程式碼的f1函式和f2函式同樣適用。

那麼,如果f1函式在另外一個執行緒中丟擲了一個異常,會發生什麼事?我們並不常提及一個事實:每一個執行緒都有它自己的呼叫棧。從技術上來講,C++標準並不要求有呼叫棧,它僅僅要求了棧展開,在這個“棧”裡面,唯一需要“實體”的是異常處理器(catch字句)和自動物件的析構。如果宿主執行緒的棧被展開後,卻沒找到相匹配的異常處理器(這種情況就是異常離開了f1),函式std::terminate就會被呼叫,這和讓一個異常在main函式之外發生非常相似。某種程度上,在我們的例子中,f1相對於它的宿主執行緒,相當於main函式和main執行緒的關係。

如果函式f2丟擲了一個異常,會發生什麼?棧展開會在主執行緒中開始,合併指令會被跳過;執行緒的解構函式會被呼叫。根據標準慣例,執行緒是可以合併的(它既沒有被合併,也沒有被拆開)。對一個可合併的執行緒呼叫解構函式會導致系統自動呼叫std::terminate。

如果你非常熟悉異常安全保障機制,你會注意到,我對一件事非常含糊不清。呼叫std::terminate不一定是異常非安全的,異常安全至少意味著不存在資源洩露,同時我們也不希望讓程式處在一種不穩定的狀態。std::terminate並不會使程式處於不穩定的狀態:它根本就不會讓程式繼續執行。關於資源洩露的問題,終止程式也許可以處理一些資源的洩露,在程式被終止後,洩露的資源,例如自由儲存區(譯者注:這裡指的就是堆區)會變的和程式毫無關係。但是,如果f1或者f2丟擲了不太舒服的異常,系統就會呼叫std::terminate。

為什麼有這些約束?

為什麼這些在子執行緒中未處理的異常能引起呼叫std::terminate?其他的一些程式語言選擇隱式的停止子執行緒,但是卻讓其他執行緒保持執行。在C++哲學裡,沒有異常可以被直接忽略,每個異常必須被顯式的處理。如果我們不能用更好的方法去處理它,那麼最後的方法則是呼叫std::terminate。同時也要注意:事實上,通過呼叫std::terminate,一個異常可以被處理的非常有意義。(看下面)。

為什麼一個可合併執行緒的解構函式不得不呼叫std::terminate?畢竟,解構函式可以在子執行緒中執行,或者,它可以從子執行緒中分離出來,或者,他可以直接撤銷執行緒。簡而言之,你不可能加入到解構函式中,因為,如果f2丟擲了異常,這有可能導致意想不到的程式無響應。

try {
  if_we_join::thread thr{f1};

  if (skip_f2()) {         // suppose this is true
    throw runtime_error();
  }

  f2();
}
catch (std::exception const&) {
  // will not get here soon: f1 is still running
}

你不能把它(子執行緒)抽離出來,因為這會使當前程式狀況處在風險中。當主執行緒離開它的作用域後,子執行緒會被啟動,然後子執行緒會一直保持執行,並且會引用到資源已經被主執行緒釋放的作用域中。

try {
  Resource r{};            // a resource in local scope

  auto closure = [&] {     // the closure takes reference to 'r'
    do_somethig_for_1_minute();
    use(r);
  };

  if_we_detach::thread thr{closure};

  throw runtime_error();
}
catch (std::exception const&) {
  // at this time f1 is still using 'r' which is gone
}

關於不合並和不分離的理論說明細節可以在N2802文件上找到。為什麼執行緒的解構函式不能夠撤銷子執行緒?按照POSIX執行緒標準(詳細連結),撤銷執行緒和C++資源管理手法並不能很好的相容,原因則是:解構函式和RAII(資源獲取就是初始化)。如果一個執行緒在自動物件解構函式未被呼叫的情況下就被撤銷,或者至少,這些解構函式是否被呼叫取決於具體的實現,那麼,這也許會導致過多的資源洩露。因此,對於C++來說,撤銷執行緒是不可能的。但是,卻有一個類似的機制來處理這些:執行緒中斷。中斷是非常友好的:將要被中斷的執行緒必須通知中斷將要發生。如果中斷了,一個特殊的異常將會被丟擲,直到這個異常被傳遞到了最外層作用域,子執行緒的棧才會被展開。然而,中斷機制在C++11標準中也並不可用,人們曾經考慮過將此機制引入C++11標準,但是,最後卻因為非常滑稽的原因被拒絕了,這個原因會在另外一篇文章裡詳述。

請注意,std::thread的解構函式呼叫std::terminate的問題並不真的和異常相關。當我們無意識的忘記呼叫(執行緒)合併或者分離的時候,它同樣會被觸發:

{
  thread thr{f1};
  // lots of code ...

  if (condition()) return;
  // lots of code ...

  thr.join();
  return;
}

如果我們有一個長函式,這個函式具有不同的返回值,當每次退出作用域的時候,我們有可能忘記去合併/分離(執行緒)。注意,這種情況和手動清理資源很相似,但是,我們有兩種方法去清理資源:或者合併或者分離,程式不可能自動的去選擇其中一種方式。

因此,在給出了這些和std::threads相關的安全問題後,這些東西又對什麼是有利的呢?為了回答這個問題,我們不得不先離一下題。

底層的原始資訊

下面這個例子經常用於工作面試(對於C++程式設計師):

// why is this unsafe?  
 Animal * animal = makeAnimal(params);  
use(animal);  
delete animal; 

事實上,在實際的程式碼中使用缺乏保護的delete操作符經常是程式設計錯誤,程式設計師們被告知儘量使用高階的工具,比如用std::unique_ptr去做這種工作。但是,鑑於在STL中已經有類似高階的工具了,我們還需要原始的delete嗎?答案是“需要”:我們仍需要用它來構建類似於std::unique_ptr的高階別的工具。

這個答案和std:thread或多或少有些類似。這是一種系統底層的抽象化,為了和作業系統的執行緒建立1對1的對映關係。我們有一種標準的可移植的元件來扮演執行緒的角色,並且不會產生執行時期的開銷,利用這種底層的工具,我們能夠按照我們的需求,建立更多的高階別的,減少異常的工具。

如果你想你的執行緒類可以在解構函式裡合併,你只需要按照RAII的樣子來實現執行緒類,把std::thread在類的內部進行管理,下面這段程式碼就完全是你需要的:

{
  JoiningThread thr{f1}; // run f1 in a new thread
  f2();                  // run f2 in this thread
}                        // wait until f1 is done

你想要你的執行緒類在解構函式中被分離嗎?我們可以寫另外一個執行緒類。對於解構函式中每種可能的、有意義的行為,你想不想新增一個新的類?請讓你的型別是可配置的:

{
  SafeThread<Join> thr{f1};
  f2();                  
}

或者

{
  SafeThread thr{f1, join};
  f2();                  
}

這個方案正是boost::thread所提出的(詳細)。注意,如果你正在用boost::thread,線上程封裝器中,一個合理的、可選的行為是第一次中斷子執行緒後,然後和子執行緒合併。同理,刪除操作也是類似的,對於程式設計師來說,一個合理的建議是,一直使用類似的封裝器來完成這個任務。

高階的多執行緒抽象

因此,標準庫為多執行緒提供了什麼高階別的抽象?實際上,並沒有。有一個:函式std::async。它用來解決一個問題:在一個新執行緒裡安全的開始一個任務。當異常在子執行緒中被丟擲的時候(我們的f1函式),它能幹淨的處理類似問題;當你忘記去合併/分離(隱式的合併)的時候,它並不會終止(執行緒)。在這個文章裡,我們沒有時間去對這個做更詳細的分析。

然而,如果你對它期盼過多的話,你會很驚豔的。例如,我們上面的例子也可以這麼寫:

{
  JoiningThread thr{f1}; // run f1 in a new thread
  f2();                  // run f2 in this thread
}                        // wait until f1 is done

你也許會很驚訝的發現,f1和f2並不會並行化執行:直到f1終止前,f2才會執行!你可以讀一下Herb Sutter的這篇論文來獲得更多的解釋。另外,Bartosz Milewski也描述了一些關於async的無效期望的事情,在他的文章Async Tasks in C++11: Not Quite There Yet中,有詳細的描述.

那麼執行緒池呢?還有非阻塞式併發佇列?“並行演算法”?並不在C++11標準裡,為什麼?答案是非常簡短的。C++委員會並沒有時間去做這些東西的標準化。在這一點上,很多人經常表達一種沮喪或者失望的情緒。我並沒發現以上的事情多有趣、多有成效,甚至於多合理。C++11標準為併發機制提供了一個非常牢固的基礎:記憶體模型、原子操作,和執行緒的概念以及執行緒鎖。如果我的文章讓你感覺std::thread大體上是沒用的,這並不是事實:他和其他的底層基本工具一樣有用。它給了你潛在的空間去構建各種各樣的高階多執行緒工具,我的目的僅僅是展示一下如果小心的使用它。用一句話總結就是“不要用在程式裡用裸執行緒:用類似RAII封裝器去代替它”。

在下一個C++11標準的版本修訂中,很可能會提供很多的併發和平行計算抽象內容。如果你在委員會的郵件列表裡看過今年的提案,你會發現很多的主題是關於這個的。

本文翻譯自Andrzej的C++部落格,原文:http://akrzemi1.wordpress.com/2012/11/14/not-using-stdthread/

相關文章