學習目標
讀完本章並完成練習後,你應該能夠:
- 解釋並行(concurrency)與平行(parallelism)的差異,並判斷何時值得引入多執行緒
- 使用
std::thread以函式、lambda、成員函式、函式物件建立執行緒,並正確地join/detach - 理解
joinable狀態,避免「可 joinable 的執行緒被銷毀」導致std::terminate() - 正確地以值、
std::ref、std::move傳遞參數給執行緒 - 說明資料競爭(data race)為何是未定義行為,並能寫出可重現的範例
- 使用
std::mutex搭配 RAII 鎖(lock_guard、unique_lock、C++17 的scoped_lock)保護臨界區域 - 分析死鎖的四個必要條件,並以「固定鎖定順序」、
std::lock、std::scoped_lock預防 - 使用
std::condition_variable實作生產者-消費者模式,並理解假喚醒與謂詞等待 - 使用
std::atomic進行無鎖計數,並對記憶體順序有基本概念 - 使用
std::async/std::future/std::promise/std::shared_future進行任務式(task-based)非同步程式設計,並讓例外能跨執行緒傳遞 - 認識
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.9、N → ∞,加速最多也只有 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_concurrency、sleep_for/yield。
三、資料競爭與互斥鎖
什麼是資料競爭(Data Race)?
當以下三個條件同時成立時,就發生資料競爭:
- 兩個以上的執行緒存取同一個記憶體位置
- 其中至少一個是寫入
- 這些存取沒有同步(沒有用任何機制建立先後關係)
資料競爭在 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_lock 比 lock_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_variable與atomic。
四、死鎖與預防
死鎖的四個必要條件(Coffman conditions)
死鎖必須同時滿足以下四個條件,只要打破任一個就能預防:
- 互斥(Mutual Exclusion):資源一次只能被一個執行緒持有
- 持有並等待(Hold and Wait):執行緒持有資源的同時又等待其他資源
- 不可搶占(No Preemption):資源不能被強制奪走,只能自願釋放
- 循環等待(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_onevsnotify_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:多個消費者共享一個結果
普通 future 的 get() 只能呼叫一次(結果被「移動」走)。若多個執行緒都要讀同一個結果,用 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 |
每執行緒獨立的變數副本 | 關鍵字 |
常見錯誤與陷阱
- 忘記
-pthread:連結錯誤或執行期std::system_error。所有用到執行緒的程式都要加。 - 忘記 join 或 detach:可 joinable 的
std::thread被銷毀 →std::terminate(),程式崩潰。改用 RAII 包裝最保險。 - 以為傳了參考其實是複製:
std::thread預設複製參數,要傳參考必須std::ref/std::cref,並確保物件生命週期夠長。 - 懸空參考:
std::ref或 lambda 以參考捕獲了區域變數,但執行緒比該變數活得久 → UB。 - 資料競爭:多執行緒存取共享可變資料卻沒同步 → 未定義行為。用 mutex 或 atomic。
- 手動 lock/unlock 後忘記 unlock:尤其是中途 return 或拋例外時。永遠用 RAII 鎖。
- 死鎖:多個鎖以不同順序取得。用
scoped_lock或固定鎖定順序。 condition_variable沒用謂詞:無法防範假喚醒,也可能漏掉「通知早於等待」。一律用cv.wait(lock, pred)。- 修改條件變數的條件時沒上鎖:通知方修改條件必須在 mutex 保護下進行,否則仍是資料競爭。
std::async的 future 沒接住:臨時 future 立刻解構並阻塞,導致看似非同步實為同步。- 預設啟動策略誤以為一定平行:可能被
deferred。需要保證平行就明確指定std::launch::async。 - 過度同步:把整個函式都鎖起來,導致並行度歸零、效能甚至比單執行緒還差。鎖要小而精。
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::async或std::thread皆可。