Effective Modern C++ 條款38 意識到線程句柄的析構函數的不同行爲

意識到線程句柄的析構函數的不同行爲

條款37解釋過一個可連接的(joinable)線程對應着一個底層的系統執行線程,一個非推遲任務(看條款36)的future和系統線程也有類似的關係。這樣的話,可以認爲std::thread對象和future對象都可以操縱系統系統。

從這個角度看,std::thread對象和future對象的析構函數表現出不同的行爲是很有趣的。就如條款37提到,銷燬一個可連接的std::thread對象會終止你的程序,因爲另外兩個選擇——隱式join和隱式detach——被認爲是更糟的選擇。而銷燬一個future,有時候會表現爲隱式join,有時候會表現爲隱式detach,有時候表現的行爲既不是join也不是detach。它決不會導致程序終止,這種線程管理行爲的方法值得我們仔細檢查。

我們從觀察一個future開始吧,它是一個交流管道的一端,在這個交流管道中被叫方要把結果傳給主叫方。被叫方(通常異步運行)把計算的結果寫進交流管道(通常藉助一個std::promise對象),而主叫方使用一個future來讀取結果。你可以用下圖來思考,虛線箭頭展示了信息被叫這流向主叫:

這裏寫圖片描述

但被叫方的結果存儲在哪裏呢?在主叫方future執行get之前,被叫方可能已經執行完了,因此結果不能存儲在被叫的std::promise裏。那個對象,會是被叫方的局部變量,在被叫執行結束後會被銷燬。

然而,結果也不能存儲在主叫方的future中,因爲(還有其他原因)一個std::future對象可能被用來創建一個std::shared_future(因此把被叫方結果的所有權從std::future轉移到std::shared_future),而在最原始的std::future銷燬之後,這個std::shared_future可能會被拷貝很多次。倘若被叫方的結果類型是不可被拷貝的(即只可移動類型),而那結果是只要有一個future引用它,它就會存在,那麼,多個future中哪一個含有被叫方的結果呢?

因爲被叫方對象和主叫方對象都不適合存儲結構,所以這個結果存在兩者之外的地方。這個地方叫做shared stateshared state通常表現爲一個基於堆實現的對象,但標準沒有指定它的類型、接口和實現,所以標準庫的作者可以用他們喜歡的方法來實現shared state

如下,我們可以把主叫、被叫、shared state之間的關係視圖化,虛線箭頭再次表現信息的流向:

這裏寫圖片描述

shared state的存在很重要,因爲future的析構函數的行爲——該條款的話題——是由與它關聯的shared state決定的。特別是:

  • 最後一個引用shared state(它藉助std::aysnc創建了一個非推遲任務時產生)的future的析構函數會阻塞直到任務完成。本質上,這種future的析構函數對底層異步執行任務的線程進行隱式的join
  • 其他的future對象的析構函數只是簡單地銷燬future對象。對於底層異步運行的任務,與對線程進行detach操作相似。對於最後一個future是推遲的任務的情況,這意味着推遲的任務將不會運行。

這些規則聽起來很複雜,但我們真正需要處理的是一個簡單“正常的”行爲和一個單獨的例外而已。這正常的行爲是:future的析構函數會銷燬future對象。那意思是,它不會join任何東西,也不會detach任何東西,它也沒有運行任何東西,它只是銷燬 future的成員變量。(好吧。實際上,它還多做了些東西。它減少了shared state裏的引用計數,這個shared state由future和被叫的std::promise共同操控。引用計數可以讓庫知道什麼時候銷燬**shared state,關於引用計數的通用知識,請看條款19.)

對於正常行爲的那個例外,只有在future滿足下面全部條件纔會出現:

  • future引用的shared state是在調用了std::async時被創建
  • 任務的發射策略是std::launch::async(看條款36),要麼是運行時系統選擇的,要麼是調用std::async時指定的。
  • 這個future是最後一個引用shared state的future。對於std::future,它總是最後一個,而對於std::shared_future,當它們被銷燬的時候,如果它們不是最後一個引用shared state的future,那麼它們會表現出正常的行爲(即,銷燬成員變量)。

只有當這些條件都被滿足時,future的析構函數纔會表現出特殊的行爲,而這行爲是:阻塞直到異步運行的任務結束。特別說明一下,這相當於對運行着std::async創建的任務的線程執行隱式join

這個例外對於正常的future析構函數行爲來說,可以總結爲“來自std::async的future在它們的析構函數裏阻塞了。”對於初步近似,它是正確的,但有時候你需要的比初步近似要多,現在你已經知道了它所有的真相了。

你可能又有另一種疑問,可能是“我好奇爲什麼會有這麼奇怪的規則?”。這是個合理的問題,關於這個我只能告訴你,標準委員會想要避免隱式detach引發的問題(看條款37),但是他們又不想用原來的策略讓程序終止(針對可連接的線程,看條款37),所以他們對隱式join妥協了。這個決定不是沒有爭議的,他們也有討論過要在C++14中禁止這種行爲。但最後,沒有改變,所以future析構函數的行爲在C++11和C++14相同。

future的API沒有提供方法判斷future引用的shared state是否產生於std::async調用,所以給定任意的future對象,不可能知道它的析構函數是否會阻塞到異步執行任務的結束。這有一些有趣的含義:

// 這個容器的析構函數可能會阻塞
// 因爲包含的future有可能引用了藉助std::async發射的推遲任務的而產生的shared state
std::vector<std::future<void>> futs;           // 關於std::future<void>,請看條款39

class Widget {                // Widget對象的析構函數可能會阻塞
public:
    ...
private:
    std::shared_future<double> fut;
};

當然,如果你有辦法知道給定的future不滿足觸發特殊析構行爲的條件(例如,通過程序邏輯),你就可以斷定future不會阻塞在它的析構函數。例如,只有在std::async調用時出現的shared state才具有特殊行爲的資格,但是有其他方法可以創建shared state。一個是std::packaged_task的使用,一個std::packaged_task對象包裝一個可調用的對象,並且允許異步執行並獲取該可調用對象產生的結果,這個結果就被放在shared state裏。引用shared state的future可以藉助std::packaged_taskget_future函數獲取:

int calcValue();         // 需要運行的函數

std::packaged_task<int()> pt(calcValue);   // 包裝calcValue,因此它可以異步允許

auto fut = pt.get_future();     // 得到pt的future

在這時,我們知道future對象fut沒有引用由std::async調用的產生的shared state,所以它的析構函數將會表現出正常的行爲。

一旦std::packaged_task對象pt被創建,它就會被運行在線程中。(它也可以藉助std::async調用,但是如果你想要用std::async運行一個任務,沒有理由創建一個std::packaged_task對象,因爲std::async能做std::packaged_task能做的任何事情。)

std::packaged_task不能被拷貝,所以當把pt傳遞給一個std::thread構造函數時,它一定要被轉換成一個右值(藉助std::move——看條款23):

std::thread t(std::move(pt));             // 在t上運行pt

這個例子讓我們看到了一些future正常析構行爲,但如果把這些語句放在同一個塊中,就更容易看出來:

{           // 塊開始
    std::packaged_task<int()> pt(calcValue);

    auto fut = pt.get_future();

    std::thread t(std::move(pt));

   ...    // 看下面
}         // 塊結束

這裏最有趣的代碼是“…”,它在塊結束之前,t創建之後。這裏有趣的地方是在“…”中,t會發生什麼。有3個基本的可能:

  • t什麼都沒做。在這種情況下,t在作用域結束時是可連接的(joinable),這將會導致程序終止(看條款37)。
  • t進行了join操作。在這種情況下,fut就不需要在析構時阻塞了,因爲代碼已經join了。
  • t進行了detach操作。在這種情況下,fut就不需要在析構時detach了,因爲代碼已經做了這件事了。

換句話說,當你shared state對應的future是由std::packaged_task產生的,通常不需要採用特殊析構策略,因爲操縱運行std::packaged_taskstd::thread的代碼會在終止、joindetach之間做出決定。
*需要記住的2點:

  • future的析構函數通常只是銷燬future的成員變量。
  • 最後一個引用shared state(它是在藉助std::aysnc創建了一個非推遲任務時產生)的future會阻塞到任務完成。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章