學習目標

讀完本章並完成練習後,你應該能夠:

  1. 解釋並行(concurrency)與平行(parallelism)的差異,並判斷何時值得引入多執行緒
  2. 使用 std::thread 以函式、lambda、成員函式、函式物件建立執行緒,並正確地 join / detach
  3. 理解 joinable 狀態,避免「可 joinable 的執行緒被銷毀」導致 std::terminate()
  4. 正確地以值、std::refstd::move 傳遞參數給執行緒
  5. 說明資料競爭(data race)為何是未定義行為,並能寫出可重現的範例
  6. 使用 std::mutex 搭配 RAII 鎖(lock_guardunique_lock、C++17 的 scoped_lock)保護臨界區域
  7. 分析死鎖的四個必要條件,並以「固定鎖定順序」、std::lockstd::scoped_lock 預防
  8. 使用 std::condition_variable 實作生產者-消費者模式,並理解假喚醒與謂詞等待
  9. 使用 std::atomic 進行無鎖計數,並對記憶體順序有基本概念
  10. 使用 std::async / std::future / std::promise / std::shared_future 進行任務式(task-based)非同步程式設計,並讓例外能跨執行緒傳遞
  11. 認識 thread_local 與「優先任務式並行、最小化共享狀態、優先不可變資料」等最佳實踐

編譯提醒:本章所有程式都使用執行緒,請務必加上 -pthread

bash g++ -std=c++17 -Wall -pthread filename.cpp -o output

在 Linux/macOS 上忘記 -pthread 通常會出現連結錯誤(如 undefined reference to pthread_create)或執行期拋出 std::system_error


一、為什麼需要多執行緒?

現代 CPU 早已不再單純靠提高時脈來加速,而是把核心數量越堆越多。一支只用單一執行緒的程式,無論機器有 4 核、16 核還是 64 核,永遠只能用到其中一顆核心。多執行緒正是讓你的程式「同時做很多事」的關鍵工具。

使用場景

  • 提升效能(CPU 密集):把可平行的計算分散到多個核心,例如影像處理、矩陣運算、蒙地卡羅模擬。
  • 改善回應性(responsiveness):GUI / 遊戲 / 伺服器中,把耗時工作丟到背景執行緒,避免主執行緒卡住而畫面凍結。
  • I/O 密集任務:同時等待多個網路請求、磁碟讀寫,等待期間 CPU 可以做別的事。
  • 背景處理:日誌寫入、自動存檔、資料同步、垃圾回收等守護任務。

並行 vs 平行(這是兩件不同的事!)

很多人把這兩個詞混用,但它們指的是不同概念:

概念 說明 比喻
並行(Concurrency) 多個任務在時間上重疊地推進,但不一定同時執行。單核 CPU 也能並行(靠快速切換) 一位廚師同時照顧煎鍋、烤箱、燉鍋,不斷在它們之間切換
平行(Parallelism) 多個任務在同一瞬間真正同時執行,需要多核硬體 四位廚師各自掌管一道菜,真的同時在動

Rob Pike 的名言:「並行是關於程式的結構(dealing with lots of things at once),平行是關於程式的執行(doing lots of things at once)。」

單核並行(時間切片)          多核平行(真正同時)
─────────────────────        ─────────────────────
核心0: A B A B A B            核心0: A A A A A A
                             核心1: B B B B B B
時間 →                        時間 →

多執行緒不是萬靈丹

引入執行緒會帶來成本與風險,不是越多執行緒越快

  • 建立/切換執行緒有開銷(context switch、快取失效)
  • 共享資料需要同步,同步本身有成本,還可能引入資料競爭、死鎖
  • 除錯困難:bug 往往不可重現(取決於排程時機)

C++ Core Guidelines CP.1:假設你的程式碼會在多執行緒環境中執行。 CP.2:避免資料競爭。 這兩條是整章的核心精神。

Amdahl 定律(為什麼加核心不會線性變快)

若程式中只有比例 p 的部分能平行化,使用 N 個處理器的理論加速上限為:

