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 | std::mutex mtx; |
推荐在函数作用域内使用
使用std::unique_lock
std::unique_lock 功能更强大,支持延迟上锁、手动解锁、重新加锁、条件变量等。如果没有解锁,则在离开锁的作用域时会自动解锁。
1 | std::mutex mtx; |
std::unique_lock 是 std::condition_variable 唯一支持的锁类型。
这是它最重要的用途之一:允许线程等待条件满足时再继续执行。
1 | std::mutex mtx; |
使用std::scoped_lock
std::scoped_lock 是 cpp17 引入的一种更现代的锁管理工具,用于简化多互斥量(std::mutex)的加锁操作,并且可以自动避免死锁。
单个锁:
1 | std::mutex mtx; |
多个锁(自动避免死锁)
1 | void f1() { |
std::scoped_lock 会自动避免死锁
死锁通常发生在多个线程同时试图获取多个互斥锁,且加锁顺序不一致的情况下:
1 | std::mutex m1, m2; |
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 |
|
读线程(共享锁):
读线程可以使用 std::shared_lock 来加共享锁:
1 | void reader(int id) { |
C++ 锁的实现:Top-Down 视角
总览
一把 std::mutex 从上到下,大致可以拆成:
1 | C++ 代码 |
一句话总结:
std::mutex是 C++ 标准库提供的锁接口;在 Linux 上,它通常封装pthread_mutex_t;pthread_mutex_t的具体实现通常由 glibc 提供;glibc 先用用户态 atomic 尝试加锁,无竞争时不进入内核;竞争时通过 futex 进入内核完成线程睡眠和唤醒;最底层依赖 CPU 原子指令、缓存一致性和内存屏障保证正确性。
第一层:std::mutex 是 C++ 标准库接口
std::mutex 保证的是:
1 | 同一时间只有一个线程能持有这把锁; |
但是 C++ 标准只规定行为,不规定底层实现。
也就是说,标准不要求它必须使用:
1 | pthread_mutex_t |
所以这一层只是 C++ 接口语义层。
第二层:std::mutex 通常封装 pthread_mutex_t
在 Linux 上,常见实现大致是:
1 | std::mutex |
概念上类似:
1 | class mutex { |
pthread_mutex_t 可以理解成:
POSIX 线程库定义的 C 风格互斥锁对象。
它不是 C++ 的东西,而是 POSIX Threads 里的同步对象。
在 Linux + glibc 环境下:
1 | pthread_mutex_t 的具体数据布局:glibc 定义; |
第三层:glibc 的用户态快路径
进入 pthread_mutex_lock() 后,并不是马上进入内核。
glibc 会先看 mutex 内部的状态字,可以粗略理解成:
1 | 0:未加锁 |
加锁时先尝试用户态原子操作:
1 | if (CAS(lock_word, 0, 1)) { |
所以无竞争时:
1 | 用户态 CAS 成功 |
有竞争时:
1 | CAS 失败 |
解锁也类似:
1 | 没有等待者: |
这一层的核心是:
能不进内核就不进内核。
第四层:futex wait / wake:用户态锁和内核睡眠/唤醒的接口
futex 的重点不是“实现锁”,而是提供两个能力:
1 | wait:让线程在某个用户态地址上睡眠 |
对 mutex 来说,最核心的是这一对操作:
1 | futex_wait(&lock_word, expected); |
futex_wait(addr, expected) 的语义是:
1 | 如果 *addr 仍然等于 expected: |
futex_wake(addr, n) 的语义是:
1 | 唤醒最多 n 个睡在 addr 对应等待队列上的线程 |
在 mutex 里的典型用法是:
1 | 加锁: |
所以这一层可以总结成:
atomic 负责修改锁状态;futex_wait 负责竞争时睡眠;futex_wake 负责解锁时唤醒。
第五层:操作系统如何响应 futex_wait / futex_wake
用户态调用:
1 | futex_wait(&lock_word, expected); |
进入内核后,操作系统大致做:
1 | 1. 根据用户态地址 &lock_word 生成 futex key; |
也就是:
1 | 用户态地址 |
用户态调用:
1 | futex_wake(&lock_word, 1); |
进入内核后,操作系统大致做:
1 | 1. 根据同一个用户态地址生成 futex key; |
也就是:
1 | futex 等待队列 |
这一层的核心是:
futex 把一个用户态地址变成了内核中的等待点;wait 让线程从运行状态进入等待队列,wake 让线程从等待队列回到调度器 runqueue。
第六层:CPU 需要提供哪些能力
再往下就是硬件层。
锁最终依赖 CPU 提供两个核心能力:
1 | 1. 原子修改共享变量; |
第一类能力是原子指令,例如:
1 | compare-and-swap |
在 C++ 中通常对应:
1 | std::atomic<int> |
这些原子操作保证 检查锁状态 + 修改锁状态 这个动作不能被其他线程打断。
第二类能力是内存顺序控制。
锁通常需要:
1 | lock:acquire 语义 |
含义是:
1 | acquire: |
否则临界区里的代码可能被 CPU 或编译器乱序到锁外面,锁的语义就会被破坏。
所以最底层可以总结成:
CPU 原子指令保证抢锁动作不可分割;内存屏障和 acquire/release 语义保证临界区读写不会乱序到锁外。
一句话总结
现代 mutex 的实现主干是:用户态 atomic 先尝试完成无竞争加锁;竞争时通过 futex_wait 进入内核睡眠;解锁时通过 futex_wake 唤醒等待线程;操作系统负责等待队列和调度;CPU 负责原子修改和内存顺序保证。