執行緒池 (Thread Pools)

Overview Table

概念 重點
執行緒池基本概念 固定數量工作執行緒 + 任務佇列;任務並發執行,免除逐執行緒管理
最簡單的執行緒池(代碼 9.1) thread_safe_queue<std::function<void()>> + worker 迴圈;無回傳值、不可等待
function_wrapper(代碼 9.2) 型別擦除的只可移動包裝,讓佇列能存放 std::packaged_task<>
可等待任務的 submit()(代碼 9.2) 包成 packaged_task → 回傳 std::future<>,呼叫端可等待結果與異常
parallel_accumulate 改寫(代碼 9.3) 工作量依資料塊數(而非執行緒數)劃分,最大化可擴展性
run_pending_task()(代碼 9.4–9.5) 等待子任務時順手執行佇列中其他任務,避免相依任務造成死結 (deadlock)

執行緒池基本概念

多數系統中,替每個任務都建立一條執行緒並不切實際。執行緒池 (thread pool) 提供固定數量的工作執行緒共用給所有任務(如同公司的共用車輛):提交的任務掛在任務佇列上,工作執行緒從佇列取出任務執行,完成後再取下一個。

                    ┌────────────────────────────────┐
  submit(f) ──────► │ work_queue (thread_safe_queue) │
                    └───────────────┬────────────────┘
                            try_pop │ (取不到就 yield)
              ┌─────────────────────┼─────────────────────┐
              ▼                     ▼                     ▼
        ┌──────────┐         ┌──────────┐          ┌──────────┐
        │ worker 0 │         │ worker 1 │   ...    │ worker N │   N = hardware_concurrency()
        │ task()   │         │ task()   │          │ task()   │
        └──────────┘         └──────────┘          └──────────┘

最簡單的執行緒池(代碼 9.1)

成員:std::atomic_bool donethread_safe_queue<std::function<void()>>(第 6 章的執行緒安全佇列)、std::vector<std::thread>join_threads joiner(第 8 章的 RAII 匯入類)。

void worker_thread() {
    while (!done) {
        std::function<void()> task;
        if (work_queue.try_pop(task))
            task();                      // 取到任務就執行
        else
            std::this_thread::yield();   // 沒任務先讓出 CPU
    }
}
成員宣告順序是刻意的

donework_queue 必須宣告在 threads 之前、threadsjoiner 之前。成員按宣告的反序銷毀,如此才能保證:先匯入執行緒(joiner)→ 再銷毀 threads → 最後才銷毀 worker 仍會存取的 done 與佇列。

簡單執行緒池的侷限

任務無法回傳結果、無法等待;若任務之間互相依賴(等待彼此完成),固定數量的執行緒會全部卡在等待、沒人做事,形成死結 (deadlock)。簡單場景用 std::async 反而更好(見第 8 章)。

可等待任務:function_wrapper 與新版 submit()(代碼 9.2)

想等待任務完成並取得結果,需要 submit() 回傳 std::future<> —— 把任務包進 std::packaged_task<>。但 std::packaged_task<> 只可移動、不可複製,而 std::function<> 要求可複製建構的函式物件,所以佇列不能再用 std::function<void()>

解法:自製 function_wrapper —— 一個帶 operator()型別擦除 (type erasure) 類:

template <typename FunctionType>
std::future<typename std::result_of<FunctionType()>::type>
submit(FunctionType f) {
    typedef typename std::result_of<FunctionType()>::type result_type;
    std::packaged_task<result_type()> taskmove(f);
    std::future<result_type> res(task.get_future());
    work_queue.pushmove(task);   // packaged_task 不可複製,只能 move
    return res;
}

用執行緒池改寫 parallel_accumulate(代碼 9.3)

unsigned long const block_size = 25;
unsigned long const num_blocks = (length + block_size - 1) / block_size;
// 每塊 submit 一個任務,最後在 futures[i].get() 累加
任務塊太小反而更慢

向池提交任務有固定開銷(打包、入列、future 儲存)。任務粒度太細時,執行緒池版本可能比單執行緒還慢,劃分工作塊時必須權衡。

等待相依任務:run_pending_task() 避免死結(代碼 9.4–9.5)

以平行快速排序為例:每層遞迴把「小於中軸」的半邊 submit() 給池,自己排另一半、再等待 future。問題:池只有固定數量執行緒,當所有 worker 都在等待別的資料塊排序、卻沒有執行緒能去執行那些任務時,就死結了。

解法:在 thread_pool 新增 run_pending_task() —— 把 worker_thread() 主迴圈中「取一個任務執行,否則 yield」的那一次動作抽出來,讓等待中的執行緒也能幫忙消化佇列:

while seconds(0) ==
       std::future_status::timeout) {
    pool.run_pending_task();   // 等待期間不閒著,幫忙執行佇列上的任務
}
模式:「等待時也做事」

wait_for(0秒) 輪詢 future 狀態 + run_pending_task(),是解決「池內任務等待池內任務」死結的標準手法。更進階的池可在等待函式中內建此邏輯,或優先執行被等待的任務。

仍不理想:單一佇列成為瓶頸

此版本中所有 submit()run_pending_task() 都存取同一個全域佇列,執行緒數增加後佇列爭用與快取乒乓 (cache ping-pong) 會拖垮效能 —— 解法見 09-Advanced-Thread-Management/02-Work-Stealing-and-Queue-Contention


Exam/Test Patterns

情境/關鍵字 答案
「簡單執行緒池的任務為何用 std::function<void()>?」 任務無回傳值、不需等待;std::function 足以封裝任意可呼叫物件
packaged_task 為何不能放進 std::function?」 packaged_task 只可移動;std::function 要求可複製建構 → 需自製型別擦除的 function_wrapper
「submit 如何讓呼叫端等待任務?」 包成 std::packaged_task<result_type()>,先 get_future()std::move 入列,回傳 std::future<>
「done/queue → threads → joiner 的宣告順序」 反序銷毀:確保 joiner 先匯入執行緒,worker 存取的成員最後銷毀
「worker 取不到任務時做什麼?」 std::this_thread::yield(),讓出 CPU 給其他執行緒推送任務
「工作量該依執行緒數還是塊數劃分?」 塊數;塊數與硬體無關,執行緒越多並發塊越多,擴展性佳
「池內任務等待池內任務 → 全部卡死」 死結;等待迴圈中呼叫 run_pending_task(),等待時幫忙執行佇列任務
「任務很小、數量多,用池反而變慢」 提交任務有開銷;任務粒度太細不划算,可能不及單執行緒
「任務中拋出異常會怎樣?」 異常存入 future,呼叫 get() 時拋出;未完成任務由池解構時丟棄