std::async的使用總結

VE視訊引擎發表於2021-01-25

C++98標準中並沒有執行緒庫的存在,直到C++11中才終於提供了多執行緒的標準庫,提供了管理執行緒、保護共享資料、執行緒間同步操作、原子操作等類。多執行緒庫對應的標頭檔案是#include <thread>,類名為std::thread

然而執行緒畢竟是比較貼近系統的東西,使用起來仍然不是很方便,特別是執行緒同步及獲取執行緒執行結果上就更加麻煩。我們不能簡單的通過thread.join()得到結果,必須定義一個執行緒共享的變數來傳遞結果,同時還要考慮執行緒間的互斥問題。好在C++11中提供了一個相對簡單的非同步介面std::async,通過這個介面可以簡單的建立執行緒並通過std::future中獲取結果。以往都是自己去封裝執行緒實現自己的async,現在有執行緒的跨平臺介面可以使用就極大的方便了C++多執行緒程式設計。

先看一下std::async的函式原型

//(C++11 起) (C++17 前)
template< class Function, class... Args>
std::future<std::result_of_t<std::decay_t<Function>(std::decay_t<Args>...)>>
    async( Function&& f, Args&&... args );

//(C++11 起) (C++17 前)
template< class Function, class... Args >
std::future<std::result_of_t<std::decay_t<Function>(std::decay_t<Args>...)>>
    async( std::launch policy, Function&& f, Args&&... args );  

第一個引數是執行緒的建立策略,有兩種策略可供選擇

  • std::launch::async:在呼叫async就開始建立執行緒。
  • std::launch::deferred:延遲載入方式建立執行緒。呼叫async時不建立執行緒,直到呼叫了future的get或者wait時才建立執行緒。

預設策略是:std::launch::async | std::launch::deferred也就是兩種策略的合集,具體什麼意思後面詳細再說

第二個引數是執行緒函式

執行緒函式可接受function, lambda expression, bind expression, or another function object

第三個引數是執行緒函式的引數

不再說明

返回值std::future

std::future是一個模板類,它提供了一種訪問非同步操作結果的機制。從字面意思上看它表示未來,這個意思就非常貼切,因為她不是立即獲取結果但是可以在某個時候以同步的方式來獲取結果。我們可以通過查詢future的狀態來獲取非同步操作的結構。future_status有三種狀態:

  • deferred:非同步操作還未開始
  • ready:非同步操作已經完成
  • timeout:非同步操作超時,主要用於std::future.wait_for()

示例:

//查詢future的狀態
std::future_status status;
do {
    status = future.wait_for(std::chrono::seconds(1));
    if (status == std::future_status::deferred) {
        std::cout << "deferred" << std::endl;
    } else if (status == std::future_status::timeout) {
        std::cout << "timeout" << std::endl;
    } else if (status == std::future_status::ready) {
        std::cout << "ready!" << std::endl;
    }
} while (status != std::future_status::ready); 

std::future獲取結果的方式有三種:

  • get:等待非同步操作結束並返回結果
  • wait:等待非同步操作結束,但沒有返回值
  • waite_for:超時等待返回結果,上面示例中就是對超時等待的使用展示

介紹完了std::async的函式原型,那麼它到底該如何使用呢?

std::async的基本用法:示例連結

#include <iostream>
#include <vector>
#include <algorithm>
#include <numeric>
#include <future>
#include <string>
#include <mutex>

std::mutex m;
struct X {
    void foo(int i, const std::string& str) {
        std::lock_guard<std::mutex> lk(m);
        std::cout << str << ' ' << i << '\n';
    }
    void bar(const std::string& str) {
        std::lock_guard<std::mutex> lk(m);
        std::cout << str << '\n';
    }
    int operator()(int i) {
        std::lock_guard<std::mutex> lk(m);
        std::cout << i << '\n';
        return i + 10;
    }};

template <typename RandomIt>int parallel_sum(RandomIt beg, RandomIt end){
    auto len = end - beg;
    if (len < 1000)
        return std::accumulate(beg, end, 0);

    RandomIt mid = beg + len/2;
    auto handle = std::async(std::launch::async,
                             parallel_sum<RandomIt>, mid, end);
    int sum = parallel_sum(beg, mid);
    return sum + handle.get();
}

