CodeGym /課程 /C++ SELF /std::async — 啟動策略

std::async — 啟動策略

C++ SELF
等級 71, 課堂 0
開放

1. 為什麼需要 std::async 與 std::future

如果你剛接觸執行緒,對 std::async 的直覺可能是:「這又是一種做相同事的方式?」答案既是也不是。執行緒是「在別處並行執行」。而 std::async 是「啟動一個計算,並回傳一個把手,讓我之後取回結果」。這個「把手」就叫做 std::future。

想像一個日常場景:你叫了披薩。執行緒就像是「我去廚房做披薩」。std::async 則像是「我訂了披薩並拿到訂單號:憑這個號碼我之後會查狀態並取回結果」。訂單號就是 future。你不需要關心廚房的內部如何運作——重要的是結果會準備好,且可以受控地獲取。

還有一個重要差別:std::async 可以延遲執行。也就是你可以說:「別現在做披薩——等我真的來取的時候再開始做」。這就是啟動策略的作用。

非同步不是魔法,而是契約

async 這個詞聽起來好像一定是並行、快速又漂亮。但在 C++ 中,這不是「魔法」,而是一種你與標準函式庫之間的契約:你啟動一個任務,函式庫決定何時何地執行它──取決於所選的啟動策略。

在標準化過程中,圍繞 std::async 與相關機制有過許多討論,討論內容涉及行為與表述的細微差別——甚至有關某些規範部分是否能在所有標準庫中「以同樣方式實作」。這說明這個主題並非小玩意,別單憑名稱去「猜」行為。

因此這裡的目標務實:學會寫不依賴猜測的程式碼,理解何時 std::async 會真正把工作啟動為並行,何時則不會。

2. 基本概念與語法

小詞彙表:callable、任務、結果、future

在寫程式前,先把術語「說成人話」說清楚。

Callable 就是「可呼叫的東西」。一般函式是 callable。lambda 是 callable。有時候實作了 operator() 的物件也是 callable(但這部分今天不會深入)。

任務是你的 callable 加上它的參數:你想執行的事。

結果是 callable 回傳的值(例如 int),或者只是完成這件事的事實(void)。

std::future<T> 是一個物件,與「結果儲存格」相連(常稱為 shared state,共用的結果狀態)。你會在結果準備好之後取得它。

對於 std::async,要記住一點:它會啟動 callable 並回傳一個 future。至於如何啟動──那就看策略。

(順帶一提,即便在標準文件與修訂討論中,也能看到 std::async 在 futures 小節中有專門的位置——它不是附帶工具,而是結果模型的重要一環。)

基本語法:std::async — 「啟動並取得 future」

先看最小範例:函式做兩數相加,但透過 std::async 來啟動。

#include <future>
#include <iostream>

int add(int a, int b) {
    return a + b;
}

int main() {
    std::future<int> f = std::async(std::launch::async, add, 2, 3);
    std::cout << f.get() << '\n'; // 5
}

注意三件事。

  • 第一──要 include <future>,因為 std::async 與 std::future 都在那裡。
  • 第二──我們傳入了策略 std::launch::async。為什麼要明確寫出來,而不是假設「async」?因為不寫策略很容易讓人對行為產生誤解,這是本講重點之一。
  • 第三──get() 看起來像是「好吧,拿到結果」。今天我們不深究 future 的生命週期,但重要的是:get() 是你實際取得結果的點,如果結果尚未就緒,你會在此等待。

3. 啟動策略 std::launch

現在進入最重要的部分。

std::async 擁有透過 std::launch 指定的啟動策略。

std::launch::async

這個策略表示:「把任務以非同步方式執行」。通常代表會在獨立的執行緒上執行(或由實作提供的等效非同步活動)。

也就是說你的 callable 會立即開始執行,而主執行緒會繼續運行。

std::launch::deferred

這個策略表示:「把執行延後,直到有人請求結果為止」。

重點是:使用 deferred 時,任務會在呼叫等待(get() 或 wait())的那個執行緒中執行,通常就是在 main。這不是並行,這是「懶惰啟動」。

為什麼有用?例如你想描述一個計算,但只在確實需要時才啟動。類似「我會準備一份報表,但只有當有人按下『顯示報表』才真的做」。

示範:deferred — 「之後在同一執行緒執行」

來看一個會印出事件順序的例子。這裡不需要回傳特殊結果──重點是觀察執行時點。

#include <future>
#include <iostream>

int main() {
    auto f = std::async(std::launch::deferred, [] {
        std::cout << "work\n";
        return 10;
    });

    std::cout << "before get\n";
    std::cout << f.get() << '\n';
}

預期輸出:

// before get
// work
// 10