[ \text{speedup} = \frac{1}{(1-p) + \frac{p}{N}} ]

意思是:只要有一段「序列」的部分無法平行,再多核心也救不了它。例如 p = 0.9N → ∞,加速最多也只有 10 倍。先量測、找出瓶頸,再決定要不要平行化


二、std::thread:建立與管理執行緒

std::thread 定義在 <thread>,是 C++ 對作業系統執行緒的標準封裝。

四種建立方式

#include <thread>
#include <string>

void task() { /* ... */ }                       // 1. 一般函式

struct Worker {
    void run(int id) { /* ... */ }              // 成員函式
    void operator()() const { /* ... */ }        // 函式物件
};

int main() {
    // 1. 一般函式
    std::thread t1(task);

    // 2. lambda(最常用)
    std::thread t2([] { /* ... */ });

    // 3. 成員函式:傳「函式指標 + 物件指標 + 參數」
    Worker w;
    std::thread t3(&Worker::run, &w, 42);

    // 4. 函式物件
    std::thread t4(Worker{});

    t1.join(); t2.join(); t3.join(); t4.join();
}

建立執行緒後,新執行緒會立刻開始執行傳入的可呼叫物件(callable)。

join 與 detach:每個執行緒只能擇一

方法 行為 適用場景
join() 阻塞當前執行緒,直到目標執行緒完成 需要等待結果或確保完成
detach() std::thread 物件分離,讓它在背景獨立執行 守護執行緒、射後不理的背景任務

致命陷阱:若 std::thread 物件在 joinable(既未 join 也未 detach)的狀態下被解構,解構子會呼叫 std::terminate(),整個程式直接當掉!

void bad() {
    std::thread t([]{ /* ... */ });
    // 忘記 t.join() 或 t.detach()
}   // ← t 解構時仍 joinable → std::terminate()!程式崩潰

joinable 狀態

std::thread t([]{ /* ... */ });
std::cout << t.joinable();  // true:有一個關聯的執行緒
t.join();
std::cout << t.joinable();  // false:已 join,不再關聯

一個 std::thread「沒有關聯執行緒」(joinable 為 false)的情況:預設建構、已被 move 走、已 join 或已 detach

用 RAII 包裝執行緒(避免忘記 join)

既然「忘記 join 會 terminate」,最穩健的做法是用 RAII 自動處理(這是 C++20 std::jthread 標準化前的常見慣用法):

class ThreadGuard {
    std::thread t_;
public:
    explicit ThreadGuard(std::thread t) : t_(std::move(t)) {}
    ~ThreadGuard() { if (t_.joinable()) t_.join(); }  // 解構時自動 join

    ThreadGuard(const ThreadGuard&) = delete;          // 不可複製
    ThreadGuard& operator=(const ThreadGuard&) = delete;
};

C++20 引入了 std::jthread,解構時自動 join,並支援 std::stop_token 協作式取消。本章鎖定 C++17,但值得知道未來方向。

傳遞參數:值、參考、移動

std::thread 的建構子會複製(decay-copy)所有參數到執行緒自己的儲存空間。這帶來幾個重要後果:

void by_value(int x);
void by_ref(int& x);
void by_const_ref(const std::string& s);

int n = 10;
std::string name = "Alice";

std::thread t1(by_value, n);              // OK:複製 n
std::thread t2(by_ref, std::ref(n));      // 要改到外部變數 → 必須 std::ref
std::thread t3(by_const_ref, name);       // OK:複製 name(注意:是複製不是參考!)
std::thread t4([](std::string s){}, std::move(name));  // 移動,避免複製成本

常見陷阱:即使函式參數是 const std::string&std::thread 還是會先複製字串,再把參考綁到那份複本。若你真的要共享同一個物件並修改它,必須用 std::ref / std::cref,而且要保證該物件的生命週期長於執行緒

hardware_concurrency:機器能真正平行多少?

unsigned int n = std::thread::hardware_concurrency();
std::cout << "建議的平行執行緒數:" << n << "\n";  // 例如 8

