Wiki
mutex 與 RAII 鎖
本文由簡體中文內容確定性轉換,並受版本化術語表保護。
多個線程同時運行時,如果它們會訪問同一份共享數據,並且其中至少有一個線程會修改這份數據,就必須開始考慮線程同步問題。
C++ 中最基礎、最常用的同步工具之一就是:
std::mutex
mutex 的中文通常叫:
互斥量
它最核心的作用可以簡單理解成:
同一時刻只允許一個線程進入某段需要保護的代碼。
這一節重點學習:
- 什麼是共享數據、數據競爭和臨界區;
std::mutex到底“鎖”的是什麼;lock()、try_lock()、unlock();- 為什麼不推薦手動成對調用
lock()/unlock(); - RAII 為什麼特別適合管理鎖;
std::lock_guard的基本使用;std::unique_lock為什麼更加靈活;std::scoped_lock為什麼適合多把鎖;- 什麼是死鎖,以及怎樣避免;
std::recursive_mutex和std::timed_mutex;std::shared_mutex的多讀單寫;std::call_once的一次性初始化。
1.為什麼需要互斥量
先來看一個最經典的例子:
#include <print>
#include <thread>
int counter = 0;
void add_many()
{
for (int i = 0; i < 100000; ++i)
{
++counter;
}
}
int main()
{
std::thread t1(add_many);
std::thread t2(add_many);
t1.join();
t2.join();
std::println("{}", counter);
}
運行結果示例(存在數據競爭,實際數字每次可能不同):
104563
這裏創建了兩個線程:
线程 t1
线程 t2
每個線程都執行:
++counter;
100000 次。
直覺上:
100000 + 100000 = 200000
所以好像最後一定應該輸出:
200000
但實際上,這段程序存在嚴重的併發問題。
問題就在:
++counter;
1.1.++counter 並不是不可分割的一步
我們平時看:
++counter;
感覺它就是:
counter 加 1
一條操作。
但從程序執行的角度來看,它通常大致需要經歷:
读取 counter 当前值
↓
当前值 + 1
↓
把结果写回 counter
假設現在:
counter = 10
兩個線程同時執行:
++counter;
就可能出現:
线程 A:读取 counter,得到 10
线程 B:读取 counter,得到 10
线程 A:计算 10 + 1 = 11
线程 B:计算 10 + 1 = 11
线程 A:写回 11
线程 B:写回 11
最終:
counter = 11
但兩個線程明明都執行了一次加法,正確結果應該是:
counter = 12
其中一次修改就這樣丟失了。
這説明:
即使 C++ 原始碼裏看起來只有一條語句,也不代表它在多線程環境中天然不可被其他線程干擾。
1.2.什麼是數據競爭
如果:
- 多個線程訪問同一個內存位置;
- 至少有一個線程會寫;
- 這些訪問之間沒有正確的同步;
就可能產生:
數據競爭(Data Race)
前面的:
++counter;
就是典型例子。
線程 A 和線程 B 都在:
读取 counter
修改 counter
並且沒有任何同步措施。
在 C++ 中,數據競爭通常意味着:
未定義行為(Undefined Behavior)
所以不能只理解成:
“最後結果偶爾少 1。”
真正的問題是:
一旦發生數據競爭,C++ 標準就不再保證程序行為。
因此多線程編程中第一件非常重要的事情就是:
不要讓多個線程無保護地同時訪問共享可變數據。
1.3.什麼是臨界區
假設有:
++counter;
這段代碼訪問了多個線程共享的變量:
counter
並且我們希望:
同一時刻只能有一個線程執行它。
那麼這段代碼就可以稱為:
臨界區(Critical Section)
簡單理解:
临界区
=
不能让多个线程同时执行的代码区域
例如:
++counter;
也可能是一整組操作:
++count;
average = calculate_average();
只要這些操作必須作為一個整體被保護,都可以屬於同一個臨界區。
2.std::mutex 基礎
2.1.std::mutex 到底是什麼
mutex 是:
mutual exclusion
也就是:
互斥
可以把它想像成一把只有一把鑰匙的門鎖。
假設有一個房間:
共享数据
門口放着:
一把 mutex
線程 A 想進去:
线程 A
↓
尝试获得 mutex
↓
成功
↓
进入临界区
此時線程 B 也想進去:
线程 B
↓
尝试获得 mutex
↓
发现 mutex 已经被 A 持有
↓
等待
等線程 A 離開:
线程 A 释放 mutex
線程 B 才有機會獲得它。
所以 mutex 的核心效果就是:
线程 A ─┐
线程 B ─┼→ 同一时刻只能有一个线程通过
线程 C ─┘
2.2.mutex 並不會自動“鎖住變量”
這是初學 mutex 時非常容易誤解的一點。
例如:
int counter = 0;
std::mutex mutex;
mutex 並不知道:
counter
的存在。
它和 counter 之間沒有任何自動綁定關係。
例如線程 A:
mutex.lock();
++counter;
mutex.unlock();
但線程 B 如果直接:
++counter;
mutex 根本攔不住它。
所以更準確的理解是:
mutex 不是把某個變量鎖住,而是讓所有遵守同一套加鎖規則的線程無法同時進入某段代碼。
我們人為規定:
凡是访问 counter
都必须先获得 counter_mutex
這樣:
std::mutex counter_mutex;
才真正起到了保護 counter 的作用。
2.3.std::mutex 的基本使用
std::mutex 定義在:
#include <mutex>
最基礎的三個操作是:
mutex.lock();
mutex.try_lock();
mutex.unlock();
2.3.1.lock():獲得鎖(不是加鎖,是獲得鎖)
最基本的用法:
mutex.lock();
如果當前 mutex 沒有被其他線程持有:
mutex 空闲
↓
当前线程获得 mutex
↓
继续执行
如果 mutex 已經被其他線程持有:
线程 A 已经持有 mutex
↓
线程 B 调用 lock()
↓
暂时拿不到
↓
线程 B 阻塞等待
等線程 A:
mutex.unlock();
以後,線程 B 才有機會繼續。
所以:
lock();
可以簡單記成:
拿不到就等。(非常重要)
2.3.2.unlock():釋放鎖
線程獲得 mutex 後:
mutex.lock();
完成臨界區操作以後,就應該:
mutex.unlock();
例如:
mutex.lock();
++counter;
mutex.unlock();
執行過程大致就是:
获得 mutex(获得不了就阻塞,卡在这里)
↓
进入临界区
↓
修改 counter
↓
离开临界区
↓
释放 mutex
釋放以後,其他等待這把 mutex 的線程才有機會繼續執行。
2.3.3.try_lock():拿不到就算了
有些時候,我們並不想:
拿不到锁
↓
一直等(阻塞)
而是希望:
拿得到就执行
拿不到就先干别的(不想阻塞卡在这里)
這時可以使用:
try_lock()
例如:
if (mutex.try_lock())
{
++counter;
mutex.unlock();
}
else
{
// 这一次没有获得 mutex
}
區別可以簡單記成:
lock()
拿不到
↓
等待(阻塞)
而:
try_lock()
拿不到
↓
立即返回 false(不会阻塞)
成功時:
true
失敗時:
false
所以:
if (mutex.try_lock())
{
// 当前线程已经获得锁
}
需要注意,標準允許 try_lock() 偶發性失敗。
所以不要把:
try_lock() == false
絕對理解成:
現在肯定有另一個線程持有 mutex。
更簡單的理解就是:
這次沒有獲得鎖。
2.4.用 mutex 修復 counter
之前的數據競爭可以這樣解決:
#include <print>
#include <mutex>
#include <thread>
int counter = 0;
std::mutex counter_mutex;
void add_many()
{
for (int i = 0; i < 100000; ++i)
{
counter_mutex.lock();
++counter;
counter_mutex.unlock();
}
}
int main()
{
std::thread t1(add_many);
std::thread t2(add_many);
t1.join();
t2.join();
std::println("{}", counter);
}
運行結果:
200000