意思是:在你呼叫 get() 之前,「工作」甚至還沒開始。

「何時是真正的 async」:預設策略的陷阱

這裡開始出現讓新手常說的話:「我呼叫了 std::async,那它就一定是 async!」但標準庫可能會回你一個微笑的否定。

如果你在呼叫 std::async 時不指定明確策略,通常會得到一個「兩者都允許」的情況:實作可以選擇非同步執行,也可以延遲執行。實務上這表示:在某個平台上你會看到並行,另一個平台上看到延遲,有時候行為還會不同。

因此課程建議遵循一條規則:如果你需要確保並行,就明確寫出 std::launch::async。

我們用「故意做長一點」的工作來感受這差異。

#include <chrono>
#include <future>
#include <iostream>
#include <thread>

int slow() {
    std::this_thread::sleep_for(std::chrono::seconds{1});
    return 7;
}

int main() {
    auto f = std::async(std::launch::async, slow);
    std::cout << "doing something...\n";
    std::cout << f.get() << '\n'; // 7
}

如果這裡使用的是 deferred 策略,則「doing something...」 可能會被印出,但 slow() 會在 get() 時才開始,這會造成所謂的「偽非同步」:好像「我在做其他事」,但實際上並沒有平行計算在進行。

示意圖:async vs deferred

為了把這件事從「空中魔術」落實,畫成簡單示意圖。

flowchart TD
    A[main: calls std::async] --> B{launch policy}
    B -->|launch::async| C[task starts immediately in another thread/activity]
    B -->|launch::deferred| D[task does NOT start immediately]
    D --> E["main: later calls get()/wait()"]
    E --> F[task runs in main thread]
    C --> G["main: later calls get()/wait()"]
    G --> H[main receives the result]
    F --> H

這個示意圖是你的「反神秘化」工具。懂了它,就不會寫那種「應該會並行吧」的程式了。

4. 結果、生命週期與設計

std::future<void>:當不需要值但要等待完成時

有時你不需要回傳值,你只需要等待任務完成。例如「保存日誌」、「清除快取」、「優雅關閉連線」等情境。

這時回傳 void 很方便──得到的是 std::future<void>。

#include <future>
#include <iostream>

void work() {
    // 做一些有用的事
}

int main() {
    std::future<void> f = std::async(std::launch::async, work);
    f.get(); // 等待完成;沒有值
    std::cout << "done\n"; // done
}

注意:對於 future<void>,get() 的語意就是「等待並檢查是否成功結束」。今天不深入討論 future 中的例外處理,但重點是:future<void> 也是一種「結果通道」,只是結果表現為「任務成功完成」。

參數與生命週期:為何 async 會「突然掛掉」

現在一個常讓新手出問題的重要觀念要說明。

std::async 會在「某時或某處」啟動工作(或並行執行)。因此任務內使用到的東西必須保證活得夠久。

如果你捕捉了對本地變數的引用,而該函式已結束——引用就會變成懸空引用。這會導致「隨機當機」、「隨機亂資料」或「只有在我機器上才會出錯」這類問題。

危險範例──以引用捕捉本地變數但未保證其生命週期:

#include <future>
#include <string>

int main() {
    std::string name = "Alice";

    auto f = std::async(std::launch::async, [&] {
        return name.size();
    });

    return 0; // 風險:任務可能仍在讀取 name
}

較安全的做法──以值捕捉:

#include <future>
#include <string>

int main() {
    std::string name = "Alice";

    auto f = std::async(std::launch::async, [name] {
        return name.size();
    });

    (void)f.get();
}

是的,這會複製資料。但對於字串,複製是換來安心的代價。等程式碼正確且有必要時,再考慮優化。

設計要點:std::async 不是「fire-and-forget」

新手很容易出現誘惑:「啊!我現在到處用 std::async,程式馬上變快!」這有點像把無線電分發給每個員工,就以為公司效率大幅提升。

std::async 假設你保有 future,也就是你有一個點去取得結果或等待完成。這是一種紀律:若你「丟失」了 future,你也就失去對任務的控制。

典型的好習慣是:建立 future → 儲存到變數 → 在適當時候有意識地呼叫 get()(或 wait(),後面會講)。

未來思考:future 與「共用結果儲存格」

我們今天不深入 future 的生命週期,但要知道內部存在一個「結果儲存格」,且多年來標準庫一直在細化這個儲存格如何建立與釋放的表述。連 future::get 的語意,也曾討論到共用狀態何時應被「分離」或釋放。

這不是要嚇你,而是讓你知道:future 不是「盒子裡的 int」那麼簡單。它是一個有生命週期的物件,你需要以有紀律的方式對待它。

5. 實作範例:命令列報表產生器

