並行 API練習題 (Practice - Concurrency API)
Related Concepts
- 08-Concurrency-API/01-Task-Based-Vs-Thread-Based
- 08-Concurrency-API/02-Std-Launch-Async
- 08-Concurrency-API/03-Threads-Unjoinable-On-All-Paths
- 08-Concurrency-API/04-Thread-Handle-Destructor-Behavior
- 08-Concurrency-API/05-Void-Futures-For-Events
- 08-Concurrency-API/06-Atomic-Vs-Volatile
| 關鍵字 | 答案 |
|---|---|
| 非同步函式的回傳值 / 例外 | std::async + future 的 get();std::thread 沒有管道,丟例外 → std::terminate |
noexcept 函式 + std::thread 建構 |
仍可能丟 std::system_error(thread 耗盡) |
std::async 預設 launch policy |
std::launch::async | std::launch::deferred,由系統決定 |
wait_for 迴圈永不終止 |
任務被 deferred;先用 fut.wait_for(0s) 檢查 |
解構 joinable 的 std::thread |
程式終止(std::terminate) |
| 所有路徑 unjoinable | RAII 物件(ThreadRAII),解構子中 join 或 detach |
| future 解構「通常」做什麼 | 只銷毀資料成員、遞減 shared state 參考計數 |
| future 解構「何時阻塞」 | 來自 std::async + 實際 policy 為 async + 最後一個指涉的 future |
| 一次性事件通訊、不傳資料 | std::promise<void> + std::future<void>(真正阻塞、免 mutex) |
| spurious wakeup 只影響誰 | 只有 condvar;flag 與 future 皆免疫 |
| 多執行緒共享資料、不用 mutex | std::atomic;volatile 是 data race → UB |
| memory-mapped I/O / 感測器 | volatile(redundant loads / dead stores 不可優化) |
Question 1 - 回傳值與例外 [recall]
同事用
std::thread t(doAsyncWork);執行會回傳int的函式,問你要怎麼拿到回傳值;另外若doAsyncWork丟出例外會發生什麼事?
std::thread API 沒有直接管道取得非同步函式的回傳值;應改用 task-based 寫法 auto fut = std::async(doAsyncWork);,透過 future 的 get() 取得結果。
若 thread-based 下函式丟出例外,程式會呼叫 std::terminate 直接終止;task-based 下例外會存入 shared state,由 get() 重新拋出、可以 catch。
Question 2 - Thread 三種意義與耗盡 [recall]
面試官問:「C++ 並行程式中 thread 有哪三種意義?另外
doAsyncWork已宣告noexcept,std::thread t(doAsyncWork);還可能丟例外嗎?」
三種意義:hardware threads(CPU core 上真正執行運算)、software threads(OS 跨 process 管理與排程,可多於 hardware threads)、std::thread(C++ 物件,底層 software thread 的 handle,可能為 null handle——預設建構、被 move 走、已 join、已 detach)。
仍可能丟例外:software threads 是有限資源,耗盡時 std::thread 建構會丟 std::system_error——例外來自建立執行緒,與函式本身是否 noexcept 無關。
Question 3 - 預設 launch policy [recall]
程式碼審查時你看到
auto fut = std::async(f);,作者堅稱「f 一定會在另一條執行緒上跑」——這個說法正確嗎?
不正確。預設 launch policy 是 std::launch::async | std::launch::deferred(兩者 OR),由執行期系統決定:f 可能真正非同步執行,也可能被 deferred——延遲到對 future 呼叫 get/wait 時才在呼叫者的執行緒上同步執行;若從未呼叫 get/wait,f 永遠不會執行。
要保證真正非同步,必須明確傳入 std::asyncasync, f。
Question 4 - 高負載才出現的無窮迴圈 [application]
產品在測試環境一切正常,但正式環境高負載時
while (fut.wait_for(100ms) != std::future_status::ready) { ... }偶爾永遠跑不完(fut 來自std::async(f))——請診斷原因並給出修正。
高負載(oversubscription / thread exhaustion)時,預設政策的任務最容易被排為 deferred;deferred 任務的 wait_for 永遠回傳 std::future_status::deferred,不可能等於 ready,迴圈因此永不終止。這正是測試抓不到、只在重載下現形的典型 bug。
修正:先以零逾時探測 if (fut.wait_for(0s) == std::future_status::deferred),是 deferred 就直接對 fut 呼叫 get/wait 同步執行;否則才進入 timeout 迴圈。或一開始就指定 std::launch::async。
Question 5 - 預設政策的安全檢查表 [recall]
學弟問:「什麼情況下用
std::async的預設 launch policy 才安全?哪些情況必須改指定std::launch::async?」
以下全部成立時,預設政策才安全:(1) 任務不需要與呼叫 get/wait 的執行緒併發執行;(2) 讀寫哪條執行緒的 thread_local 變數無關緊要;(3) 保證會對 future 呼叫 get/wait,或可接受任務永不執行;(4) 使用 wait_for/wait_until 的程式碼已考慮 deferred 狀態。
任一條不成立(例如 f 依賴 TLS、或必須真正併發),就明確傳 std::launch::async(或用完美轉發包裝的 reallyAsync)。
Question 6 - Joinable 解構的後果 [recall]
某函式在
return false;的路徑上讓一個仍 joinable 的std::thread離開 scope——會發生什麼事?又有哪些std::thread是 unjoinable?
解構 joinable 的 std::thread 會使程式終止(std::terminate)——不是 UB、也不是隱式 join 或 detach。「所有路徑」包含流到 scope 結尾、return、continue、break、goto 與例外。
Unjoinable 的四種情況:預設建構、被 move 走、已 join、已 detach。注意:底層執行緒已跑完的 std::thread 仍是 joinable。
Question 7 - ThreadRAII 設計要點 [application]
你要實作一個
ThreadRAII類別在解構子中自動 join/detach。請說明:為何解構子要先呼叫t.joinable()?為何std::thread成員要宣告在最後?宣告了解構子之後 move 操作怎麼辦?
(1) 對 unjoinable 的 thread 呼叫 join()/detach() 是未定義行為——使用者可能已透過 get() 對 t 做過 join/detach/move,所以必先檢查。此檢查與後續動作之間沒有 race:joinability 只能經成員函式改變,若真有並行呼叫,race 在呼叫端。
(2) std::thread 初始化後可能立即開始執行,宣告在成員列表最後可保證它執行時,其前面的成員都已初始化、可被安全存取。
(3) 宣告解構子會抑制編譯器生成 move 操作,需以 ThreadRAII(ThreadRAII&&) = default; 與 move assignment = default 顯式要回(Item 17)。
Question 8 - 為何選擇終止程式 [analysis]
標準委員會為何讓 joinable
std::thread的解構直接終止程式,而不是隱式 join 或隱式 detach?以doWork中 lambda 以 reference 捕獲區域變數goodVals的情境,分析兩個替代方案各自的問題。
Implicit join:解構子等待底層執行緒完成 → doWork 明明已判定不需要計算結果(conditionsAreSatisfied() 回傳 false),卻仍苦等 filter 跑完千萬筆——反直覺的效能異常,極難追查。
Implicit detach:doWork 返回後 stack frame 被回收,背景 lambda 仍繼續對「曾是 goodVals」的記憶體 push_back;後續函式 f 佔用同一段 stack 時,其 stack frame 內容會憑空改變——除錯噩夢級的未定義行為。
兩者的共同缺點是問題延遲且隱蔽地浮現;程式終止雖激烈,但後果明確、立即可見,因此被認為是「爛選項中最不爛的」。這也解釋了為何需要 RAII 類別讓程式設計者明確選擇 join 或 detach。
Question 9 - Future 解構子的正常與例外 [recall]
面試官問:「future 的解構子通常做什麼?什麼情況下它會阻塞?」
正常行為:只銷毀 future 的資料成員,並遞減 shared state 的參考計數——不 join、不 detach、不執行任何東西;對非同步任務等同隱含 detach,若是 deferred 任務的最後一個 future,該任務永不執行。future 解構永不導致程式終止。
唯一例外(三條件全部成立才阻塞至任務完成,等同隱含 join):(1) shared state 來自 std::async;(2) 任務的實際 launch policy 為 std::launch::async;(3) 它是指涉該 shared state 的最後一個 future(std::shared_future 只有最後一份才阻塞)。
注意:future API 無法查詢是否來自 std::async,所以持有 future 的容器或類別解構時都「可能」阻塞。
Question 10 - packaged_task 的 future [recall]
同事擔心
auto fut = pt.get_future();(pt 是std::packaged_task<int()>)的 fut 會在解構時阻塞,並問把 pt 交給std::thread時為何要std::move。
不會阻塞:來自 std::packaged_task 的 shared state 不是 std::async 建立的,fut 必走正常解構行為。終止 / join / detach 的抉擇由操作底層 std::thread 的程式碼決定——什麼都不做則 t 在 scope 結尾仍 joinable → 程式終止;已 join 或已 detach 則 fut 解構無事可做。
std::packaged_task 不可複製,傳入 std::thread 建構子時必須以 std::move(pt) 轉為 rvalue(Item 23)。另外若打算用 std::async 執行任務,就沒必要先建 packaged_task。
Question 11 - 事件通訊四方案比較 [recall]
偵測任務要通知反應任務「事件已發生」,candidate 方案有:純 condvar、輪詢 atomic flag、condvar+flag、
std::promise<void>+ future——各有什麼問題?void future 的兩個代價是什麼?
純 condvar:需要多餘的 mutex(code smell);notify 先於 wait 則通知遺失、永久等待;有 spurious wakeup(且反應任務可能無法自行驗證事件)。
輪詢 flag(while (!flag);):無上述問題,但不是真正阻塞——佔用硬體執行緒、context switch 成本、耗電。
condvar + flag:正確但彆扭(stilted):notify 說「可能發生」、flag 才說「確定發生」,雙重機制。
void future:免 mutex、set 先於 wait 仍正確、免疫 spurious wakeup、真正阻塞;代價是 (1) shared state 通常在 heap 配置,(2) std::promise 只能 set 一次——one-shot,不能重複使用。
Question 12 - 暫停執行緒與懸掛 [analysis]
detect()用ThreadRAII(DtorAction::join)包住一條執行緒,lambda 內先p.get_future().wait()暫停自己;若在p.set_value()之前的程式碼拋出例外,會發生什麼事?請完整推理因果鏈,並說明多個反應任務的版本要怎麼寫。
因果鏈:例外使 p.set_value() 永遠不會被呼叫 → lambda 中的 wait() 永不返回 → 執行緒永不結束 → 例外離開 scope 時 ThreadRAII 解構子執行 join(),但 join 永遠等不到執行緒結束 → detect 懸掛(hang)。這比 Item 37 所說的「效能異常」更糟——join-on-destruction 與暫停執行緒併用時可能直接卡死;Meyers 將解法留作讀者習題。
多反應任務版本:以 auto sf = p.get_future().share(); 取得 std::shared_future<void>(share() 會轉移 shared state 所有權),每條反應執行緒的 lambda 必須傳值捕獲 [sf] 自己的拷貝再 sf.wait();一次 p.set_value() 即可喚醒全部,最後逐一 join() 使所有執行緒 unjoinable。
Question 13 - atomic vs volatile 分工 [recall]
面試快問:
std::atomic和volatile各自的用途是什麼?兩條執行緒各對volatile int vc(0);執行一次++vc,結果一定是 2 嗎?
std::atomic 是並行工具:多執行緒共享資料且不用 mutex——保證所有操作(含 RMW)的原子性,且預設 sequential consistency 禁止寫入前的程式碼被重排到其後。volatile 是特殊記憶體工具(如 memory-mapped I/O):告訴編譯器不得優化掉 redundant loads / dead stores,對原子性與重排毫無保證。
不一定是 2:++ 是 read-modify-write 三步操作,兩執行緒可能都讀到 0、各寫回 1;且同時讀寫非 atomic 又無 mutex 保護的記憶體就是 data race → 未定義行為,結果根本不可預測。換成 std::atomic<int> 則必定是 2。兩者用途不同,甚至可合用:volatile std::atomic<int>。
Question 14 - atomic 複製與 auto 推導 [application]
下列程式碼哪幾行無法編譯?該怎麼改?另外若
x是volatile int,auto y = x;中 y 的型別是什麼?
std::atomic<int> x; auto y = x; y = x;
auto y = x; 與 y = x; 都無法編譯:std::atomic 的 copy 建構與 copy 賦值皆被 = delete(硬體無法在單一原子操作中「讀 x 又寫 y」;move 操作也因 Item 17 的生成規則而不存在)。正確寫法:std::atomic<int> y(x.load()); 與 y.store(x.load());——但 load 與 store 是分開的呼叫,整條敘述並非單一原子操作,且編譯器可把 x.load() 存進暫存器只讀一次,故 atomic 不適合特殊記憶體。
若 x 是 volatile int:auto 推導非參考非指標型別時會丟棄 const/volatile,y 的型別是 int(對 y 的冗餘讀寫可優化,但對 x 的每次讀取都必須執行)。
| Item | 核心模式 | 一句話答案 |
|---|---|---|
| 35 | task vs thread | 預設用 std::async(回傳值、例外、thread 管理都有人扛);需要 native_handle 等底層功能才用 std::thread |
| 36 | launch policy | 預設 = async | deferred;必須真正非同步就明寫 std::launch::async;wait_for 迴圈先以 wait_for(0s) 防 deferred |
| 37 | joinability | joinable 解構 = 程式終止;用 RAII 在所有路徑上 join/detach;thread 成員宣告在最後 |
| 38 | future 解構 | 通常只銷毀成員 + 減參考計數;唯 std::async + 實際 async policy + 最後一個 future 才阻塞(隱含 join) |
| 39 | 一次性事件 | std::promise<void> + future<void>:免 mutex、免 spurious wakeup、真正阻塞;代價為 heap shared state 與 one-shot |
| 40 | atomic vs volatile | 並行 → std::atomic(原子性 + 禁止重排);特殊記憶體 → volatile(不准優化);可合用 |