回傳「硬體支援的並行執行緒數」(通常等於邏輯核心數)。這只是一個提示,可能回傳 0(表示無法判斷)。用它來決定執行緒池大小時記得處理 0 的情況。

對應程式碼:完整可執行範例見 threads.cpp,涵蓋四種建立方式、join/detach、joinable、各種參數傳遞、hardware_concurrencysleep_for / yield


三、資料競爭與互斥鎖

什麼是資料競爭(Data Race)?

當以下三個條件同時成立時,就發生資料競爭:

  1. 兩個以上的執行緒存取同一個記憶體位置
  2. 其中至少一個是寫入
  3. 這些存取沒有同步(沒有用任何機制建立先後關係)

資料競爭在 C++ 是未定義行為(Undefined Behavior, UB)。不是「結果可能不對」,而是「整個程式的行為都不被保證」——可能崩潰、可能算錯、可能看似正常然後在最糟的時機爆炸。

為什麼 ++counter 不是原子的?

counter++ 看起來是一行,但實際上是三個步驟(讀-改-寫):

執行緒 A              執行緒 B
read counter (=5)
                     read counter (=5)
add 1 → 6
                     add 1 → 6
write 6
                     write 6     ← 兩次遞增只生效一次!最終是 6 而非 7
int counter = 0;
// 4 個執行緒各做 100000 次 ++counter
// 預期:400000,實際:往往小於 400000(且每次不同)→ 資料競爭!

驗證工具:用 g++ -fsanitize=thread(ThreadSanitizer)編譯,能在執行期偵測資料競爭並印出衝突的兩個存取點。強烈建議在開發多執行緒程式時開啟。

std::mutex:最基本的互斥鎖

mutex(mutual exclusion,互斥)保證「同一時間只有一個執行緒能進入臨界區域」。

#include <mutex>
std::mutex mtx;

mtx.lock();        // 取得鎖(若已被佔用則阻塞等待)
// ── 臨界區域:受保護的程式碼 ──
mtx.unlock();      // 釋放鎖

不要手動 lock/unlock! 如果臨界區域中途 return 或拋出例外,unlock() 就不會被執行,導致鎖永遠不被釋放(死鎖)。永遠用下面介紹的 RAII 鎖。

RAII 鎖(強烈推薦)

std::lock_guard——最簡單

{
    std::lock_guard<std::mutex> lock(mtx);  // 建構時 lock
    // 臨界區域
}   // ← 離開作用域時自動 unlock(即使因例外離開也一樣)

std::unique_lock——更彈性

unique_locklock_guard 重一點,但支援延遲鎖定、手動解鎖/重鎖、轉移所有權,而且是 condition_variable 的必要搭檔。

std::unique_lock<std::mutex> lock(mtx);          // 預設立即上鎖
lock.unlock();                                    // 可手動解鎖
lock.lock();                                       // 可重新上鎖

std::unique_lock<std::mutex> l2(mtx, std::defer_lock);  // 先不鎖
l2.lock();                                          // 之後再鎖

std::unique_lock<std::mutex> l3(mtx, std::try_to_lock); // 嘗試鎖,不阻塞
if (l3.owns_lock()) { /* 拿到鎖 */ }

std::scoped_lock(C++17)——同時鎖多個 mutex

std::mutex m1, m2;
std::scoped_lock lock(m1, m2);  // 一次鎖兩個,內部用免死鎖演算法

三種 RAII 鎖的比較

特性 lock_guard unique_lock scoped_lock(C++17)
自動 lock/unlock
手動 unlock / 重新 lock
延遲鎖定(defer_lock)
搭配 condition_variable
同時鎖多個 mutex
開銷 最小 略大(記錄狀態) 最小
預設選擇 單一鎖、簡單情境 需要彈性時 一次鎖多個鎖時

C++ Core Guidelines CP.20:使用 RAII,絕不直接呼叫 lock() / unlock()CP.21:用 std::lock()std::scoped_lock 取得多個鎖。

讓 mutex 成為類別成員,並用 mutable

