互斥锁、自旋锁、读写锁使用场景以及底层实现
在多线程程序中,锁的本质是协调多个线程对共享资源的访问,但不同锁的设计目标并不一样。互斥锁更关注"抢不到锁时不要浪费 CPU",自旋锁更关注"短时间等待时不要陷入内核调度",读写锁更关注"读多写少时让多个读线程并发执行"
从底层实现看,互斥锁通常是 CAS + futex 的组合,自旋锁通常是 atomic_flag/CAS + 忙等 的组合,读写锁通常是 状态变量 + mutex + 条件变量 的组合。下面从使用场景、底层结构、加锁解锁流程三个角度分别分析这三类锁
一、互斥锁 mutex:抢不到就睡眠,避免浪费 CPU
1. mutex 的使用场景
互斥锁适合保护普通临界区,尤其适合临界区执行时间不确定、锁竞争可能比较明显、线程不应该一直占用 CPU 忙等的场景
比如共享容器的插入删除、全局配置的修改、日志文件写入、连接表维护、任务队列的 push/pop 等,都可以使用互斥锁。互斥锁的特点是同一时刻只允许一个线程进入临界区,其他线程如果抢不到锁,最终会进入阻塞状态,由操作系统调度其他线程运行
典型场景如下:
cpp
std::mutex mtx;
std::unordered_map<int, std::string> users;
void update_user(int id, const std::string& name) {
std::lock_guard<std::mutex> lock(mtx);
users[id] = name;
}
这段代码中,多个线程可能同时修改 users,所以必须用锁保护。由于修改 map 可能涉及扩容、内存分配、哈希冲突处理等操作,临界区并不一定极短,因此使用 mutex 比 spinlock 更稳妥
2. pthread mutex 的核心结构
glibc 中的 pthread mutex 底层可以抽象为一个结构体,核心字段是 __lock,它表示当前锁状态。其他字段用于记录持锁线程、递归次数、锁类型、自旋次数、等待队列等辅助信息
cpp
struct __pthread_mutex_s {
int __lock;
unsigned int __count;
int __owner;
#ifdef __x86_64__
unsigned int __nusers;
#endif
int __kind;
#ifdef __x86_64__
short __spins;
short __elision;
__pthread_list_t __list;
#else
unsigned int __nusers;
__extension__ union {
struct {
short __espins;
short __eelision;
} __elision_data;
__pthread_slist_t __list;
};
#endif
};
可以把它简化理解为:
cpp
struct mutex {
int lock; // 0 表示未加锁,1 表示已加锁,2 表示已加锁且可能有等待者
int owner; // 当前持锁线程
int kind; // 锁类型
};
其中真正决定线程能否进入临界区的是 __lock。无竞争时,线程只需要在用户态通过原子操作把 __lock 从 0 改成 1,就可以完成加锁
3. mutex 加锁:先 CAS,失败后 futex_wait
pthread mutex 的加锁快路径大致如下:
cpp
#define __lll_lock(futex, private) \
((void) \
({ \
int *__futex = (futex); \
if (__glibc_unlikely \
(atomic_compare_and_exchange_bool_acq(__futex, 1, 0)))\
{ \
if (__builtin_constant_p(private) && (private) == LLL_PRIVATE)\
__lll_lock_wait_private(__futex); \
else \
__lll_lock_wait(__futex, private); \
} \
}))
关键代码是 atomic_compare_and_exchange_bool_acq(__futex, 1, 0),它的含义是尝试把 *__futex 从 0 改成 1。如果当前锁没有被持有,CAS 成功,线程直接获得锁,不需要进入内核
可以把加锁过程理解为:
cpp
lock(mutex):
if CAS(mutex.__lock, 0, 1) 成功:
直接获得锁
else:
进入 futex_wait,挂起当前线程
被唤醒后重新尝试抢锁
这也是 mutex 的一个重要优化点:无竞争情况下,mutex 并不是系统调用,而是一次用户态原子操作。只有竞争失败时,才会进入慢路径
4. futex 机制:用户态 CAS + 内核态等待队列
futex 全称是 Fast Userspace Mutex,它的核心思想是:无竞争时在用户态完成加锁和解锁,有竞争时才进入内核阻塞和唤醒线程
如果每次加锁都进入内核,系统调用和调度成本很高。如果抢不到锁后一直自旋,锁持有时间一长就会浪费 CPU。futex 正好折中处理这个问题:用户态负责快速判断锁状态,内核态只负责管理真正需要睡眠的线程
futex_wait 的语义可以理解为:
cpp
futex_wait(addr, expected):
如果 *addr 仍然等于 expected:
把当前线程挂到 addr 对应的等待队列并睡眠
否则:
直接返回,让线程重新判断锁状态
这里必须由内核完成"检查锁值 + 挂起线程"的原子操作,否则可能出现丢失唤醒。比如线程 B 判断锁还被线程 A 持有,正准备睡眠,但还没睡着时 A 已经释放锁并 wake 了,如果 B 随后才真正睡下去,就可能永远没人唤醒它
futex 在内核中不是给每个 mutex 创建一个独立对象,而是根据用户态地址做 key,hash 到某个 futex bucket。等待同一个 futex 地址的线程会挂到对应 bucket 的队列中,唤醒时再根据地址找到对应等待队列
可以简化为:
cpp
futex_data
├── hash bucket 0
├── hash bucket 1
│ ├── futex_q 1 -> task_struct
│ ├── futex_q 2 -> task_struct
│ └── futex_q 3 -> task_struct
└── hash bucket n
所以 mutex 平时只是用户态内存中的一个 int,只有发生竞争时,内核才会根据这个地址组织等待队列
5. mutex 解锁:release 语义清零,必要时 futex_wake
pthread mutex 的解锁慢路径大致如下:
cpp
#define __lll_unlock(futex, private) \
((void) \
({ \
int *__futex = (futex); \
int __private = (private); \
int __oldval = atomic_exchange_rel(__futex, 0); \
if (__glibc_unlikely(__oldval > 1)) \
{ \
if (__builtin_constant_p(private) && (private) == LLL_PRIVATE)\
__lll_lock_wake_private(__futex); \
else \
__lll_lock_wake(__futex, __private); \
} \
}))
关键代码是 atomic_exchange_rel(__futex, 0),它把锁状态设置为 0,表示释放锁。这里使用 release 语义,是为了保证临界区内的写操作不会被重排到解锁之后
如果旧值 __oldval > 1,说明可能有线程正在等待这个锁,于是调用 futex_wake 唤醒等待线程。被唤醒的线程不代表已经拿到锁,它只是从睡眠状态返回,之后还要重新 CAS 竞争锁
可以把解锁过程理解为:
cpp
unlock(mutex):
old = atomic_exchange_release(lock, 0)
if old > 1:
futex_wake(&lock, 1)
最终,mutex 的底层模型可以总结为:先用 CAS 走用户态快路径,失败后用 futex 进入内核睡眠;释放锁时先清零,如果发现有等待者,再用 futex_wake 唤醒
二、自旋锁 spinlock:抢不到就原地等待,减少上下文切换
1. spinlock 的使用场景
自旋锁适合临界区极短、锁持有时间非常短、线程阻塞和唤醒成本反而更高的场景
比如只保护一个简单计数器、修改一个小型状态位、更新一个短路径上的共享变量,或者在底层高性能组件中保护非常短的临界区,都可能使用自旋锁
典型场景如下:
cpp
spinlock lock;
int counter = 0;
void increase() {
lock.lock();
++counter;
lock.unlock();
}
这里临界区只有 ++counter,如果线程抢不到锁,持锁线程可能几十纳秒后就释放了。此时如果等待线程进入内核睡眠,再被唤醒,调度成本可能远大于直接自旋几次的成本
但自旋锁不适合临界区较长、锁中可能发生 IO、锁中可能阻塞、单核 CPU 上长时间等待等场景。因为自旋线程不会主动睡眠,会持续占用 CPU
2. atomic_flag 实现自旋锁
一个典型自旋锁可以基于 std::atomic_flag 实现。atomic_flag 是一个原子布尔标志,test_and_set 会读取旧值并把标志设置为 true,clear 会把标志重新置为 false
cpp
#include <array>
#include <thread>
#include <atomic>
#include <emmintrin.h>
struct spinlock {
void lock() noexcept {
constexpr std::array iterations = {5, 10, 3000};
for (int i = 0; i < iterations[0]; ++i) {
if (try_lock())
return;
}
for (int i = 0; i < iterations[1]; ++i) {
if (try_lock())
return;
_mm_pause();
}
while (true) {
for (int i = 0; i < iterations[2]; ++i) {
if (try_lock())
return;
_mm_pause();
_mm_pause();
_mm_pause();
_mm_pause();
_mm_pause();
_mm_pause();
_mm_pause();
_mm_pause();
_mm_pause();
_mm_pause();
}
std::this_thread::yield();
}
}
bool try_lock() noexcept {
return !flag.test_and_set(std::memory_order_acquire);
}
void unlock() noexcept {
flag.clear(std::memory_order_release);
}
private:
std::atomic_flag flag = ATOMIC_FLAG_INIT;
};
try_lock() 中的 flag.test_and_set(std::memory_order_acquire) 会把 flag 设置为 true,并返回旧值。如果旧值是 false,说明没有线程持锁,当前线程成功拿到锁;如果旧值是 true,说明锁已经被其他线程持有,当前线程加锁失败
所以 return !flag.test_and_set(...) 的含义是:旧值为 false 才表示抢锁成功
3. 自旋锁为什么要使用 acquire/release
自旋锁不能只关心 flag 的 true/false,还必须保证临界区内存访问的顺序正确。加锁时使用 memory_order_acquire,解锁时使用 memory_order_release
acquire 保证当前线程拿到锁之后,临界区内的读写不会被重排到加锁之前。release 保证当前线程释放锁之前,临界区内的写入不会被重排到解锁之后
例如:
cpp
spinlock.lock();
shared_data = 100;
spinlock.unlock();
另一个线程:
cpp
spinlock.lock();
std::cout << shared_data;
spinlock.unlock();
第一个线程解锁时使用 release,第二个线程加锁成功时使用 acquire,就能保证第二个线程看到第一个线程在临界区内写入的数据
4. 自旋锁为什么要使用 _mm_pause 和 yield
最简单的自旋锁可以写成 while (!try_lock()) {},但这种实现会疯狂执行原子指令,持续争抢同一个 cache line,给 CPU 流水线、缓存一致性协议和总线带来压力
_mm_pause() 对应 x86 的 pause 指令,它的作用是告诉 CPU 当前线程正在执行自旋等待,可以降低流水线压力,减少超线程场景下对同一个物理核心另一个逻辑线程的影响,并缓解忙等造成的资源浪费
上面的实现采用了分阶段策略:先快速尝试几次,不成功后带 pause 自旋,再等待更久后调用 std::this_thread::yield()。这样可以兼顾低延迟和 CPU 友好性
这个策略背后的思想是:如果锁马上释放,就不要进入内核;如果等得太久,就适当让出 CPU,避免空转过度
三、读写锁 shared_mutex:读读共享,写独占
1. 读写锁的使用场景
读写锁适合读多写少的共享资源。它允许多个读线程同时进入临界区,但写线程必须独占访问
它的语义是:
读读可以并发
读写不能并发
写写不能并发
典型场景包括配置缓存、路由表、用户信息表、本地缓存、文件元数据表、排行榜快照等。比如一个服务中大量请求只是读取配置,只有管理员偶尔更新配置,此时用读写锁可以让多个读请求并发执行
示例:
cpp
std::shared_mutex rwlock;
std::unordered_map<std::string, std::string> config;
std::string get_config(const std::string& key) {
std::shared_lock<std::shared_mutex> lock(rwlock);
return config[key];
}
void update_config(const std::string& key, const std::string& value) {
std::unique_lock<std::shared_mutex> lock(rwlock);
config[key] = value;
}
读取配置时使用共享锁,多个读线程可以并发;更新配置时使用独占锁,必须等待所有读线程退出,并阻止新的读线程进入
读写锁不适合写操作很多、临界区非常短、或者读线程长期持锁的场景。写操作很多时,读写锁频繁在读写状态之间切换,开销可能比普通 mutex 更大。读线程长期持锁时,写线程可能等待很久,甚至出现写饥饿
2. 读写锁的状态变量设计
一个经典读写锁实现可以用一个状态变量 _M_state 同时保存写标志和读者数量
高位表示是否有写者进入,低位表示当前读者数量:
cpp
static constexpr unsigned _S_write_entered = 1U << 31;
static constexpr unsigned _S_max_readers = ~_S_write_entered;
unsigned _M_state = 0;
bool _M_write_entered() const {
return _M_state & _S_write_entered;
}
unsigned _M_readers() const {
return _M_state & _S_max_readers;
}
可以理解为:
_M_state = 0 表示没有写者,也没有读者
_M_state = 3 表示没有写者,有 3 个读者正在读
_M_state = _S_write_entered | 2 表示有写者已经进入等待阶段,同时还有 2 个旧读者没有退出
这个设计的关键是:写者一旦设置写标志,后续读者就不能继续进入,只能等待。这样可以防止读者源源不断进入导致写者长期抢不到锁
3. 为什么读写锁需要一个 mutex 和两个条件变量
这个实现中有三个同步组件:
_M_mut:内部互斥锁,用来保护 _M_state 的访问
_M_gate1:第一道条件变量,用于等待"没有写者进入"或者"读者数量未达到上限"
_M_gate2:第二道条件变量,用于写者等待已有读者全部退出
可以这样理解:_M_mut 不是业务层面的读写锁,而是保护读写锁内部状态的锁。_M_gate1 是入口门,读者和写者都可能在这里等待。_M_gate2 是写者专用的等待点,写者设置写标志后,还要等当前读者数量变成 0
4. 写锁 lock 和 unlock 的实现
写锁加锁逻辑如下:
cpp
void lock() {
std::unique_lock<std::mutex> lk(_M_mut);
_M_gate1.wait(lk, [this] {
return !_M_write_entered();
});
_M_state |= _S_write_entered;
_M_gate2.wait(lk, [this] {
return _M_readers() == 0;
});
}
void unlock() {
std::lock_guard<std::mutex> lk(_M_mut);
_M_state = 0;
_M_gate1.notify_all();
}
写锁加锁分成两步。第一步是在 _M_gate1 等待没有其他写者进入,保证同一时刻只有一个写者可以进入写等待阶段。第二步是设置 _S_write_entered,阻止新的读者进入,然后在 _M_gate2 等待已有读者全部退出
注意,设置写标志并不代表马上可以写,而是先声明"我要写了,后来的读者先别进来"。只有 _M_readers() == 0 时,写者才真正获得写锁
写锁释放时,直接把 _M_state 清零,并通过 _M_gate1.notify_all() 唤醒等待在入口处的读者和写者。被唤醒的线程还要重新竞争 _M_mut,并重新判断条件是否满足
5. 读锁 lock_shared 和 unlock_shared 的实现
读锁加锁和释放逻辑如下:
cpp
void lock_shared() {
std::unique_lock<std::mutex> lk(_M_mut);
_M_gate1.wait(lk, [this] {
return _M_state < _S_max_readers;
});
++_M_state;
}
void unlock_shared() {
std::lock_guard<std::mutex> lk(_M_mut);
auto prev = _M_state--;
if (_M_write_entered()) {
if (_M_readers() == 0) {
_M_gate2.notify_one();
}
} else {
if (prev == _S_max_readers) {
_M_gate1.notify_one();
}
}
}
读者进入时要检查 _M_state < _S_max_readers。这个判断同时表达两个条件:没有写者设置写标志,读者数量也没有达到上限。满足条件后执行 ++_M_state,表示读者数量加 1
读者释放锁时执行 _M_state--,表示读者数量减 1。随后分两种情况处理
如果有写者正在等待,即 _M_write_entered() 为 true,那么最后一个读者退出时需要调用 _M_gate2.notify_one() 唤醒写者。因为写者正在第二道门等待 _M_readers() == 0
如果没有写者等待,那么只有在读者数量之前达到上限时,才需要通过 _M_gate1.notify_one() 唤醒一个因为读者数量上限而阻塞的读者
可以把读写锁整体流程总结为:
cpp
写线程 lock():
1. 等待没有其他写者
2. 设置写标志,阻止新读者进入
3. 等待已有读者全部退出
4. 进入写临界区
写线程 unlock():
1. 清空 state
2. 唤醒 gate1 上等待的读者和写者
读线程 lock_shared():
1. 等待没有写者进入
2. 等待读者数量没有达到上限
3. 读者数量加 1
4. 进入读临界区
读线程 unlock_shared():
1. 读者数量减 1
2. 如果有写者等待且自己是最后一个读者,唤醒写者
3. 如果没有写者等待但读者上限解除,唤醒一个读者
四、三种锁的对比和选择
1. 等待方式不同
mutex 抢不到锁时,会先走用户态 CAS,失败后可能通过 futex 进入内核睡眠。spinlock 抢不到锁时,不睡眠,而是在用户态不断尝试。读写锁则根据读写语义决定是否等待,读者可以共享进入,写者必须独占进入
| 锁类型 | 等待方式 | 是否进入内核 | CPU 消耗 | 适合场景 |
|---|---|---|---|---|
| mutex | CAS 失败后 futex 睡眠 | 有竞争时可能进入 | 等待时不占 CPU | 普通临界区,等待时间不确定 |
| spinlock | 原子操作失败后循环重试 | 通常不进入 | 等待时持续占 CPU | 极短临界区,追求低延迟 |
| read-write lock | 读者共享,写者独占 | 取决于实现 | 取决于竞争情况 | 读多写少的共享资源 |
2. 底层实现不同
mutex 的核心是一个锁状态字段,配合原子 CAS 和 futex。无竞争时,线程直接在用户态 CAS 成功;有竞争时,线程通过 futex_wait 睡眠;解锁时,如果发现有等待者,就通过 futex_wake 唤醒
spinlock 的核心是一个原子标志位,配合 test_and_set 或 CAS。抢锁失败后线程不睡眠,而是继续执行循环,通常会加入 _mm_pause() 降低忙等成本,必要时使用 yield() 让出 CPU
读写锁的核心是一个状态变量,配合内部 mutex 和条件变量。状态变量同时保存写标志和读者数量,读者根据写标志判断能否进入,写者先设置写标志阻止新读者进入,再等待已有读者全部退出
3. 选择建议
如果临界区执行时间不确定,或者锁内可能有复杂逻辑,优先使用 mutex。mutex 在竞争时可以让线程睡眠,避免浪费 CPU,是最通用的锁
如果临界区极短,并且可以确定持锁线程很快释放锁,可以考虑 spinlock。spinlock 避免了系统调用和上下文切换,但等待期间会消耗 CPU,所以不能用于长临界区
如果共享资源读多写少,并且允许多个读线程并发访问,可以考虑读写锁。读写锁能提升读并发能力,但写线程需要等所有读线程退出,因此不适合写多或读线程长期持锁的场景
可以记成三句话:
mutex = 抢不到就睡眠,适合通用互斥
spinlock = 抢不到就忙等,适合极短临界区
rwlock = 读读共享、写独占,适合读多写少