如上圖,只有第一個有mutex機制的正確輸出了200000,而其他沒mutex機制的輸出是不固定的。
現在所有想修改:
counter
的線程,都必須先:
counter_mutex.lock();
於是可能出現:
线程 A 获得 counter_mutex
↓
++counter
↓
线程 A 释放 counter_mutex
↓
线程 B 获得 counter_mutex
↓
++counter
而不會兩個線程同時修改。
但是這種寫法還有一個很大的問題:
lock();
...
unlock();
完全由程序員自己管理。
2.5.為什麼不推薦手寫 lock() / unlock()
下面這段代碼看起來沒有什麼問題:
mutex.lock();
change_shared_state();
mutex.unlock();
但以後代碼可能變成:
mutex.lock();
if (something_wrong())
{
return;
}
change_shared_state();
mutex.unlock();
如果:
something_wrong()
為真:
return;
就會直接離開函數。
於是:
mutex.unlock();
永遠不會執行。
mutex 會一直保持鎖定狀態。
其他線程如果再:
mutex.lock();
就可能一直卡在那裏。
還有異常:
mutex.lock();
some_function();
mutex.unlock();
如果:
some_function();
拋出異常,後面的:
mutex.unlock();
同樣不會正常執行。
因此手動管理:
lock()
unlock()
最大的問題就是:
必須保證程序的每一條離開路徑都正確釋放鎖。
代碼一複雜,就很容易遺漏。
這很像內存管理裏的new和delete所面臨的問題,在內存管理中,我們使用的是RAII思路去解決這個問題,也就是智能指針。
在這裏,也正是 RAII 特別適合解決的問題。
3.RAII 鎖管理
3.1.RAII 為什麼適合管理鎖
RAII 的核心思想之前已經學過:
讓資源的生命週期由對象生命週期管理。
mutex 的所有權也可以看成一種資源。
於是我們希望有這樣一個對象:
对象构造
↓
mutex.lock()
对象析构
↓
mutex.unlock()
這樣只需要:
{
某个 RAII 锁对象 lock(mutex);
// 临界区
}
當作用域結束:
}
↓
lock 对象自动析构
↓
自动释放 mutex
就不需要程序員自己到處寫:
unlock();
這就是:
RAII 鎖
3.2.std::lock_guard
最簡單、最常用的 RAII 鎖之一就是:
std::lock_guard
例如:
std::mutex mutex;
void foo()
{
std::lock_guard<std::mutex> lock(mutex);
// 临界区
}
執行:
std::lock_guard<std::mutex> lock(mutex);
時,可以簡單理解成:
lock_guard 构造
↓
mutex.lock()
離開作用域時:
lock_guard 析构
↓
mutex.unlock()
所以:
void foo()
{
std::lock_guard<std::mutex> lock(mutex);
change_shared_state();
} // 自动释放 mutex
不需要再寫:
mutex.unlock();
3.3.用 lock_guard 改寫 counter
之前的代碼可以寫成:
#include <print>
#include <mutex>
#include <thread>
int counter = 0;
std::mutex counter_mutex;
void add_many()
{
for (int i = 0; i < 100000; ++i)
{
std::lock_guard<std::mutex> lock(counter_mutex);
++counter;
}
}
int main()
{
std::thread t1(add_many);
std::thread t2(add_many);
t1.join();
t2.join();
std::println("{}", counter);
}
運行結果:
200000
每輪循環:
创建 lock_guard
↓
获得 counter_mutex
↓
++counter
↓
这一轮作用域结束
↓
lock_guard 析构
↓
自动释放 mutex
這樣就算以後代碼中出現:
return;
或者正常的異常棧展開,也不容易忘記釋放鎖。
C++17 開始還可以利用類模板參數推導寫成:
std::lock_guard lock(counter_mutex);
不一定必須寫:
std::lock_guard<std::mutex>
3.4.可以用 {} 主動縮小鎖的作用域
RAII 鎖什麼時候釋放?
答案就是:
鎖對象什麼時候析構,mutex 就什麼時候釋放。
所以我們可以使用 {} 創建一個局部作用域:
void foo()
{
do_something();
{
std::lock_guard lock(mutex);
change_shared_state();
}
do_other_work();
}
執行邏輯:
do_something()
获得 mutex
↓
修改共享数据
↓
释放 mutex
do_other_work()
這樣:
do_other_work();
就不會繼續佔着 mutex。
3.5.臨界區應該儘量小
假設寫成:
std::lock_guard lock(mutex);
do_big_calculation();
save_file();
send_network_data();
update_shared_state();
那麼整個過程中:
mutex 一直被当前线程持有
其他需要這把 mutex 的線程只能:
等待
但:
do_big_calculation();
save_file();
send_network_data();
可能根本不需要訪問共享數據。
更合理的方式通常是:
do_big_calculation();
save_file();
send_network_data();
{
std::lock_guard lock(mutex);
update_shared_state();
}
因此通常應該:
只把真正需要互斥保護的操作放進臨界區。
尤其要謹慎在持鎖期間執行:
- 長時間計算;
sleep;- 文件 IO;
- 網絡 IO;
- 等待其他線程;
- 執行耗時未知的函數。
否則其他線程會長時間拿不到鎖。
3.6.但臨界區也不能亂拆
“臨界區儘量小”並不是説:
每一行代碼都單獨加一次鎖。
假設有:
int count;
double average;
並且這兩個值必須始終保持對應關係。
例如:
++count;
average = calculate_average();
那麼這兩個操作可能應該屬於同一個臨界區:
std::lock_guard lock(state_mutex);
++count;
average = calculate_average();
如果拆成:
{
std::lock_guard lock(state_mutex);
++count;
}
{
std::lock_guard lock(state_mutex);
average = calculate_average();
}
兩次加鎖之間,另一個線程可能插進來讀取:
新的 count
+
旧的 average
於是看到一個邏輯上不一致的狀態。
所以:
臨界區應該儘量小,但必須保證一次完整的共享狀態修改不會被拆開。
3.7.鎖保護的是“共享狀態”
假設:
struct User
{
std::string name;
int age;
};
如果:
name
age
共同組成一個完整的用户狀態,那麼更合理的是:
std::mutex user_mutex;
User user;
然後:
void update_user()
{
std::lock_guard lock(user_mutex);
user.name = "Tom";
user.age = 20;
}
而不是:
name 一把锁
age 一把锁
所以設計 mutex 時不要只想:
哪一行代碼要鎖?
更應該考慮:
哪些數據共同組成一個必須保持一致的共享狀態?
3.8.減少共享數據,通常比瘋狂加鎖更好
再看這個例子:
for (int i = 0; i < 100000; ++i)
{
std::lock_guard lock(counter_mutex);
++counter;
}
雖然是正確的,但會反覆:
lock
unlock
lock
unlock
lock
unlock
...
可以考慮改成:
void add_many()
{
int local_counter = 0;
for (int i = 0; i < 100000; ++i)
{
++local_counter;
}
std::lock_guard lock(counter_mutex);
counter += local_counter;
}
這裏:
local_counter
只被當前線程使用,所以不需要鎖。
整個線程先自己完成:
100000 次本地计算
最後才:
加一次锁
↓
更新共享 counter
這反映了併發編程中的一個重要思想:
能不共享的數據,就儘量不要共享。
因為:
没有共享
↓
通常也就不需要同步
4.std::unique_lock (常用)
lock_guard 的特點非常簡單:
创建
↓
加锁
销毁
↓
解锁
但有時候我們希望更加靈活。
例如:
先获得锁
↓
读取共享数据
↓
提前释放锁
↓
继续执行耗时操作
這時候可以使用:
std::unique_lock
最簡單的寫法:
std::unique_lock<std::mutex> lock(mutex);
和 lock_guard 一樣:
构造时获得 mutex
析构时自动释放 mutex
但 unique_lock 提供了更多控制能力。
4.1.unique_lock 可以提前解鎖
例如:
std::unique_lock lock(mutex);
read_shared_state();
lock.unlock();
do_expensive_work();
執行邏輯:
获得 mutex
↓
访问共享数据
↓
主动释放 mutex
↓
执行耗时操作
而 lock_guard 不提供這種手動解鎖能力。
4.2.unique_lock 可以再次加鎖
例如:
std::unique_lock lock(mutex);
read_shared_state();
lock.unlock();
do_something();
lock.lock();
change_shared_state();
所以 unique_lock 可以進行:
lock
↓
unlock
↓
lock
↓
unlock
不過這種代碼越複雜,鎖的設計也越容易出問題。
因此只有真的需要這種靈活性時才使用。
4.3.延遲加鎖:std::defer_lock
默認:
std::unique_lock lock(mutex);
會立即獲得 mutex。
但可以寫:
std::unique_lock lock(mutex, std::defer_lock);
此時:
unique_lock 对象已经创建
但是 mutex 还没有被锁住
之後再:
lock.lock();
例如:
std::unique_lock lock(mutex, std::defer_lock);
do_something();
lock.lock();
change_shared_state();
4.4.嘗試加鎖:std::try_to_lock
還可以:
std::unique_lock lock(mutex, std::try_to_lock);
它會嘗試獲得 mutex,但不會因為暫時拿不到鎖而一直等待。
之後可以:
if (lock.owns_lock())
{
// 已经获得 mutex
}
else
{
// 当前没有获得 mutex
}
4.5.owns_lock()
由於 unique_lock 比較靈活,所以一個:
unique_lock
對象不一定始終持有 mutex。
例如:
std::unique_lock lock(mutex, std::defer_lock);
剛創建時:
lock.owns_lock()
為:
false
執行:
lock.lock();
以後:
lock.owns_lock()
才為:
true
如果再:
lock.unlock();
又會變回:
false
4.6.lock_guard 和 unique_lock 怎麼選
初學階段可以直接記:
| 工具 | 特點 | 常見場景 |
|---|---|---|
std::lock_guard |
簡單,整個作用域始終持鎖 | 普通臨界區 |
std::unique_lock |
可以主動解鎖、重新加鎖、延遲加鎖、嘗試加鎖 | 需要靈活管理鎖生命週期 |
所以通常:
普通情况
↓
lock_guard
需要:
unlock()
重新 lock()
defer_lock
try_to_lock
等能力時:
unique_lock
不要因為 unique_lock 功能更多,就認為:
它永遠比
lock_guard更好。
功能更多也意味着狀態更復雜。
能使用簡單方案時,一般優先簡單方案。
| 項目 | lock_guard |
unique_lock |
|---|---|---|
| 自動加鎖/解鎖 | ✅ | ✅ |
手動 unlock() / lock() |
❌ | ✅ |
| 延遲加鎖 | ❌ | ✅ |
try_lock |
❌ | ✅ |
| move | ❌ | ✅ |
condition_variable |
❌ | ✅ |
| 對象大小 | 更小 | 通常更大 |
| 理論運行開銷 | 最低 | 略高 |
| 靈活性 | 低 | 高 |
單論效率,std::lock_guard 略高於 std::unique_lock,但通常差距小到基本不用考慮。
5.死鎖與多把鎖
5.1.什麼是死鎖
mutex 可以解決很多線程同時訪問共享數據的問題。
但如果同時使用多把 mutex,又可能產生新的問題:
死鎖(Deadlock),當然不是V社開發的那個遊戲死鎖。
假設:
std::mutex m1;
std::mutex m2;
線程 A:
void task1()
{
std::lock_guard lock1(m1);
std::lock_guard lock2(m2);
}
線程 B:
void task2()
{
std::lock_guard lock2(m2);
std::lock_guard lock1(m1);
}
兩個線程的鎖順序不同:
线程 A:
m1 → m2
线程 B:
m2 → m1
於是可能發生:
线程 A 获得 m1
线程 B 获得 m2
线程 A 想获得 m2
但是 m2 在线程 B 手里
↓
A 等待 B
线程 B 想获得 m1
但是 m1 在线程 A 手里
↓
B 等待 A
最終:
A 等 B
B 等 A
誰都無法繼續。
這就是:
死鎖
5.2.一個形象的死鎖例子
可以把兩把 mutex 想像成兩根筷子。
兩個人吃飯都必須同時擁有:
左筷子
+
右筷子
A 拿到了左筷子。
B 拿到了右筷子。
然後:
A:等 B 放下右筷子
B:等 A 放下左筷子
但兩個人誰都不願意先放下自己已經拿到的筷子。
於是:
谁也吃不上饭
這和線程死鎖非常類似。
5.3.避免死鎖:固定加鎖順序
一個非常重要的方法就是:
整個程序規定統一的鎖獲取順序。
例如規定:
永远先获得 m1
再获得 m2
那麼所有線程都必須:
std::lock_guard lock1(m1);
std::lock_guard lock2(m2);
而不能有些地方寫:
m1 → m2
另一些地方卻寫:
m2 → m1
統一鎖順序可以避免很多典型死鎖。
5.4.std::scoped_lock
如果一個操作本來就需要:
同時獲得多把 mutex
C++17 提供:
std::scoped_lock
例如:
#include <mutex>
std::mutex m1;
std::mutex m2;
void task()
{
std::scoped_lock lock(m1, m2);
// 当前同时持有 m1 和 m2
}
它會使用標準庫提供的死鎖避免策略獲取這些鎖。
相比:
std::lock_guard lock1(m1);
std::lock_guard lock2(m2);
如果你的意圖本來就是:
我要一起拿到 m1 和 m2
那麼:
std::scoped_lock lock(m1, m2);
通常更加直接。
作用域結束以後:
scoped_lock 析构
↓
自动释放它管理的锁
5.4.1.scoped_lock 也可以管理一把鎖
它不一定必須傳兩把以上 mutex。
也可以:
std::scoped_lock lock(mutex);
對於一把鎖的普通場景,它和:
std::lock_guard lock(mutex);
很接近。
初學階段可以簡單記:
普通单锁
→ lock_guard
需要灵活控制
→ unique_lock
同时管理多把锁
→ scoped_lock
5.5.std::lock()
C++11 還提供:
std::lock(m1, m2);
它可以使用死鎖避免算法嘗試獲取多把可鎖對象。
例如:
std::lock(m1, m2);
執行成功以後:
m1 已经被当前线程获得
m2 也已经被当前线程获得
接下來通常需要把它們交給 RAII 對象管理。
例如:
std::lock(m1, m2);
std::lock_guard lock1(m1, std::adopt_lock);
std::lock_guard lock2(m2, std::adopt_lock);
這裏:
std::adopt_lock
可以理解為告訴 lock_guard:
這把 mutex 已經被我提前鎖好了,你不要再調用
lock(),只需要從現在開始接管它,最後負責釋放。
所以:
std::adopt_lock
的前提是:
當前線程必須已經持有這把鎖。
C++17 以後,如果只是:
同时获得多把 mutex
+
自动管理它们的生命周期
通常:
std::scoped_lock lock(m1, m2);
更加簡單。
| 鎖 | 單 mutex 性能 | 靈活性 | 多 mutex |
|---|---|---|---|
lock_guard |
⭐⭐⭐⭐⭐ | 低 | 不方便 |
scoped_lock |
⭐⭐⭐⭐⭐ | 低 | 最好 |
unique_lock |
⭐⭐⭐⭐≈ | 最高 | 可配合 std::lock |
6.其他 mutex 類型
6.1.std::recursive_mutex
普通:
std::mutex
不適合讓同一個線程重複獲得同一把 mutex。
例如:
void A()
{
std::lock_guard lock(mutex);
B();
}
void B()
{
std::lock_guard lock(mutex);
}
調用:
A()
↓
已经获得 mutex
↓
A() 调用 B()
↓
B() 又尝试获得同一把 mutex
這會出問題。
如果程序確實需要:
同一個線程重複進入由同一把鎖保護的區域
可以使用:
std::recursive_mutex
例如:
std::recursive_mutex mutex;
它允許同一個線程重複獲得這把鎖。
不過:
recursive_mutex通常不應該成為第一選擇。
如果代碼大量依賴遞歸鎖,有時説明:
函数职责
或
锁的边界
設計得過於複雜。
能通過重新整理代碼結構避免遞歸加鎖時,通常更加容易維護。
6.2.std::timed_mutex
普通:
mutex.lock();
可以理解成:
暫時拿不到,就一直等待。
但有時候程序不希望無限等待。
例如:
最多等 100ms
這時可以使用:
std::timed_mutex
它支持:
try_lock_for()
try_lock_until()
例如:
#include <chrono>
#include <mutex>
std::timed_mutex mutex;
if (mutex.try_lock_for(std::chrono::milliseconds(100)))
{
// 成功获得 mutex
mutex.unlock();
}
else
{
// 在等待时间内没有获得 mutex
}
其中:
try_lock_for()
強調:
最多等待多長時間。
例如:
100ms
2s
而:
try_lock_until()
強調:
等待到哪個時間點。
它們和之前學習過的:
sleep_for()
sleep_until()
在“相對時間 / 絕對時間”這個思路上比較類似。
6.3.std::shared_mutex:多讀單寫
普通 mutex 有一個特點:
不管你是讀數據還是寫數據,同一時刻都只能有一個線程持有鎖。
假設程序裏有:
std::string config;
很多線程都只是:
读取 config
真正修改它的情況非常少。
如果使用普通 mutex:
线程 A 读
↓
线程 B 即使也只是读
也必须等待
但:
读 + 读
通常並不會互相破壞數據。
真正危險的是:
读 + 写
或者:
写 + 写
所以 C++17 提供:
std::shared_mutex
它可以支持:
多個讀線程同時進入,但寫線程必須獨佔。
6.3.1.shared_lock 和 unique_lock
使用 shared_mutex 時:
讀取通常使用:
std::shared_lock
寫入通常使用:
std::unique_lock
例如:
#include <mutex>
#include <shared_mutex>
#include <string>
std::string config;
std::shared_mutex config_mutex;
std::string read_config()
{
std::shared_lock lock(config_mutex);
return config;
}
void write_config(std::string value)
{
std::unique_lock lock(config_mutex);
config = std::move(value);
}
多個線程都讀取:
线程 A:shared_lock
线程 B:shared_lock
线程 C:shared_lock
可以同時存在。
但是寫線程:
std::unique_lock lock(config_mutex);
必須獨佔。
可以簡單記成:
shared_lock + shared_lock
✓ 可以同时存在
shared_lock + unique_lock
✗ 不可以
unique_lock + unique_lock
✗ 不可以
這就是:
多讀單寫。
6.3.2.為什麼讀取也可能需要鎖
有一種很常見的錯誤寫法:
void write()
{
std::lock_guard lock(mutex);
value = 10;
}
int read()
{
return value;
}
有人會覺得:
寫的時候加鎖就夠了,讀又不會修改變量。
實際上並不是。
如果:
线程 A 正在写 value
同時:
线程 B 正在读 value
仍然可能產生數據競爭。
所以:
如果共享數據存在併發寫入,那麼對應的讀取通常也必須參與同一套同步規則。
不能只鎖寫,不管讀。
6.3.3.shared_mutex 不一定比普通 mutex 更快
看到:
多个线程可以同时读
很容易產生一個誤解:
那以後全部用 shared_mutex 不就好了?
實際上 shared_mutex 自己需要維護更加複雜的狀態,例如:
现在有几个读线程?
有没有写线程?
什么时候允许写线程进入?
這些本身都有額外開銷。
所以:
std::shared_mutex
比較適合:
读很多
写很少
并且确实存在大量并发读取
的場景。
不要簡單認為:
shared_mutex 一定比 mutex 快
它們只是適用場景不同。
7.一次性初始化
7.1.std::call_once
還有一類特殊的多線程需求:
某段初始化代碼只能成功執行一次。
例如:
initialize();
可能有三個線程同時第一次進入:
线程 A
线程 B
线程 C
但我們希望:
initialize()
最終只成功完成一次。
標準庫提供:
std::once_flag
std::call_once
例如:
#include <print>
#include <mutex>
#include <thread>
std::once_flag flag;
void initialize()
{
std::println("initialize once");
}
void worker()
{
std::call_once(flag, initialize);
}
int main()
{
std::thread t1(worker);
std::thread t2(worker);
std::thread t3(worker);
t1.join();
t2.join();
t3.join();
}
運行結果:
initialize once
三個線程都會執行:
std::call_once(flag, initialize);
但:
initialize();
只會成功完成一次。
可以粗略理解成:
线程 A ─┐
线程 B ─┼→ call_once → initialize 成功一次
线程 C ─┘
如果初始化函數執行過程中拋出異常,這一次不會被認為已經成功完成,後續調用仍然可以再次嘗試。
7.2.函數局部 static 初始化也是線程安全的
C++11 開始:
MyObject& instance()
{
static MyObject object;
return object;
}
這裏:
static MyObject object;
的初始化本身已經具有線程安全保證。
所以如果目的只是:
延遲初始化一個函數內部的 static 對象
通常不需要額外再寫:
std::call_once
8.工程實踐 (重要)
8.1.最好把 mutex 和它保護的數據封裝在一起
不推薦設計成:
函数 A:
调用前必须先锁 mutex
函数 B:
自己内部会锁
函数 C:
只能在已经持锁时调用
函数 D:
有时候锁,有时候不锁
代碼一多以後,很容易變成:
到底誰負責加鎖?
更好的設計通常是:
讓擁有共享數據的類自己管理同步。
例如:
class Counter
{
public:
void increment()
{
std::lock_guard lock(mutex_);
++value_;
}
int get() const
{
std::lock_guard lock(mutex_);
return value_;
}
private:
mutable std::mutex mutex_;
int value_ = 0;
};
外部只需要:
counter.increment();
並不需要知道:
内部用了哪把 mutex
什么时候 lock
什么时候 unlock
這樣同步規則更加集中,也更不容易漏鎖。
8.2.為什麼 mutex 經常寫成 mutable (重要)
前面的:
int get() const
是一個 const 成員函數。
但是對 mutex:
mutex_.lock();
mutex_.unlock();
會修改 mutex 自己的內部狀態。
所以通常寫:
mutable std::mutex mutex_;
這樣即使當前成員函數是:
const
仍然可以對 mutex 加鎖。
這並不意味着:
get()
真的修改了 Counter 對外表現出來的邏輯狀態。
它只是修改了內部的同步狀態。
所以 mutex 寫成:
mutable
是非常常見的做法。
8.3.持鎖時不要隨便調用未知代碼
例如:
std::lock_guard lock(mutex);
some_callback();
如果:
some_callback();
內部又嘗試獲得:
mutex
或者又獲得其他 mutex,就可能產生非常複雜的鎖依賴關係。
因此一個很實用的原則是:
持鎖期間儘量不要調用鎖行為未知的外部代碼。
特別是:
回调函数
虚函数
第三方复杂函数
用户传入函数
都應該謹慎。
8.4.三種常用 RAII 鎖怎麼記
初學階段可以記成:
std::lock_guard
│
├─ 简单
├─ 构造时加锁
├─ 析构时解锁
└─ 普通单锁场景优先考虑
std::unique_lock
│
├─ 更灵活
├─ 可以 unlock()
├─ 可以重新 lock()
├─ 支持 defer_lock
└─ 支持 try_to_lock
std::scoped_lock
│
├─ C++17
├─ RAII
├─ 可以管理一把锁
└─ 特别适合同时管理多把锁
最簡單的記憶方式:
普通单锁
→ lock_guard
灵活控制
→ unique_lock
同时多锁
→ scoped_lock
9.常見錯誤
9.1.只給寫操作加鎖
void write()
{
std::lock_guard lock(mutex);
value = 10;
}
int read()
{
return value;
}
如果存在併發寫入,無同步讀取同樣可能產生數據競爭。
9.2.手動 lock() 後忘記 unlock()
例如:
mutex.lock();
if (error)
{
return;
}
mutex.unlock();
因此普通場景優先使用 RAII 鎖。
9.3.多把鎖的順序不一致
线程 A:
m1 → m2
线程 B:
m2 → m1
可能導致死鎖。
9.4.臨界區太大
例如:
std::lock_guard lock(mutex);
sleep();
network_io();
file_io();
big_calculation();
其他線程可能長時間無法獲得 mutex。
9.5.臨界區太小
本來應該整體更新的數據被拆開:
锁
修改 A
解锁
锁
修改 B
解锁
可能讓其他線程在中間看到不一致狀態。
9.6.持鎖調用未知函數
std::lock_guard lock(mutex);
callback();
如果 callback 內部又涉及鎖,可能造成死鎖或複雜鎖依賴。
9.7.為了“安全”到處加鎖
鎖不是越多越安全。
鎖越多,可能帶來:
更复杂的锁关系
↓
更多线程竞争
↓
更差的性能
↓
更高的死锁风险
真正應該做的是:
明確哪一份共享狀態由哪一把 mutex 保護。
9.8.認為 mutex 會自動保護變量
下面:
std::mutex mutex;
int counter;
並不會自動建立:
mutex 保护 counter
的關係。
必須讓所有線程遵守:
访问 counter 前
必须按照约定获得 mutex
mutex 才真正起作用。
9.9.認為 shared_mutex 一定更快
shared_mutex 允許多個線程同時讀,但管理成本也更高。
只有真正:
读很多
写很少
存在明显并发读取需求
時才值得考慮。
10.最重要的 mutex 心智模型
看到:
std::mutex mutex;
不要簡單理解成:
創建了一把可以鎖變量的鎖。
更好的理解是:
創建了一個多個線程競爭“進入某段臨界區資格”的同步對象。
看到:
std::lock_guard lock(mutex);
可以理解成:
线程想进入临界区
↓
先获得 mutex
↓
获得成功
↓
进入临界区
↓
离开作用域
↓
自动释放 mutex
其他線程:
暂时拿不到 mutex
↓
不能同时进入
這才是 mutex 最核心的思想。
11.小結
std::mutex 最核心的作用是:
讓多個線程互斥訪問共享可變狀態。
最基礎的三個操作:
lock()
try_lock()
unlock()
但實際開發中,普通場景不推薦手動寫:
mutex.lock();
...
mutex.unlock();
而應該優先讓 RAII 對象管理鎖。
普通單鎖場景:
std::lock_guard
需要更加靈活地控制鎖生命週期:
std::unique_lock
需要同時管理多把鎖:
std::scoped_lock
需要多讀單寫:
std::shared_mutex
需要帶超時地等待:
std::timed_mutex
確實需要同一個線程重複進入同一個臨界區時:
std::recursive_mutex
需要讓某段初始化邏輯只成功執行一次:
std::call_once
併發代碼中真正需要思考的,通常不是:
mutex API 怎么写?
而是:
哪些数据会被多个线程共享?
哪些操作必须作为一个整体?
哪把 mutex 负责保护这些状态?
临界区应该有多大?
有没有同时获取多把锁?
不同地方的锁顺序是否一致?
把這些問題想明白以後,mutex 本身的代碼通常反而並不複雜。
對於“單個簡單共享變量”的同步,以及“讓線程等待某個條件發生”這兩類問題,後面的 std::atomic 和 std::condition_variable 章節會分別繼續介紹。