cpp中的锁

mutex基础用法

创建std::mutex:

1
std::mutex mtx_;

对mutex加锁:

1
mtx_.lock();

对mutex解锁:

1
mtx_.unlock();

以上是 std::mutex 的基础用法,需要显式地调用 lock() 和 unlock() 来加锁和解锁。这种方式容易因异常或提前返回而导致忘记解锁。为了避免这种风险,可以使用基于 RAII(资源获取即初始化)思想的锁对象,在创建时自动加锁,并在离开作用域时自动解锁,从而确保锁的正确释放。

使用std::lock_guard

std::lock_guard 是最简单的 RAII 锁封装,构造时自动上锁,析构时自动解锁:

1
2
3
4
5
6
std::mutex mtx;

void safe_print() {
std::lock_guard<std::mutex> lock(mtx);
std::cout << "thread-safe printing\n";
} // 离开作用域时自动解锁

推荐在函数作用域内使用

使用std::unique_lock

std::unique_lock 功能更强大,支持延迟上锁、手动解锁、重新加锁、条件变量等。如果没有解锁,则在离开锁的作用域时会自动解锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
std::mutex mtx;

void example() {
std::unique_lock<std::mutex> lock(mtx, std::defer_lock); // 延迟上锁,先创建,不立即加锁
// 执行其他准备操作

lock.lock(); // 手动加锁
// 临界区

lock.unlock(); // 手动解锁

lock.lock(); // 重新上锁
// 临界区
}

std::unique_lock 是 std::condition_variable 唯一支持的锁类型。
这是它最重要的用途之一:允许线程等待条件满足时再继续执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void worker() {
std::unique_lock<std::mutex> lock(mtx);
std::cout << "工作线程开始等待\n";
cv.wait(lock, [] { return ready; }); // 等待条件满足
std::cout << "工作线程开始执行\n";
}

void notifier() {
std::this_thread::sleep_for(std::chrono::seconds(3));
{
std::lock_guard<std::mutex> lock(mtx);
ready = true;
}
cv.notify_one(); // 唤醒等待线程
}

使用std::scoped_lock

std::scoped_lock 是 cpp17 引入的一种更现代的锁管理工具,用于简化多互斥量(std::mutex)的加锁操作,并且可以自动避免死锁。

单个锁:

1
2
3
4
5
6
std::mutex mtx;

void example() {
std::scoped_lock lock(mtx);
// 临界区
} // 离开作用域时自动解锁

多个锁(自动避免死锁)

1
2
3
4
5
6
7
8
9
void f1() {
std::scoped_lock lock(m1, m2); // 一次性安全加锁两个互斥量
std::cout << "线程1获取 m1, m2\n";
}

void f2() {
std::scoped_lock lock(m1, m2); // 即使顺序不同也不会死锁
std::cout << "线程2获取 m1, m2\n";
}

std::scoped_lock 会自动避免死锁

死锁通常发生在多个线程同时试图获取多个互斥锁,且加锁顺序不一致的情况下:

1
2
3
4
5
6
7
8
9
10
11
std::mutex m1, m2;

void ThreadA() {
std::lock_guard<std::mutex> lock1(m1);
std::lock_guard<std::mutex> lock2(m2);
}

void ThreadB() {
std::lock_guard<std::mutex> lock1(m2);
std::lock_guard<std::mutex> lock2(m1);
}

std::scoped_lock 底层实现是基于 std::lock(),而 std::lock() 是一个专门为避免死锁设计的函数。std::lock() 打破了不可剥夺条件,采用“全拿全放”策略,核心思想是尝试性加锁 + 回退重试。

[!WARNING]
注意:条件变量必须使用 std::unique_lock,不能用 std::scoped_lock。

读写锁

cpp17引入了 std::shared_mutex 和 std::shared_lock 来实现“读写锁”机制。包含于头文件<shared_mutex>中。

写线程(独占锁):

写线程必须使用 std::unique_lock 或 std::lock_guard 或 std::scoped_lock 来加独占锁:

1
2
3
4
5
6
7
8
9
10
11
12
13

#include <shared_mutex>
#include <thread>
#include <iostream>

std::shared_mutex smtx;
int counter = 0;

void writer() {
std::unique_lock<std::shared_mutex> lock(smtx); // 写锁(独占)
++counter;
std::cout << "写线程修改 counter = " << counter << "\n";
}

读线程(共享锁):

读线程可以使用 std::shared_lock 来加共享锁:

1
2
3
4
void reader(int id) {
std::shared_lock<std::shared_mutex> lock(smtx); // 读锁(共享)
std::cout << "读线程 " << id << " 读取 counter = " << counter << "\n";
}

C++ 锁的实现:Top-Down 视角

总览

一把 std::mutex 从上到下,大致可以拆成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
C++ 代码

std::mutex

libstdc++ / libc++ 实现

pthread_mutex_t

glibc pthread_mutex_lock / pthread_mutex_unlock

用户态 atomic 快路径

竞争时 futex_wait / futex_wake

Linux futex 子系统

线程睡眠 / 唤醒

调度器 runqueue

CPU 原子指令 + 缓存一致性 + 内存屏障

一句话总结:

std::mutex 是 C++ 标准库提供的锁接口;在 Linux 上,它通常封装 pthread_mutex_tpthread_mutex_t 的具体实现通常由 glibc 提供;glibc 先用用户态 atomic 尝试加锁,无竞争时不进入内核;竞争时通过 futex 进入内核完成线程睡眠和唤醒;最底层依赖 CPU 原子指令、缓存一致性和内存屏障保证正确性。


第一层:std::mutex 是 C++ 标准库接口

std::mutex 保证的是:

1
2
3
同一时间只有一个线程能持有这把锁;
拿不到锁的线程会阻塞等待;
unlock 释放锁。

但是 C++ 标准只规定行为,不规定底层实现。

也就是说,标准不要求它必须使用:

1
2
3
4
pthread_mutex_t
futex
Windows Critical Section
自旋锁

所以这一层只是 C++ 接口语义层


第二层:std::mutex 通常封装 pthread_mutex_t

在 Linux 上,常见实现大致是:

1
2
3
std::mutex

pthread_mutex_t

概念上类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class mutex {
private:
pthread_mutex_t native_;

public:
void lock() {
pthread_mutex_lock(&native_);
}

bool try_lock() {
return pthread_mutex_trylock(&native_) == 0;
}

void unlock() {
pthread_mutex_unlock(&native_);
}
};

pthread_mutex_t 可以理解成:

POSIX 线程库定义的 C 风格互斥锁对象。

它不是 C++ 的东西,而是 POSIX Threads 里的同步对象。

在 Linux + glibc 环境下:

1
2
3
pthread_mutex_t 的具体数据布局:glibc 定义;
pthread_mutex_lock/unlock 的具体逻辑:glibc 实现;
竞争时的睡眠/唤醒能力:Linux futex 提供。

第三层:glibc 的用户态快路径

进入 pthread_mutex_lock() 后,并不是马上进入内核。

glibc 会先看 mutex 内部的状态字,可以粗略理解成:

1
2
3
0:未加锁
1:已加锁,无等待者
2:已加锁,可能有等待者

加锁时先尝试用户态原子操作:

1
2
3
if (CAS(lock_word, 0, 1)) {
return; // 抢锁成功,不进内核
}

所以无竞争时:

1
2
3
4
5
用户态 CAS 成功

直接返回

不发生系统调用

有竞争时:

1
2
3
4
5
CAS 失败

进入慢路径

必要时调用 futex_wait 进入内核睡眠

解锁也类似:

1
2
3
4
5
没有等待者:
用户态 store 释放,直接返回

可能有人等待:
释放锁后调用 futex_wake,进入内核唤醒等待线程

这一层的核心是:

能不进内核就不进内核。