int main(){
    std::vector<int> v(10000, 1);
    std::cout << "The sum is " << parallel_sum(v.begin(), v.end()) << '\n';

    X x;
    // 以預設策略呼叫 x.foo(42, "Hello") :
    // 可能同時列印 "Hello 42" 或延遲執行
    auto a1 = std::async(&X::foo, &x, 42, "Hello");
    // 以 deferred 策略呼叫 x.bar("world!")
    // 呼叫 a2.get() 或 a2.wait() 時列印 "world!"
    auto a2 = std::async(std::launch::deferred, &X::bar, x, "world!");
    // 以 async 策略呼叫 X()(43) :
    // 同時列印 "43"
    auto a3 = std::async(std::launch::async, X(), 43);
    a2.wait();                     // 列印 "world!"
    std::cout << a3.get() << '\n'; // 列印 "53"
} // 若 a1 在此點未完成,則 a1 的解構函式在此列印 "Hello 42"

可能的結果

The sum is 10000
43
world!
53
Hello 42

由此可見,std::async是非同步操作做了一個很好的封裝,使我們不用關注執行緒建立內部細節,就能方便的獲取非同步執行狀態和結果,還可以指定執行緒建立策略。

深入理解執行緒建立策略

  • std::launch::async排程策略意味著函式必須非同步執行,即在另一執行緒執行。
  • std::launch::deferred排程策略意味著函式可能只會在std::async返回的future物件呼叫get或wait時執行。那就是,執行會推遲到其中一個呼叫發生。當呼叫get或wait時,函式會同步執行,即呼叫者會阻塞直到函式執行結束。如果get或wait沒有被呼叫,函式就絕對不會執行。

兩者策略都很明確,然而該函式的預設策略卻很有趣,它不是你顯示指定的,也就是第一個函式原型中所用的策略即std::launch::async | std::launch::deferred,c++標準中給出的說明是:

進行非同步執行還是惰性求值取決於實現

auto future = std::async(func);        // 使用預設發射模式執行func

這種排程策略我們沒有辦法預知函式func是否會在哪個執行緒執行,甚至無法預知會不會被執行,因為func可能會被排程為推遲執行,即呼叫get或wait的時候執行,而get或wait是否會被執行或者在哪個執行緒執行都無法預知。

同時這種排程策略的靈活性還會混淆使用thread_local變數,這意味著如果func寫或讀這種執行緒本地儲存(Thread Local Storage,TLS),預知取到哪個執行緒的本地變數是不可能的。

它也影響了基於wait迴圈中的超時情況,因為排程策略可能為deferred的,呼叫wait_for或者wait_until會返回值std::launch::deferred。這意味著下面的迴圈,看起來最終會停止,但是,實際上可能會一直執行:

void func()           // f睡眠1秒後返回
{
    std::this_thread::sleep_for(1);
}
auto future = std::async(func);      // (概念上)非同步執行f
while(fut.wait_for(100ms) !=         // 迴圈直到f執行結束
      std::future_status::ready)     // 但這可能永遠不會發生
{
    ...
}

為避免陷入死迴圈,我們必須檢查future是否把任務推遲,然而future無法獲知任務是否被推遲,一個好的技巧就是通過wait_for(0)來獲取future_status是否是deferred:

auto future = std::async(func);      // (概念上)非同步執行f
if (fut.wait_for(0) == std::future_status::deferred)  // 如果任務被推遲
{
    ...     // fut使用get或wait來同步呼叫f
} else {            // 任務沒有被推遲
    while(fut.wait_for(100ms) != std::future_status::ready) { // 不可能無限迴圈
      ...    // 任務沒有被推遲也沒有就緒,所以做一些併發的事情直到任務就緒
    }
    ...        // fut就緒
}

有人可能會說既然有這麼多缺點為啥還要用它,因為畢竟我們考慮的極限情況下的可能,有時候我不要求它是併發還是同步執行,也不需要考慮修改那個執行緒thread_local變數,同時也能接受可能任務永遠不會執行,那麼這種方式就是一種方便且高效的排程策略。

綜上所述,我們總結出以下幾點:

  • std::async的預設排程策略既允許任務非同步執行,又允許任務同步執行。
  • 預設策略靈活性導致了使用thread_local變數時的不確定性,它隱含著任務可能不會執行,它還影響了基於超時的wait呼叫的程式邏輯。
  • 如果非同步執行是必需的,指定std::launch::async發射策略。

參考文章:

API Reference Document

用C++11的std::async代替執行緒的建立

相關文章