封裝執行緒安全的類別時,把 mutex 設為私有成員。注意 const 成員函式(如 getter)也需要鎖,因此 mutex 要宣告為 mutable

class BankAccount {
    mutable std::mutex mtx_;   // mutable:const 函式中也能上鎖
    double balance_;
public:
    void deposit(double amt) {
        std::lock_guard<std::mutex> lock(mtx_);
        balance_ += amt;
    }
    double balance() const {                 // const 函式
        std::lock_guard<std::mutex> lock(mtx_);  // 仍可鎖 mutable 成員
        return balance_;
    }
};

對應程式碼mutex_lock.cpp 完整示範資料競爭、lock_guard(BankAccount)、unique_lock 各種模式、scoped_lock(轉帳免死鎖)、condition_variableatomic


四、死鎖與預防

死鎖的四個必要條件(Coffman conditions)

死鎖必須同時滿足以下四個條件,只要打破任一個就能預防:

  1. 互斥(Mutual Exclusion):資源一次只能被一個執行緒持有
  2. 持有並等待(Hold and Wait):執行緒持有資源的同時又等待其他資源
  3. 不可搶占(No Preemption):資源不能被強制奪走,只能自願釋放
  4. 循環等待(Circular Wait):存在一個環狀的等待鏈(A 等 B、B 等 A)

經典死鎖場景:兩個鎖、相反順序

std::mutex mA, mB;

void thread1() {
    std::lock_guard<std::mutex> l1(mA);  // 先鎖 A
    std::lock_guard<std::mutex> l2(mB);  // 再鎖 B
}
void thread2() {
    std::lock_guard<std::mutex> l1(mB);  // 先鎖 B
    std::lock_guard<std::mutex> l2(mA);  // 再鎖 A ← 死鎖!
}
     thread1 持有 mA ───等待──► mB 被 thread2 持有
         ▲                          │
         │                          ▼
     mA 被 thread1 持有 ◄──等待─── thread2 持有 mB

      (循環等待 → 兩個執行緒永遠卡住)

預防策略

策略 做法 打破的條件
固定鎖定順序 所有執行緒永遠以相同順序(如位址大小)取得鎖 循環等待
std::scoped_lock(C++17) 一次鎖定多個 mutex,內部用免死鎖演算法 持有並等待
std::lock()(C++11) 同上,配合 std::adopt_lock 使用 持有並等待
縮小鎖範圍 / 避免巢狀鎖 不要在持有一個鎖時再去取得另一個鎖 持有並等待
逾時機制 try_lock_for() / try_lock() 失敗就放棄重來 不可搶占
// 推薦:用 scoped_lock 一次鎖兩個,無論呼叫順序都不會死鎖
void transfer(Account& from, Account& to, double amount) {
    std::scoped_lock lock(from.mtx_, to.mtx_);  // 一次鎖定,安全
    from.balance_ -= amount;
    to.balance_   += amount;
}

五、條件變數(std::condition_variable)

互斥鎖解決「保護資料」,但無法解決「等待某個條件成立」。例如消費者要等到佇列裡有東西才取出。若用忙等迴圈(while(empty) {})會白白燒掉 CPU。std::condition_variable 讓執行緒睡眠等待,直到被通知。

核心 API

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

// 等待方
{
    std::unique_lock<std::mutex> lock(mtx);   // 必須用 unique_lock
    cv.wait(lock, []{ return ready; });        // 等待,直到謂詞為 true
    // 醒來時 lock 已重新持有,且 ready == true
}

// 通知方
{
    std::lock_guard<std::mutex> lock(mtx);
    ready = true;                              // 必須在鎖保護下修改條件!
}
cv.notify_one();   // 喚醒一個等待者(notify_all 喚醒全部)

假喚醒(spurious wakeup)與謂詞

cv.wait(lock) 可能在沒有任何通知的情況下醒來(這是作業系統層級允許的行為,稱為假喚醒)。因此絕對不要寫沒有謂詞的 wait