第四层:futex wait / wake:用户态锁和内核睡眠/唤醒的接口

futex 的重点不是“实现锁”,而是提供两个能力:

1
2
wait:让线程在某个用户态地址上睡眠
wake:唤醒睡在某个用户态地址上的线程

对 mutex 来说,最核心的是这一对操作:

1
2
futex_wait(&lock_word, expected);
futex_wake(&lock_word, 1);

futex_wait(addr, expected) 的语义是:

1
2
3
4
5
如果 *addr 仍然等于 expected:
当前线程睡眠

否则:
立刻返回,不睡

futex_wake(addr, n) 的语义是:

1
唤醒最多 n 个睡在 addr 对应等待队列上的线程

在 mutex 里的典型用法是:

1
2
3
4
5
6
7
8
9
加锁:
用户态 CAS 尝试抢锁
成功:直接返回
失败:调用 futex_wait 睡眠

解锁:
用户态释放锁
如果可能有人等待:
调用 futex_wake 唤醒一个等待线程

所以这一层可以总结成:

atomic 负责修改锁状态;futex_wait 负责竞争时睡眠;futex_wake 负责解锁时唤醒。


第五层:操作系统如何响应 futex_wait / futex_wake

用户态调用:

1
futex_wait(&lock_word, expected);

进入内核后,操作系统大致做:

1
2
3
4
5
6
7
1. 根据用户态地址 &lock_word 生成 futex key;
2. 根据 futex key 找到对应的 futex hash bucket;
3. 再次检查 *lock_word 是否仍然等于 expected;
4. 如果不等,直接返回用户态;
5. 如果相等,把当前线程挂到对应等待队列;
6. 设置当前线程为 sleeping;
7. 调用 schedule(),让出 CPU。

也就是:

1
2
3
4
5
6
7
8
9
用户态地址

futex key

futex hash bucket

等待队列

当前线程睡眠

用户态调用:

1
futex_wake(&lock_word, 1);

进入内核后,操作系统大致做:

1
2
3
4
5
1. 根据同一个用户态地址生成 futex key;
2. 找到对应的 futex hash bucket;
3. 在等待队列中找到 key 匹配的等待线程;
4. 唤醒线程;
5. 把线程放回调度器 runqueue。

也就是:

1
2
3
4
5
6
7
futex 等待队列

wake_up_process(task)

CPU runqueue

等待调度器选择运行

这一层的核心是:

futex 把一个用户态地址变成了内核中的等待点;wait 让线程从运行状态进入等待队列,wake 让线程从等待队列回到调度器 runqueue。


第六层:CPU 需要提供哪些能力

再往下就是硬件层。

锁最终依赖 CPU 提供两个核心能力:

1
2
1. 原子修改共享变量;
2. 控制必要的内存顺序。

第一类能力是原子指令,例如:

1
2
3
4
compare-and-swap
test-and-set
exchange
fetch-add

在 C++ 中通常对应:

1
2
3
4
std::atomic<int>
compare_exchange_strong
exchange
fetch_add

这些原子操作保证 检查锁状态 + 修改锁状态 这个动作不能被其他线程打断。

第二类能力是内存顺序控制。

锁通常需要:

1
2
lock:acquire 语义
unlock:release 语义

含义是:

1
2
3
4
5
acquire:
lock 后面的读写不能跑到 lock 前面

release:
unlock 前面的读写不能跑到 unlock 后面

否则临界区里的代码可能被 CPU 或编译器乱序到锁外面,锁的语义就会被破坏。

所以最底层可以总结成:

CPU 原子指令保证抢锁动作不可分割;内存屏障和 acquire/release 语义保证临界区读写不会乱序到锁外。


一句话总结

现代 mutex 的实现主干是:用户态 atomic 先尝试完成无竞争加锁;竞争时通过 futex_wait 进入内核睡眠;解锁时通过 futex_wake 唤醒等待线程;操作系统负责等待队列和调度;CPU 负责原子修改和内存顺序保证。