跳到正文

Wiki

mutex 與 RAII 鎖

約 14 分鐘閱讀

本文由簡體中文內容確定性轉換,並受版本化術語表保護。

多個線程同時運行時,如果它們會訪問同一份共享數據,並且其中至少有一個線程會修改這份數據,就必須開始考慮線程同步問題。

C++ 中最基礎、最常用的同步工具之一就是:

std::mutex

mutex 的中文通常叫:

互斥量

它最核心的作用可以簡單理解成:

同一時刻只允許一個線程進入某段需要保護的代碼。

這一節重點學習:

  • 什麼是共享數據、數據競爭和臨界區;
  • std::mutex 到底“鎖”的是什麼;
  • lock()try_lock()unlock()
  • 為什麼不推薦手動成對調用 lock() / unlock()
  • RAII 為什麼特別適合管理鎖;
  • std::lock_guard 的基本使用;
  • std::unique_lock 為什麼更加靈活;
  • std::scoped_lock 為什麼適合多把鎖;
  • 什麼是死鎖,以及怎樣避免;
  • std::recursive_mutexstd::timed_mutex
  • std::shared_mutex 的多讀單寫;
  • std::call_once 的一次性初始化。

1.為什麼需要互斥量

先來看一個最經典的例子:

#include <iostream>
#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::cout << counter << '\n';
}

這裏創建了兩個線程:

线程 t1
线程 t2

每個線程都執行:

++counter;

100000 次。

直覺上:

100000 + 100000 = 200000

所以好像最後一定應該輸出:

200000

但實際上,這段程序存在嚴重的併發問題。

問題就在:

++counter;

2.++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++ 原始碼裏看起來只有一條語句,也不代表它在多線程環境中天然不可被其他線程干擾。


3.什麼是數據競爭

如果:

  • 多個線程訪問同一個內存位置;
  • 至少有一個線程會寫;
  • 這些訪問之間沒有正確的同步;

就可能產生:

數據競爭(Data Race)

前面的:

++counter;

就是典型例子。

線程 A 和線程 B 都在:

读取 counter
修改 counter

並且沒有任何同步措施。

在 C++ 中,數據競爭通常意味着:

未定義行為(Undefined Behavior)

所以不能只理解成:

“最後結果偶爾少 1。”

真正的問題是:

一旦發生數據競爭,C++ 標準就不再保證程序行為。

因此多線程編程中第一件非常重要的事情就是:

不要讓多個線程無保護地同時訪問共享可變數據。


4.什麼是臨界區

假設有:

++counter;

這段代碼訪問了多個線程共享的變量:

counter

並且我們希望:

同一時刻只能有一個線程執行它。

那麼這段代碼就可以稱為:

臨界區(Critical Section)

簡單理解:

临界区
=
不能让多个线程同时执行的代码区域

例如:

++counter;

也可能是一整組操作:

++count;
average = calculate_average();

只要這些操作必須作為一個整體被保護,都可以屬於同一個臨界區。


5.std::mutex 到底是什麼

mutex 是:

mutual exclusion

也就是:

互斥

可以把它想像成一把只有一把鑰匙的門鎖。

假設有一個房間:

共享数据

門口放着:

一把 mutex

線程 A 想進去:

线程 A

尝试获得 mutex

成功

进入临界区

此時線程 B 也想進去:

线程 B

尝试获得 mutex

发现 mutex 已经被 A 持有

等待

等線程 A 離開:

线程 A 释放 mutex

線程 B 才有機會獲得它。

所以 mutex 的核心效果就是:

线程 A ─┐
线程 B ─┼→ 同一时刻只能有一个线程通过
线程 C ─┘

6.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 的作用。


7.std::mutex 的基本使用

std::mutex 定義在:

#include <mutex>

最基礎的三個操作是:

mutex.lock();
mutex.try_lock();
mutex.unlock();

8.lock():獲得鎖(不是加鎖,是獲得鎖)

最基本的用法:

mutex.lock();

如果當前 mutex 沒有被其他線程持有:

mutex 空闲

当前线程获得 mutex

继续执行

如果 mutex 已經被其他線程持有:

线程 A 已经持有 mutex

线程 B 调用 lock()

暂时拿不到

线程 B 阻塞等待

等線程 A:

mutex.unlock();

以後,線程 B 才有機會繼續。

所以:

lock();

可以簡單記成:

拿不到就等。(非常重要)


9.unlock():釋放鎖

線程獲得 mutex 後:

mutex.lock();

完成臨界區操作以後,就應該:

mutex.unlock();