// ✗ 危險:假喚醒時會誤以為條件成立
cv.wait(lock);
process();

// ✓ 正確:帶謂詞的 wait,等價於 while(!pred) cv.wait(lock);
cv.wait(lock, []{ return ready; });

帶謂詞版本會在醒來後自動重新檢查條件,條件不成立就繼續睡——同時也解決了「通知早於等待」的競態問題。

生產者-消費者模式

生產者                       共享佇列                     消費者
───────                     (受 mutex 保護)             ───────
 push(item) ──► [□□□□_]  ◄── pop() 取出
   │                              ▲
   └─ notify(cv_not_empty) ───────┘ 喚醒在 wait 的消費者

 若佇列已滿,生產者 wait(cv_not_full);消費者取出後 notify(cv_not_full)
template<typename T>
class ThreadSafeQueue {
    std::queue<T> q_;
    mutable std::mutex mtx_;
    std::condition_variable not_empty_, not_full_;
    std::size_t cap_;
public:
    explicit ThreadSafeQueue(std::size_t cap = 10) : cap_(cap) {}

    void push(T item) {
        std::unique_lock<std::mutex> lock(mtx_);
        not_full_.wait(lock, [this]{ return q_.size() < cap_; });
        q_.push(std::move(item));
        not_empty_.notify_one();
    }
    T pop() {
        std::unique_lock<std::mutex> lock(mtx_);
        not_empty_.wait(lock, [this]{ return !q_.empty(); });
        T item = std::move(q_.front());
        q_.pop();
        not_full_.notify_one();
        return item;
    }
};

notify_one vs notify_all:只喚醒一個等待者用 notify_one(效率較好);當條件改變後可能讓多個等待者都該醒來時用 notify_all


六、std::atomic:無鎖的原子操作

對於簡單的共享變數(特別是計數器、旗標),用 mutex 顯得太重。std::atomic<T> 提供不可分割(atomic)的操作,通常由 CPU 指令直接支援,無鎖(lock-free)且更快。

#include <atomic>

std::atomic<int> counter{0};
counter++;                  // 原子遞增,執行緒安全
counter.fetch_add(5);       // 原子加 5,回傳舊值
int v = counter.load();     // 原子讀取
counter.store(100);         // 原子寫入
int old = 22;
counter.compare_exchange_strong(old, 100);  // CAS:若等於 old 則設為 100

atomic vs mutex

比較項 std::atomic std::mutex
適用 單一變數的簡單操作(計數、旗標) 任意複雜的臨界區域、保護多個變數
效能 快(通常無鎖,硬體指令) 較慢(可能涉及系統呼叫)
保護範圍 單一原子變數 一整段程式碼
死鎖風險

記憶體順序(memory ordering)簡介

atomic 操作可指定記憶體順序,控制編譯器與 CPU 對指令的重排程度:

順序 說明
memory_order_seq_cst 預設,最嚴格,全域一致的順序,最容易推理
memory_order_acquire / release 用於「生產-消費」同步,效能較好
memory_order_relaxed 只保證原子性、不保證順序,最快,適合單純計數器

初學建議:先一律使用預設的 seq_cst(不指定即可)。只有在效能剖析證明 atomic 是瓶頸、且你完全理解 happens-before 關係時,才碰 relaxed / acquire / release。誤用記憶體順序是極難除錯的 bug 來源。

// 簡單計數器用 relaxed 是常見且安全的最佳化(只需原子性,不需順序)
counter.fetch_add(1, std::memory_order_relaxed);

七、任務式並行:std::async / future / promise

直接操作 std::thread 屬於「低階」做法:你要自己管理生命週期、自己把結果傳回來、自己處理例外。任務式(task-based)並行讓你只描述「要做什麼」,由函式庫負責執行細節,並用 std::future 取回結果。

C++ Core Guidelines CP.61 / 偏好建議:優先使用 std::async 等高階抽象,而非手動管理 std::thread

std::async:啟動非同步任務

#include <future>