為了讓範例不是抽象的「真空球」,我們來構建一個可逐步擴充的小應用。

概念很簡單:我們有一個任務清單(不是執行緒,而是專案中的任務),要產生「報表」——這是個昂貴操作(實務上:讀取、解析、聚合)。在報表產生時,主執行緒仍能繼續做別的事(在教學範例中,至少可以印出訊息)。

步驟 1:資料模型與「耗時函式」

#include <chrono>
#include <string>
#include <thread>
#include <vector>

struct Task {
    std::string title;
    int estimateHours;
};

int buildReport(const std::vector<Task>& tasks) {
    std::this_thread::sleep_for(std::chrono::milliseconds{300}); // 模擬工作
    int total = 0;
    for (const auto& t : tasks) total += t.estimateHours;
    return total;
}

函式回傳 int:總估計小時數。

步驟 2:使用明確策略透過 std::async 啟動報表

#include <future>
#include <iostream>
#include <vector>

int main() {
    std::vector<Task> tasks{{"Parse logs", 5}, {"Generate PDF", 2}};

    auto futureTotal = std::async(std::launch::async, buildReport, std::cref(tasks));
    std::cout << "report started...\n";

    int total = futureTotal.get();
    std::cout << "total hours = " << total << '\n'; // total hours = 7
}

這裡出現一個新細節:std::cref(tasks)。這是把參數以參考的方式傳遞(而不是複製整個 vector)的便利方法。如果目前不確定,也可以直接傳值複製——對教學用的資料大小來說沒問題。但示範 std::cref 是因為它常見,且有助於避免在不同執行緒間搬移大量資料。

重要的是:我們明確指定 std::launch::async,因為今天要學的是不要靠猜測來判斷是否並行。

再一個示範:同時執行兩個計算

做一個範例,同時啟動兩個計算。即使不量測時間,你也會看到做法上的差異。

#include <chrono>
#include <future>
#include <iostream>
#include <thread>

int slowCalc(int x) {
    std::this_thread::sleep_for(std::chrono::milliseconds{200});
    return x * 2;
}

int main() {
    auto a = std::async(std::launch::async, slowCalc, 10);
    auto b = std::async(std::launch::async, slowCalc, 20);

    std::cout << a.get() + b.get() << '\n'; // 60
}

如果兩個任務真的同時啟動,總耗時會接近 200 ms(加上額外開銷),而不是 400 ms。如果把策略改成 deferred,兩個任務會在 get() 時才各自開始,且由於 get() 是依序呼叫,通常會變成「一個接著一個」。

6. 使用 std::async 與啟動策略的常見錯誤

錯誤 #1:在沒有策略的情況下使用 std::async,卻期待保證的並行。
這種程式在你的機器上可能「偶然」並行,但在別的平台上卻可能被延遲執行。如果並行是邏輯所依賴的(例如你想遮蔽磁碟/網路/CPU 的等待),最好明確使用 std::launch::async,讓契約對你和讀程式的人都清楚。

錯誤 #2:以為 std::launch::deferred 是「延遲一段時間後啟動」。
deferred 並不表示「等 2 秒後開始」。它的意思是「根本不開始,除非我要求結果」。啟動時點由 get()/wait() 決定,而非計時器。

錯誤 #3:捕捉本地變數的引用,而該變數可能在任務完成前就已消失。
這是並發 lambda 的經典陷阱。你看到變數很「活」,但過了幾毫秒 main 就結束,變數被銷毀,任務還在嘗試存取它。若無法保證生命週期,應以值捕捉或以讓擁有者壽命更長的方式傳遞資料。

錯誤 #4:把 future 「丟掉」,以為任務會自行存在。
std::async 不是 detach。若你不保留 future,就失去了同步點與觀察結果的機會。把 future 當成合約的一部分:啟動任務 → 保留把手 → 之後有意識地等待或取得結果。

錯誤 #5:把所有東西都塞進 std::async,包括操作共用資料結構。
std::async 會啟動計算,但不會免除並發規則。如果任務內存取共用資料,你仍需要明確協議:誰是擁有者、誰是讀者、在哪裡上鎖、哪裡使用條件變數等。std::async 是取得「之後結果」的方法,而非萬能的資料競爭護身符。

1
任務
C++ SELF, 等級 71, 課堂 0
上鎖
非同步平方和
非同步平方和
1
任務
C++ SELF, 等級 71, 課堂 0
上鎖
延遲交付
延遲交付
1
任務
C++ SELF, 等級 71, 課堂 0
上鎖
兩個加速器
兩個加速器
1
任務
C++ SELF, 等級 71, 課堂 0
上鎖
背景寫入
背景寫入
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION