1. 為什麼在多執行緒中需要 mutex
如果你剛開始寫多執行緒程式,腦中通常會想像「兩個執行緒各自誠實地做自己的工作」。現實要戲劇性一點:執行緒就像兩位共用一台筆電的實習生,你跟他們說「輪流編輯這個文件,但我不會監督」。即便實習生很努力,文件仍然有時會變成抽象藝術。
在 C++ 中,「文件」就是共享資源:變數、結構、容器,甚至 std::cout。如果兩個執行緒同時修改同一個狀態,就會產生競態條件。我們需要一個機制來定規則:在任何時刻只有一個執行緒執行臨界區。
std::mutex 是互斥原語(mutual exclusion)。概念很簡單:mutex 有兩個狀態——「可用」和「已被佔用」。執行緒可以「佔用」mutex,執行臨界區,然後「釋放」它。
可以用示意圖表示如下:
flowchart TD
A[執行緒 1 想要更新共享資源] --> B["lock(mutex)"]
B --> C[臨界區]
C --> D["unlock(mutex)"]
D --> E[其他執行緒可以進入]
重要的是要記住:mutex::lock() 是一個阻塞操作,也就是說執行緒可能會一直等待,直到 mutex 被釋放。這正是將 lock() 定義為「blocking」操作的意義。
2. std::mutex:基本用法與手動 lock()/unlock() 的陷阱
當你第一次看到 std::mutex,會想把它當作「防 Bug 的魔法護符」。這是正常的階段——大家都會這麼想。但 mutex 本身並不會自動讓程式變得執行緒安全,它只是讓你可以建立一套規律:這個區塊一次只允許一個執行緒執行。
引入方式很簡單:
#include <mutex>
最小範例
基本情況如下:
std::mutex m;
m.lock();
// ... 臨界區 ...
m.unlock();
這裡要記住兩點。首先,std::mutex 不能被複製:複製一個 mutex 在概念上是奇怪的,因為「洗手間鑰匙的複本」不該無中生有。其次,從語意上講,lock() 不應該因為例外而「意外」終止。
但實務上更重要的是:即便沒有例外,也可能忘記呼叫 unlock()——這會是實作上的真實問題。
為什麼手動 lock()/unlock() 是陷阱
手動使用 lock() / unlock() 的不愉快之處在於:它們要求你擁有完美的紀律。我們不是在無菌實驗室寫程式,而是在有早期 return、輸入錯誤、好幾個分支的真實專案中開發,有時候你會不小心「加入了 return 並忘了 unlock」。
典型的(糟糕但常見)情況:
#include <mutex>
std::mutex m;
int x = 0;
void set_if_positive(int v) {
m.lock();
if (v <= 0) return; // <-- 忘記了 unlock()
x = v;
m.unlock();
}
看起來無害,但當 v <= 0 時,mutex 將會永遠被佔用。下一個呼叫 set_if_positive 的執行緒可能會在 lock() 上「卡住」並無限期等待。
幾乎每當生產環境出現「應用程式掛起」的報告,背後常有一個 mutex 沒有等到 unlock()。這是一類真實的 bug:在某個分支忘記了 unlock。
這時候 RAII 出場了:我們希望「取得鎖」與「釋放鎖」不是靠人的記憶,而是綁定到作用域。
3. 透過 std::lock_guard 的 RAII 鎖定
std::lock_guard 是一個小型物件,它只做一件事但做得很可靠:在建構時取得 mutex,在解構時釋放它。也就是把鎖當成一個 RAII 資源。
基本的 lock_guard 範例
#include <mutex>
std::mutex m;
int x = 0;
void safe_set(int v) {
std::lock_guard<std::mutex> lock(m);
x = v;
} // <-- lock_guard 被銷毀,mutex 自動釋放
提前的 return 不再破壞紀律
#include <mutex>
std::mutex m;
int x = 0;
void set_if_positive(int v) {
std::lock_guard<std::mutex> lock(m);
if (v <= 0) return; // unlock 將自動發生
x = v;
}
在這種情況下,你可以在任何地方安全地寫 return:離開函式時區域物件會被銷毀,lock_guard 也會被銷毀——mutex 會被釋放。
為了明確比較差別,下面用表格快速總結:
| 做法 | 程式樣貌 | 風險所在 |
|---|---|---|
| 手動 lock()/unlock() | |
容易在某個返回路徑忘記 unlock() |
| 透過 RAII 的 lock_guard | |
幾乎不用擔心忘記——「unlock」綁定到 scope |
臨界區的邊界即作用域的邊界
當你習慣使用 lock_guard 之後,下一步是學會畫出臨界區的「邊界」。新手常做的事是:在函式開始就取得 mutex,一直持有到函式結束,以防萬一。這雖然安全,但有時會變成堵車:其他執行緒被迫等待,而你在鎖內做了長時間且與共享資料無關的工作。
正確的想法是:臨界區應該在語意上盡可能短。通常意味著「更新共用計數器」或「向共用容器加入元素」,其餘耗時的計算可以放在外面。
語法上可以用額外的區塊來達成:{ ... }:
#include <mutex>
std::mutex m;
int total = 0;
void add_sum(int a, int b) {
int local = a + b; // 耗時或安全的運算放在 lock 外
{
std::lock_guard<std::mutex> lock(m);
total += local; // 在 lock 下短小且明確的操作
} // 這裡 lock 被釋放
}
這個小區塊就像一間小型會議室:進去快速協商,然後出來。不是在那裡長期居住。
4. 實作中的小範例
小範例:「壞掉的」計數器與修正方式
計數器是最經典的例子,因為它看起來非常簡單(「不就是個 int 嗎!」),但也最容易出錯。假設有兩個執行緒同時增加共用的 counter。先看不該怎麼做:
#include <thread>
#include <iostream>
int main() {
int counter = 0; // 共用資源
auto work = [&] {
for (int i = 0; i < 100000; ++i) ++counter; // 競態
};
std::thread t1(work), t2(work);
t1.join(); t2.join();
std::cout << counter << '\n'; // 原本預期 200000,實際得到某些不定值
}
現在加入 std::mutex 與 std::lock_guard。注意我們沒有改變程式結構,僅是增加了一條規則:「遞增必須一次只允許一個執行緒」。
#include <thread>
#include <mutex>
#include <iostream>
int main() {
int counter = 0;
std::mutex m;
auto work = [&] {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(m);
++counter;
}
};
std::thread t1(work), t2(work);
t1.join(); t2.join();
std::cout << counter << '\n'; // 200000
}
是的,這樣可能比較慢(因為我們太頻繁地取得鎖),但這裡的目標是正確性與可靠的技巧。
小範例:std::cout 也是「共用資源」
有一個驚喜是大多數人在實務中會遇到的:兩個執行緒同時印出到控制台時,輸出會「攪在一起」。這不是神祕現象,而是對共用輸出物件的並發存取所致。
假設我們有一個記錄行的函式:
#include <iostream>
#include <string>
void log_line(const std::string& s) {
std::cout << s << '\n';
}
如果兩個執行緒同時呼叫 log_line,字串可能會與另一個字串交錯。因此輸出也應該被保護:
#include <mutex>
#include <iostream>
#include <string>
std::mutex out_m;
void log_line(const std::string& s) {
std::lock_guard<std::mutex> lock(out_m);
std::cout << s << '\n'; // 按語意原子地印出一整行
}
這裡重要的是:我們保護的不是「cout 本身」,而是邏輯上的操作「整行字串的輸出」。如果從不同執行緒分片印出,使用者會看到雜訊,而你將面對頭痛的問題。
實務小步驟:為執行緒加上「遙測」
為了讓例子不顯得零散,我們延續小型應用:有幾個工作執行緒,它們模擬工作並更新共用統計資料。
建立一個「處理統計」模型:
struct Stats {
int ok = 0;
int failed = 0;
};
然後是共用物件與 mutex:
#include <mutex>
Stats stats;
std::mutex stats_m;
接著是兩個更新統計的函式。鎖只包裹一個語意上的操作:
#include <mutex>
extern Stats stats;
extern std::mutex stats_m;
void add_ok() {
std::lock_guard<std::mutex> lock(stats_m);
++stats.ok;
}
void add_failed() {
std::lock_guard<std::mutex> lock(stats_m);
++stats.failed;
}
現在是工作執行緒的函式。我們故意把「工作」放在 mutex 外面,統計更新則在 mutex 之下:
void worker(int id) {
// ... 這裡是某些工作(在我們的例子中僅為邏輯) ...
if (id % 2 == 0) add_ok();
else add_failed();
}
以及一個小小的 main,啟動兩個執行緒並印出結果:
#include <thread>
#include <iostream>
int main() {
std::thread t1(worker, 1);
std::thread t2(worker, 2);
t1.join();
t2.join();
std::cout << "ok=" << stats.ok << " failed=" << stats.failed << '\n';
// ok=1 failed=1
}
這一步重要是為了養成習慣:一旦出現共用狀態,就馬上加入 mutex 和存取規則。最好將規則以程式碼表達出來,使得「不小心違反」變得困難。
5. 使用 std::mutex 與 std::lock_guard 時的常見錯誤
錯誤 #1:『這不過是個 int,所以安全』。
型別大小並不會使並發存取變得正確。問題不在於 int「小」,而在於操作 ++counter 在語意上是「讀取 → 增加 → 寫回」的序列,兩個執行緒可能會互相干擾。
錯誤 #2:手動使用 lock()/unlock(),因為『這樣比較清楚』。
一開始看起來手動的寫法更直觀:「這裡 lock,那裡 unlock」。但這也是最脆弱的:你可能新增了早期的 return、新的分支或另一個返回點——就可能忘記在某條路徑上呼叫 unlock(),導致程式掛起。lock_guard 幾乎應該是預設選項。
錯誤 #3:把 mutex 持有太久,因為『以防萬一』。
當 lock_guard 在函式開始就被建立並活到結束時,你可能把不必要的計算、字串格式化甚至輸出也放在鎖內。這會降低並行度,並可能導致意外的「卡住」。習慣上應該把臨界區限定在小的區塊內:使用 {...} 來縮小範圍。
錯誤 #4:保護寫入,但不保護讀取。
新手常想:「我只是讀取 stats.ok,所以沒事」。在多執行緒情境下,沒有同步的讀取仍會參與競態,如果其他執行緒在寫。若某個狀態有「必須在 mutex 下存取」的規則,那閱讀也應遵守相同規則。
錯誤 #5:同一資源被不同的 mutex 保護或有時候根本沒被保護。
有時專案裡出現「寫的 mutex」和「讀的 mutex」,或是有些函式會 lock,而有些不做任何保護。這會破壞紀律。單一資源應該有一套明確的保護規則,否則你得到的是假象的安全性。
GO TO FULL VERSION