auto fut = std::async(std::launch::async, compute, arg1, arg2);
// ... 主執行緒可以同時做別的事 ...
auto result = fut.get();   // 阻塞等待,取得回傳值

啟動策略(launch policy)

策略 說明
std::launch::async 保證在新執行緒上執行
std::launch::deferred 延遲執行,直到呼叫 get() / wait() 才在呼叫者執行緒上同步執行(不開新執行緒)
預設(async \| deferred 由實作自行決定二者之一

陷阱:用預設策略時,任務可能根本沒在背景跑(被 deferred)。若你依賴「真的有平行」,請明確指定 std::launch::async

future 的操作

std::future<int> fut = std::async(std::launch::async, []{ return 42; });

fut.valid();                              // 是否關聯到共享狀態
fut.wait();                               // 等待完成,但不取值
auto s = fut.wait_for(std::chrono::milliseconds(50));  // 限時等待
if (s == std::future_status::ready)  { /* 完成 */ }
if (s == std::future_status::timeout){ /* 還沒好 */ }
int v = fut.get();                        // 取值(只能呼叫一次!之後 valid() 變 false)

std::async 的 future 解構會阻塞!

std::async(std::launch::async, long_task);  // ← 沒有接住回傳的 future!
// 臨時 future 在這行結束時解構,解構子會「阻塞等待 long_task 完成」
// 結果:這看起來像非同步,實際上是同步的,常讓人困惑

std::async 回傳的 future,其解構子會等待任務完成。請務必用變數接住 future。

std::promise / std::future:手動跨執行緒傳值

當你需要更細緻地控制「何時、由誰」設定結果時,用 promise

std::promise<int> prom;
std::future<int> fut = prom.get_future();

std::thread t([&prom] {
    prom.set_value(42);                    // 在工作執行緒設定結果
    // 或 prom.set_exception(std::current_exception());
});

int result = fut.get();                    // 主執行緒取得結果
t.join();

例外會自動跨執行緒傳遞

任務式並行最大的優點之一:工作執行緒拋出的例外,會被存進共享狀態,並在呼叫 get()重新拋出

auto fut = std::async(std::launch::async, []{
    throw std::runtime_error("出錯了");
    return 0;
});
try {
    fut.get();          // ← 例外在這裡被重新拋出
} catch (const std::exception& e) {
    std::cout << "捕獲:" << e.what() << "\n";
}

對比之下,直接用 std::thread 時,若執行緒函式拋出未捕獲的例外,會直接 std::terminate()——這是改用任務式並行的強力理由。

std::shared_future:多個消費者共享一個結果

普通 futureget() 只能呼叫一次(結果被「移動」走)。若多個執行緒都要讀同一個結果,用 shared_future

std::promise<int> prom;
std::shared_future<int> shared = prom.get_future().share();

for (int i = 0; i < 4; ++i) {
    std::thread([shared] {            // 每個執行緒都複製一份 shared_future
        int v = shared.get();         // 全部都能取得同一個值,可多次 get
    }).detach();
}
prom.set_value(999);                  // 一次設定,所有消費者都收到

四種非同步工具的選擇

工具 何時使用
std::thread 需要完全控制執行緒、長期運行的背景執行緒
std::async 「跑一個會回傳值的任務」,最方便的起手式
std::promise / std::future 需要手動、分階段地設定結果(如流水線、事件)
std::shared_future 一個結果要被多個執行緒讀取

對應程式碼async_future.cpp 涵蓋 async 基本用法、啟動策略、future 操作、promise/future 配對與流水線、shared_future、例外傳遞、平行加總與質數計數。


八、thread_local(執行緒區域儲存)

thread_local 變數讓每個執行緒各自擁有一份獨立的副本,互不干擾,因此天生無需同步:

thread_local int counter = 0;   // 每個執行緒都有自己的 counter

void work() {
    ++counter;   // 只動到本執行緒的副本,無資料競爭
}

常見用途:每執行緒的隨機數產生器、每執行緒的暫存緩衝區、避免共享狀態。代價是每個執行緒都佔用一份記憶體。


九、最佳實踐(對照 C++ Core Guidelines)

建議 說明 對應 Guideline
假設程式會並行執行 設計類別時就考慮執行緒安全 CP.1
避免資料競爭 所有共享的可變狀態都要同步 CP.2
用 RAII 鎖,不要手動 lock/unlock lock_guard / scoped_lock CP.20
scoped_lock / std::lock 取多個鎖 避免死鎖 CP.21
偏好任務式並行 async / future 取代裸 thread
最小化共享狀態 能不共享就不共享,從根本消除資料競爭
偏好不可變資料 只讀資料天生執行緒安全(多執行緒同讀不需鎖)
縮小臨界區域 鎖只包住真正需要保護的最小範圍,提升並行度
condition_variable 一律用謂詞版 wait 防範假喚醒
用 ThreadSanitizer 測試 -fsanitize=thread 偵測資料競爭
記得 join 或 detach 或用 RAII 包裝(ThreadGuard / C++20 jthread

核心心法:與其辛苦地正確「共享並同步」可變狀態,不如從一開始就避免共享可變狀態——這才是寫出正確、好維護並行程式的根本之道。


重點整理

工具 用途 標頭檔
std::thread 建立與管理執行緒 <thread>
std::mutex 互斥鎖,保護共享資料 <mutex>
std::lock_guard RAII 鎖定(簡單場景) <mutex>
std::unique_lock 彈性鎖定(手動控制、搭配 cv) <mutex>
std::scoped_lock 同時鎖多個 mutex(C++17,免死鎖) <mutex>
std::condition_variable 執行緒間等待/通知機制 <condition_variable>
std::atomic 無鎖原子操作 <atomic>
std::async / std::future 任務式非同步、取回結果 <future>
std::promise 手動跨執行緒傳遞值/例外 <future>
std::shared_future 多消費者共享一個結果 <future>
thread_local 每執行緒獨立的變數副本 關鍵字

常見錯誤與陷阱

  1. 忘記 -pthread:連結錯誤或執行期 std::system_error。所有用到執行緒的程式都要加。
  2. 忘記 join 或 detach:可 joinable 的 std::thread 被銷毀 → std::terminate(),程式崩潰。改用 RAII 包裝最保險。
  3. 以為傳了參考其實是複製std::thread 預設複製參數,要傳參考必須 std::ref / std::cref,並確保物件生命週期夠長。
  4. 懸空參考std::ref 或 lambda 以參考捕獲了區域變數,但執行緒比該變數活得久 → UB。
  5. 資料競爭:多執行緒存取共享可變資料卻沒同步 → 未定義行為。用 mutex 或 atomic。
  6. 手動 lock/unlock 後忘記 unlock:尤其是中途 return 或拋例外時。永遠用 RAII 鎖。
  7. 死鎖:多個鎖以不同順序取得。用 scoped_lock 或固定鎖定順序。
  8. condition_variable 沒用謂詞:無法防範假喚醒,也可能漏掉「通知早於等待」。一律用 cv.wait(lock, pred)
  9. 修改條件變數的條件時沒上鎖:通知方修改條件必須在 mutex 保護下進行,否則仍是資料競爭。
  10. std::async 的 future 沒接住:臨時 future 立刻解構並阻塞,導致看似非同步實為同步。
  11. 預設啟動策略誤以為一定平行:可能被 deferred。需要保證平行就明確指定 std::launch::async
  12. 過度同步:把整個函式都鎖起來,導致並行度歸零、效能甚至比單執行緒還差。鎖要小而精。
  13. future::get() 呼叫兩次:第二次是未定義行為(future 已失效)。要多次讀取請用 shared_future

編譯注意

所有多執行緒程式需要連結 pthread 函式庫,並建議開啟警告與(開發時)ThreadSanitizer:

# 一般編譯
g++ -std=c++17 -Wall -pthread filename.cpp -o output

# 開發時偵測資料競爭(強烈建議)
g++ -std=c++17 -Wall -pthread -fsanitize=thread -g filename.cpp -o output

練習題

提示:所有練習都用 g++ -std=c++17 -Wall -pthread 編譯;除錯時可加 -fsanitize=thread

練習 1:多執行緒問候(基礎)

建立 4 個執行緒,每個執行緒印出自己的編號與訊息 "Hello from thread X"(X 為 0~3)。請把 4 個 std::thread 存進 std::vector,再用迴圈逐一 join

  • 提示:用 std::vector<std::thread> 搭配 emplace_back([i]{ ... }) 以值捕獲迴圈變數 i;輸出記得用一個共用 mutex 保護 std::cout,否則訊息會交錯。

練習 2:執行緒安全的計數器(基礎)

實作類別 ThreadSafeCounter,提供 increment()decrement()get() 三個操作。先用 std::mutex + lock_guard 實作,再寫一個版本改用 std::atomic<int>,並比較兩者程式碼的繁簡。用 8 個執行緒各遞增 100000 次來驗證最終值為 800000。

  • 提示:mutex 版本中 get() 是 const 函式,記得把 mutex 宣告為 mutable;atomic 版本連 const 都不太需要鎖,體會 atomic 的便利。

練習 3:有界緩衝區的生產者-消費者(中級)

std::condition_variable 實作一個容量上限為 N 的執行緒安全佇列,並啟動 2 個生產者與 2 個消費者。生產者共生產 M 個項目後結束;消費者要能在「全部生產完且佇列已空」時優雅地停止。

  • 提示:用兩個 condition variable(not_full_not_empty_)。停止機制可用一個 std::atomic<bool> done 旗標,並在消費者的謂詞中同時檢查 !q.empty() || done,避免結束時卡在 wait

練習 4:std::async 平行加總(中級)

std::async 把一個有一千萬個元素的 std::vector<long long> 切成 hardware_concurrency() 段,平行計算各段總和後再合併。同時實作一個序列版本,用 <chrono> 比較兩者耗時並計算加速比。

  • 提示:把每段的 [begin, end) 用 lambda 捕獲,回傳該段 std::accumulate 結果;收集所有 std::future 後逐一 get() 相加。注意明確指定 std::launch::async 才保證真的平行。

練習 5:偵測並修復死鎖(中級)

給定兩個帳戶與一個 transfer(from, to, amount) 函式,它先鎖 from 再鎖 to。寫一段程式讓兩個執行緒分別做 transfer(A, B)transfer(B, A),重現死鎖(程式卡住)。接著用 std::scoped_lock 同時鎖兩個 mutex 來修復它,並驗證大量併發轉帳後總金額守恆。

  • 提示:重現死鎖時可在兩次上鎖之間加 sleep_for 提高機率;修復後用 4+ 個執行緒做數千次雙向轉帳,最後檢查 A.balance + B.balance 是否等於初始總額。

練習 6:用 promise/future 做計算流水線(挑戰)

設計一個三階段流水線:階段 1 產生原始資料、階段 2 轉換資料、階段 3 彙總結果。各階段在獨立執行緒執行,前一階段用 std::promise::set_value 把結果交給下一階段(下一階段用對應的 std::future::get 取得)。額外要求:若任一階段拋出例外,能透過 set_exception 讓最終 get() 收到並妥善處理。

  • 提示:建立 promise<T> p1, p2,把對應的 future 傳給下游執行緒;在每個階段用 try/catch 包住運算,失敗時呼叫 set_exception(std::current_exception())

練習 7:平行 map(挑戰)

實作一個泛型函式 template<typename T, typename F> std::vector<...> parallel_map(const std::vector<T>& in, F f, unsigned n_threads),把 f 平行套用到每個元素並回傳結果向量。要求結果順序與輸入一致、無資料競爭,並能正確處理 in.size() 不能被 n_threads 整除的情況。

  • 提示:每個執行緒負責一段索引區間,各自寫入結果向量的不同位置(不同索引不會衝突,因此寫入無需鎖);先 resize 結果向量再分派工作。用 std::asyncstd::thread 皆可。

對應程式碼