例如:

mutex.lock();

++counter;

mutex.unlock();

執行過程大致就是:

获得 mutex

进入临界区

修改 counter

离开临界区

释放 mutex

釋放以後,其他等待這把 mutex 的線程才有機會繼續執行。


10.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。

更簡單的理解就是:

這次沒有獲得鎖。


11.用 mutex 修復 counter

之前的數據競爭可以這樣解決:

#include <iostream>
#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::cout << counter << '\n';
}

現在所有想修改:

counter

的線程,都必須先:

counter_mutex.lock();

於是可能出現:

线程 A 获得 counter_mutex

    ++counter

线程 A 释放 counter_mutex

线程 B 获得 counter_mutex

    ++counter

而不會兩個線程同時修改。

但是這種寫法還有一個很大的問題:

lock();
...
unlock();

完全由程序員自己管理。


12.為什麼不推薦手寫 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()

最大的問題就是:

必須保證程序的每一條離開路徑都正確釋放鎖。

代碼一複雜,就很容易遺漏。

這正是 RAII 特別適合解決的問題。


13.RAII 為什麼適合管理鎖

RAII 的核心思想之前已經學過:

讓資源的生命週期由對象生命週期管理。

mutex 的所有權也可以看成一種資源。

於是我們希望有這樣一個對象:

对象构造

mutex.lock()

对象析构

mutex.unlock()

這樣只需要:

{
    某个 RAII 锁对象 lock(mutex);

    // 临界区
}

當作用域結束:

}

lock 对象自动析构

自动释放 mutex

就不需要程序員自己到處寫:

unlock();

這就是:

RAII 鎖


14.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();

15.lock_guard 改寫 counter

之前的代碼可以寫成:

#include <iostream>
#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::cout << counter << '\n';
}

每輪循環:

创建 lock_guard

获得 counter_mutex

++counter

这一轮作用域结束

lock_guard 析构

自动释放 mutex

這樣就算以後代碼中出現:

return;

或者正常的異常棧展開,也不容易忘記釋放鎖。

C++17 開始還可以利用類模板參數推導寫成:

std::lock_guard lock(counter_mutex);

不一定必須寫:

std::lock_guard<std::mutex>

16.可以用 {} 主動縮小鎖的作用域

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。


17.臨界區應該儘量小

假設寫成:

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;
  • 等待其他線程;
  • 執行耗時未知的函數。

否則其他線程會長時間拿不到鎖。


18.但臨界區也不能亂拆

“臨界區儘量小”並不是説:

每一行代碼都單獨加一次鎖。

假設有:

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

於是看到一個邏輯上不一致的狀態。

所以:

臨界區應該儘量小,但必須保證一次完整的共享狀態修改不會被拆開。


19.鎖保護的是“共享狀態”

假設:

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 時不要只想:

哪一行代碼要鎖?

更應該考慮:

哪些數據共同組成一個必須保持一致的共享狀態?


20.減少共享數據,通常比瘋狂加鎖更好

再看這個例子:

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

這反映了併發編程中的一個重要思想:

能不共享的數據,就儘量不要共享。

因為:

没有共享

通常也就不需要同步

21.std::unique_lock

lock_guard 的特點非常簡單:

创建

加锁

销毁

解锁

但有時候我們希望更加靈活。

例如:

先获得锁

读取共享数据

提前释放锁

继续执行耗时操作

這時候可以使用:

std::unique_lock

最簡單的寫法:

std::unique_lock<std::mutex> lock(mutex);

lock_guard 一樣:

构造时获得 mutex
析构时自动释放 mutex

unique_lock 提供了更多控制能力。


22.unique_lock 可以提前解鎖

例如:

std::unique_lock lock(mutex);

read_shared_state();

lock.unlock();

do_expensive_work();

執行邏輯:

获得 mutex

访问共享数据

主动释放 mutex

执行耗时操作

lock_guard 不提供這種手動解鎖能力。


23.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

不過這種代碼越複雜,鎖的設計也越容易出問題。

因此只有真的需要這種靈活性時才使用。


24.延遲加鎖: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();

25.嘗試加鎖:std::try_to_lock

還可以:

std::unique_lock lock(mutex, std::try_to_lock);

它會嘗試獲得 mutex,但不會因為暫時拿不到鎖而一直等待。

之後可以:

if (lock.owns_lock())
{
    // 已经获得 mutex
}
else
{
    // 当前没有获得 mutex
}

26.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

27.lock_guardunique_lock 怎麼選

初學階段可以直接記:

工具 特點 常見場景
std::lock_guard 簡單,整個作用域始終持鎖 普通臨界區
std::unique_lock 可以主動解鎖、重新加鎖、延遲加鎖、嘗試加鎖 需要靈活管理鎖生命週期

所以通常:

普通情况

lock_guard

需要:

unlock()
重新 lock()
defer_lock
try_to_lock

等能力時:

unique_lock

不要因為 unique_lock 功能更多,就認為:

它永遠比 lock_guard 更好。

功能更多也意味着狀態更復雜。

能使用簡單方案時,一般優先簡單方案。


28.什麼是死鎖

mutex 可以解決很多線程同時訪問共享數據的問題。

但如果同時使用多把 mutex,又可能產生新的問題:

死鎖(Deadlock)

假設:

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

誰都無法繼續。

這就是:

死鎖


29.一個形象的死鎖例子

可以把兩把 mutex 想像成兩根筷子。

兩個人吃飯都必須同時擁有:

左筷子
+
右筷子

A 拿到了左筷子。

B 拿到了右筷子。

然後:

A:等 B 放下右筷子

B:等 A 放下左筷子

但兩個人誰都不願意先放下自己已經拿到的筷子。

於是:

谁也吃不上饭

這和線程死鎖非常類似。


30.避免死鎖:固定加鎖順序

一個非常重要的方法就是:

整個程序規定統一的鎖獲取順序。

例如規定:

永远先获得 m1
再获得 m2

那麼所有線程都必須:

std::lock_guard lock1(m1);
std::lock_guard lock2(m2);

而不能有些地方寫:

m1 → m2

另一些地方卻寫:

m2 → m1

統一鎖順序可以避免很多典型死鎖。


31.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 析构

自动释放它管理的锁

32.scoped_lock 也可以管理一把鎖

它不一定必須傳兩把以上 mutex。

也可以:

std::scoped_lock lock(mutex);

對於一把鎖的普通場景,它和:

std::lock_guard lock(mutex);

很接近。

初學階段可以簡單記:

普通单锁
→ lock_guard

需要灵活控制
→ unique_lock

同时管理多把锁
→ scoped_lock

33.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);

更加簡單。


34.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 通常不應該成為第一選擇。

如果代碼大量依賴遞歸鎖,有時説明:

函数职责

锁的边界

設計得過於複雜。

能通過重新整理代碼結構避免遞歸加鎖時,通常更加容易維護。


35.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()

在“相對時間 / 絕對時間”這個思路上比較類似。


36.std::shared_mutex:多讀單寫

普通 mutex 有一個特點:

不管你是讀數據還是寫數據,同一時刻都只能有一個線程持有鎖。

假設程序裏有:

std::string config;

很多線程都只是:

读取 config

真正修改它的情況非常少。

如果使用普通 mutex:

线程 A 读

线程 B 即使也只是读
也必须等待

但:

读 + 读

通常並不會互相破壞數據。

真正危險的是:

读 + 写

或者:

写 + 写

所以 C++17 提供:

std::shared_mutex

它可以支持:

多個讀線程同時進入,但寫線程必須獨佔。


37.shared_lockunique_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
✗ 不可以

這就是:

多讀單寫。


38.為什麼讀取也可能需要鎖

有一種很常見的錯誤寫法:

void write()
{
    std::lock_guard lock(mutex);

    value = 10;
}

int read()
{
    return value;
}

有人會覺得:

寫的時候加鎖就夠了,讀又不會修改變量。

實際上並不是。

如果:

线程 A 正在写 value

同時:

线程 B 正在读 value

仍然可能產生數據競爭。

所以:

如果共享數據存在併發寫入,那麼對應的讀取通常也必須參與同一套同步規則。

不能只鎖寫,不管讀。


39.shared_mutex 不一定比普通 mutex 更快

看到:

多个线程可以同时读

很容易產生一個誤解:

那以後全部用 shared_mutex 不就好了?

實際上 shared_mutex 自己需要維護更加複雜的狀態,例如:

现在有几个读线程?
有没有写线程?
什么时候允许写线程进入?

這些本身都有額外開銷。

所以:

std::shared_mutex

比較適合:

读很多
写很少
并且确实存在大量并发读取

的場景。

不要簡單認為:

shared_mutex 一定比 mutex 快

它們只是適用場景不同。


40.std::call_once

還有一類特殊的多線程需求:

某段初始化代碼只能成功執行一次。

例如:

initialize();

可能有三個線程同時第一次進入:

线程 A
线程 B
线程 C

但我們希望:

initialize()

最終只成功完成一次。

標準庫提供:

std::once_flag
std::call_once

例如:

#include <iostream>
#include <mutex>
#include <thread>

std::once_flag flag;

void initialize()
{
    std::cout << "initialize once\n";
}

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();
}

三個線程都會執行:

std::call_once(flag, initialize);

但:

initialize();

只會成功完成一次。

可以粗略理解成:

线程 A ─┐
线程 B ─┼→ call_once → initialize 成功一次
线程 C ─┘

如果初始化函數執行過程中拋出異常,這一次不會被認為已經成功完成,後續調用仍然可以再次嘗試。


41.函數局部 static 初始化也是線程安全的

C++11 開始:

MyObject& instance()
{
    static MyObject object;

    return object;
}

這裏:

static MyObject object;

的初始化本身已經具有線程安全保證。

所以如果目的只是:

延遲初始化一個函數內部的 static 對象

通常不需要額外再寫:

std::call_once

42.最好把 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

這樣同步規則更加集中,也更不容易漏鎖。


43.為什麼 mutex 經常寫成 mutable

前面的:

int get() const

是一個 const 成員函數。

但是對 mutex:

mutex_.lock();
mutex_.unlock();

會修改 mutex 自己的內部狀態。

所以通常寫:

mutable std::mutex mutex_;

這樣即使當前成員函數是:

const

仍然可以對 mutex 加鎖。

這並不意味着:

get()

真的修改了 Counter 對外表現出來的邏輯狀態。

它只是修改了內部的同步狀態。

所以 mutex 寫成:

mutable

是非常常見的做法。


44.持鎖時不要隨便調用未知代碼

例如:

std::lock_guard lock(mutex);

some_callback();

如果:

some_callback();

內部又嘗試獲得:

mutex

或者又獲得其他 mutex,就可能產生非常複雜的鎖依賴關係。

因此一個很實用的原則是:

持鎖期間儘量不要調用鎖行為未知的外部代碼。

特別是:

回调函数
虚函数
第三方复杂函数
用户传入函数

都應該謹慎。


45.三種常用 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

46.常見錯誤

46.1.只給寫操作加鎖

void write()
{
    std::lock_guard lock(mutex);

    value = 10;
}

int read()
{
    return value;
}

如果存在併發寫入,無同步讀取同樣可能產生數據競爭。


46.2.手動 lock() 後忘記 unlock()

例如:

mutex.lock();

if (error)
{
    return;
}

mutex.unlock();

因此普通場景優先使用 RAII 鎖。


46.3.多把鎖的順序不一致

线程 A:
m1 → m2

线程 B:
m2 → m1

可能導致死鎖。


46.4.臨界區太大

例如:

std::lock_guard lock(mutex);

sleep();
network_io();
file_io();
big_calculation();

其他線程可能長時間無法獲得 mutex。


46.5.臨界區太小

本來應該整體更新的數據被拆開:


修改 A
解锁


修改 B
解锁

可能讓其他線程在中間看到不一致狀態。


46.6.持鎖調用未知函數

std::lock_guard lock(mutex);

callback();

如果 callback 內部又涉及鎖,可能造成死鎖或複雜鎖依賴。


46.7.為了“安全”到處加鎖

鎖不是越多越安全。

鎖越多,可能帶來:

更复杂的锁关系

更多线程竞争

更差的性能

更高的死锁风险

真正應該做的是:

明確哪一份共享狀態由哪一把 mutex 保護。


46.8.認為 mutex 會自動保護變量

下面:

std::mutex mutex;
int counter;

並不會自動建立:

mutex 保护 counter

的關係。

必須讓所有線程遵守:

访问 counter 前
必须按照约定获得 mutex

mutex 才真正起作用。


46.9.認為 shared_mutex 一定更快

shared_mutex 允許多個線程同時讀,但管理成本也更高。

只有真正:

读很多
写很少
存在明显并发读取需求

時才值得考慮。


47.最重要的 mutex 心智模型

看到:

std::mutex mutex;

不要簡單理解成:

創建了一把可以鎖變量的鎖。

更好的理解是:

創建了一個多個線程競爭“進入某段臨界區資格”的同步對象。

看到:

std::lock_guard lock(mutex);

可以理解成:

线程想进入临界区

先获得 mutex

获得成功

进入临界区

离开作用域

自动释放 mutex

其他線程:

暂时拿不到 mutex

不能同时进入

這才是 mutex 最核心的思想。


48.小結

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::atomicstd::condition_variable 章節會分別繼續介紹。