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 是取得「之後結果」的方法,而非萬能的資料競爭護身符。
GO TO FULL VERSION