1. 程式人生 > >比起基於執行緒程式設計,更偏愛基於任務程式設計

比起基於執行緒程式設計,更偏愛基於任務程式設計

如果你想非同步地執行函式doAsyncWork,你有兩個基本的選擇。你可以建立一個std::thread,用它來執行doAsyncWork,這是基於執行緒(thread-based)的方法:

int doAsyncWork();

  1. std::thread t(doAsyncWork);

或者你把doAsynWork傳遞給std::async,一個叫做基於任務(task-based)的策略:

auto fut = std::async(doAsyncWork);    // "fut"的意思是"future"

在這種呼叫中,傳遞給std::async的函式物件被認為是一個任務(task)。

基於任務的方法通常優於基於執行緒的對應方法,我們看到基於任務的方法程式碼量更少,這已經是展示了一些原因了。在這裡,doAsyncWork會返回一個值。在基於執行緒的呼叫中,沒有直接的辦法獲取到它;而在基於任務的呼叫中,這很容易,因為std::asyn返回的future提供了一個函式get來獲取返回值。如果doAsyncWork函式發出了一個異常,get函式是很重要的,它能取到這個異常。在基於執行緒的方法中,如果doAsyncWork丟擲了異常,程式就死亡了(藉助std::terminate)。

基於執行緒程式設計和基於任務程式設計的一個更根本的區別是,基於任務程式設計表現出更高級別的抽象。它讓你擺脫執行緒管理的細節,這提醒我需要總結一下併發C++軟體裡“執行緒”的三種含義:

  • 硬體執行緒是實際執行計算的執行緒。現代機器體系結構為每個CPU核心提供一個或多個硬體執行緒。
  • 軟體執行緒(又稱為作業系統執行緒或系統執行緒)是由作業系統管理和為硬體執行緒進行排程的執行緒。軟體執行緒建立的數量通常會比硬體執行緒多,因為當一個軟體執行緒阻塞了(例如,I/O操作,等待鎖或者條件變數),執行另一個非阻塞的執行緒能提供吞吐率。
  • std::thread是C++程序裡的物件,實際上相當於它內部軟體執行緒的控制代碼。一些std::thread物件表示為“null”控制代碼,即不持有軟體執行緒,因為它們處於預設構造狀態(因此沒有函式需要執行),它要麼被move過了(那麼接受移動的那個std::thread物件將代替這個std::thread物件來處理執行緒),要麼被detach了(std::thread物件和它內部軟體執行緒的關聯被切斷了,即thread物件和軟體執行緒分離了)。

軟體執行緒是一種受限的資源,如果你想建立的執行緒數量多於系統提供的數量,會丟擲std::system_error異常。就算你規定函式不能丟擲異常,這個異常也會丟擲。例如,就算你把doAsyncWork宣告為noexcept,

 

int doAsyncWork noexcept;   // 關於noexcept

這語句還是可能會丟擲異常:

 

std::thread t(doAsyncWork);  // 如果沒有可獲取的系統,就丟擲異常

寫得好的軟體必須想個辦法解決這個可能性,但如何解決呢?一個辦法是在當前執行緒執行doAsyncWork,但這會導致負載不均衡的問題,而且,如果當前執行緒是個GUI執行緒,會導致響應時間變長。另一個方法是等待某些已存在的軟體執行緒完成工作,然後再嘗試建立一個新的std::thread物件,但是有可能發生這種事情:已存在的執行緒在等待doAsyncWork的處理(例如,doAsyncWorkd的返回值,或者通知條件變數)。

即使你建立的執行緒數量沒超過系統限制,你還是會有oversubscription(過載)的問題——當就緒狀態(即非阻塞)的軟體執行緒多於硬體執行緒的時候。此時,排程執行緒(通常是作業系統的一部分)會為軟體執行緒分配CPU時間片,一個執行緒的時間片用完,就執行另一個執行緒,這其中發生了上下文切換。這種上下文切換會增加系統的執行緒管理開銷。這種情況下,(1)CPU快取會持有那個軟體執行緒(即,它們會儲存對於那軟體執行緒有用的一些資料和一些指令),而(2)CPU核心上“新”執行的軟體執行緒“汙染”了CPU快取上“舊的”執行緒資料(它曾經在該CPU核心執行過,且可能再次排程到該CPU核心執行)。

避免oversubscription是很困難的,因為軟體系統和硬體執行緒的最佳比例是取決於軟體執行緒多久需要執行一次,而這是會動態改變的,例如,當一個執行緒從IO消耗型轉換為CPU消耗型時。這最佳比例也取決於上下文切換的開銷和軟體執行緒使用CPU快取的效率。再進一步說,硬體執行緒的數量和CPU快取的細節(例如,快取多大和多快)是取決於機器的體系結構,所以即使你在一個平臺上讓你的應用避免了oversubscription(保持硬體繁忙工作),也不能保證在另一種機器上你的方案能工作得好。

如果你把這些問題扔給某個人去做,你的生活就很愜意啦,然後使用std::async就能顯式地做這件事:

auto fut = std::async(doAsyncWork);  // 執行緒管理的責任交給標準庫的實現者

這個呼叫把執行緒管理的責任轉交給C++標準庫的實現者。例如,得到執行緒數超標異常的可能性顯著減少,因為這個呼叫可能從不產生這個異常。“它是怎樣做到的呢?”你可能好奇,“如果我申請多於系統提供的執行緒數,使用std::thread和使用std::async有區別嗎?”答案是有區別,因為當用預設啟動策略(default launch policy)呼叫std::async時,不能保證它會建立一個新的軟體執行緒。而且,它允許排程器把指定函式(本例中的doAsyncWork)執行在——請求doAsyncWork結果的執行緒中(例如,那個執行緒呼叫了get或者對fut使用wait ),如果系統oversubsrcibed或執行緒數耗盡時,合理的排程器可以利用這個優勢。

如果你想用“在需要函式結果的執行緒上執行該函式”來欺騙自己,我提起過這會導致負載均衡的問題,這問題不會消失,只是由std::async和排程器來面對它們,而不是你。但是,當涉及到負載均衡問題時,排程器比你更加了解當前機器發生了什麼,因為它管理所有程序的執行緒,而不是隻是你的程式碼運行於的程序和執行緒。

使用std::async,GUI執行緒的響應性也是有問題的,因為排程器沒有辦法知道哪個執行緒具有嚴格的響應性要求。在這種情況下,你可以把std::lanuch::async啟動策略傳遞給std::async,它那可以保證你想要執行的函式馬上會在另一個執行緒中執行。

最先進的執行緒排程器使用了系統範圍的執行緒池來避免oversubscription,而且排程器通過工作竊取(workstealing)演算法來提高了硬體核心的負載均衡能力。C++標準庫沒有要求執行緒池或者工作竊取演算法,而且,實話說,C++11併發技術的一些實現細節讓我們很難利用到它們。但是,一些供應商會在它們的標準庫實現中利用這種技術,所以我們有理由期待C++併發庫會繼續進步。如果你使用基於任務的方法進行程式設計,當它以後變智慧了,你會自動獲取到好處。另一方面,如果你直接使用std::thread進行程式設計,你要承擔著處理執行緒耗盡、oversubscription、負載均衡的壓力,更不用提你在程式中對這些問題的處理方案能否應用在同一臺機器的另一個程序上。

比起基於執行緒程式設計,基於任務的設計能分擔你的執行緒管理之痛,而且它提供了一種很自然的方式,讓你檢查非同步執行函式的結果(即,返回值或異常)。但是,有幾種情況直接使用std::thread更適合,它們包括

  • 你需要使用特定平臺內部執行緒實現的API。C++併發API通常是使用特定平臺的低階API實現的,通常使用pthread或Window’s Thread。它們提供的API比C++提供的要多(例如,C++沒有執行緒優先順序的概念)。為了獲取內部執行緒實現的API,std::thread物件有一個native_handle成員函式,而std::future(即std::async返回的型別)沒有類似的東西。
  • 你需要且能夠優化你應用中的執行緒。
  • 你需要在C++併發API之上實現執行緒技術。例如,實現一個C++不提供的執行緒池。

不過,這些都是不常見的情況。大多數時候,你應該選擇基於任務的設計,來代替執行緒。


總結

需要記住的3點:

  • std::thread的API沒有提供直接獲取非同步執行函式返回值的方法,而且,如果這些函式丟擲異常,程式會被終止。
  • 基於執行緒程式設計需要手動地管理:執行緒耗盡、oversubscription、負載均衡、適配新平臺。
  • 藉助預設發射策略的std::async,進行基於任務程式設計可以解決上面提到的大部分問題。

原文連結:http://blog.csdn.net/big_yellow_duck/article/details/52502869