多线程锁详解:互斥锁·自旋锁·读写锁(CAS + futex 原理)
一、临界区
锁保护的是临界区——一段不能被并发执行的的代码,此时共享的资源就是临界资源。比如多个线程同时修改一个共享变量,线程通过数据的共享完成交互,可不加锁就会数据竞争。
接下来,我将介绍三种常见的锁:
- 互斥锁
- 读写锁
- 自旋锁
二、互斥锁(mutex)
语义
同一时刻只有一个线程能持有锁。其他线程调用 lock() 时会让出CPU,挂到等待队列中。当锁被释放,操作系统唤醒一个等待线程。
核心特点
| 特性 | 说明 |
|---|---|
| 加锁失败时 | 线程睡眠,进入等待队列 |
| 释放锁时 | 唤醒等待队列中的线程 |
| 上下文开销 | 有——睡眠和唤醒涉及内核态切换 |
| 适用场景 | 通用,临界区长度不确定时 |
代码示例
#include<iostream>#include<thread>#include<mutex>std::mutex mtx;intshared_counter=0;voidincrement(intn){for(inti=0;i<n;++i){std::lock_guard<std::mutex>lock(mtx);// 构造时加锁,析构时解锁++shared_counter;}}intmain(){std::threadt1(increment,100000);std::threadt2(increment,100000);t1.join();t2.join();std::cout<<"counter = "<<shared_counter<<std::endl;// 200000return0;}分析
如果不加锁,极大概率会出现下面的情况,导致最后counter数值不是200000。
假设初始shared_counter = 0,线程 A 刚读到 0,还没来得及加就被切换走了,等它恢复时世界已经变了。
| 步骤 | 正在执行的线程 | 发生的事件 | 线程 A 的寄存器状态 (eax) | 线程 B 的寄存器状态 (ebx) | 内存 shared_counter | 上下文切换说明 |
|---|---|---|---|---|---|---|
| 1 | A | 从内存读取 shared_counter 的值到 eax | eax = 0 | — | 0 | A 在运行,CPU 执行"mov eax, [shared_counter]" |
| 2 | (内核态) | A 的时间片用完,硬件触发时钟中断,CPU 从用户态陷入内核,保存 A 的上下文 | eax=0 被保存到 A 的内核栈 / TCB | — | 0 | 内核将 A 的所有通用寄存器(包括 eax=0)、程序计数器等存入 A 的线程控制块,准备切换 |
| 3 | B | 内核调度器选择线程 B 运行,从 B 的 TCB 中恢复 B 的寄存器上下文 | (A 的上下文仍保存着 eax=0) | ebx = 未知,后变为 0 | 0 | B 被调度上 CPU,它的 eip 指向上次停下的位置,现在开始执行读取 |
| 4 | B | 读取 shared_counter → ebx | (A 仍保存 eax=0) | ebx = 0 | 0 | B 此时从内存读到的也是 0,因为 A 还没写回 |
| 5 | B | 计算 ebx + 1 → ebx (1) | (A 仍保存 eax=0) | ebx = 1 | 0 | B 在它的寄存器里完成了加 1 |
| 6 | B | 将 ebx 的值写回内存 shared_counter | (A 仍保存 eax=0) | ebx = 1 | 1 | B 成功把 1 写回内存 |
| 7 | (内核态) | B 的时间片也到了,或主动让出,CPU 陷入内核,保存 B 的上下文 | (A 仍保存 eax=0) | ebx=1 被保存到 B 的 TCB | 1 | B 的所有寄存器被保存,此时 B 的"现场"是 ebx=1 |
| 8 | A | 内核再次调度 A,从 A 的 TCB 恢复 A 的寄存器(eax 重新变成 0) | eax = 0 (刚被恢复) | (B 的 ebx=1 已保存) | 1 | 关键点:A 被恢复时,eax 还是它之前读到的 0,它完全不知道内存现在已经是 1 了 |
| 9 | A | 计算 eax + 1 → eax (1) | eax = 1 | (B 的上下文保存) | 1 | A 基于过时的值算出了 1 |
| 10 | A | 将 eax 的值写回内存 shared_counter | eax = 1 | (B 的上下文保存) | 1 | A 把 1 再次写回,覆盖了 B 之前写入的 1,B 的更新就这样“丢失”了 |
三、自旋锁(spinlock)
语义
加锁失败时线程不睡眠,而是在一个 while 循环中反复检查锁状态,直到获取锁。
// 自旋锁的伪代码while(!try_lock()){// 空转,忙等}// 拿到锁了,执行临界区核心特点
| 特性 | 说明 |
|---|---|
| 加锁失败时 | 忙等待(while循环检查),不睡眠 |
| 上下文开销 | 无——不涉及内核态切换 |
| CPU 浪费 | 有——空转时占用 100% CPU |
| 适用场景 | 临界区极短(几十条指令),且多核 CPU |
| 单核上注意 | 单核自旋没意义(被自旋的线程没CPU执行释放锁),除非关中断 |
代码示例
#include<atomic>#include<thread>// 用 std::atomic_flag 实现简易自旋锁classSpinLock{public:voidlock(){while(flag.test_and_set(std::memory_order_acquire)){// 忙等待,不断尝试// 生产环境可加 _mm_pause() 指令降低功耗}}voidunlock(){flag.clear(std::memory_order_release);}private:std::atomic_flag flag=ATOMIC_FLAG_INIT;};// 使用方式SpinLock spinlock;intcounter=0;voidincrement(intn){for(inti=0;i<n;++i){spinlock.lock();++counter;spinlock.unlock();}}Linux 中的自旋锁
#include<pthread.h>pthread_spinlock_tspinlock;pthread_spin_init(&spinlock,0);pthread_spin_lock(&spinlock);// 临界区pthread_spin_unlock(&spinlock);pthread_spin_destroy(&spinlock);自旋锁的特殊使用场景
自旋锁在内核开发中更常见,因为:
- 中断上下文中不能睡眠(没有进程上下文),只能用自旋锁
- 内核临界区通常极短,自旋比睡眠更高效
在用户态开发中,自旋锁用得少,除非你明确知道临界区只有几条指令。
面试高频追问
Q:自旋锁和互斥锁的区别?
互斥锁获取失败时线程睡眠(让出CPU),自旋锁获取失败时忙等待(不让出CPU)。互斥锁有上下文切换开销,自旋锁没有但浪费CPU。自旋锁适合临界区极短的场景,互斥锁适合临界区较长或不确定的场景。
Q:什么时候用自旋锁不用互斥锁?
①临界区极短(几十条指令),睡眠-唤醒的上下文开销比临界区本身还大;②不能睡眠的场景(如内核中断上下文);③多核CPU(单核上自旋没意义)。
Q:自旋锁为什么会浪费CPU?
线程在 while 循环中不断检查锁状态,CPU 一直被占用。如果持锁线程被调度走或临界区很长,自旋线程会长时间空转。所以自旋锁只适合极短临界区。
四、底层原理
mutex的实现是硬件原子指令和操作系统内核机制的结合。它需要解决两个核心问题:
- 如何原子地"检查并上锁",防止步骤 3 和步骤 4 之间被中断?
- 加锁失败时,如何让线程安全休眠并被唤醒,而不是空转浪费 CPU?
1. 硬件基石:CAS 原子指令
问题的根源在于,shared_counter的"读取-修改-写回"不是原子的。同样,锁变量本身的"检查-修改"也不是原子的。现代 CPU 提供了CAS(Compare And Swap,比较并交换)指令来解决这个问题。
CAS 指令接收三个参数:内存地址、预期值、新值。它的语义由硬件保证原子执行:
如果内存地址的当前值与预期值相等,则将该内存值更新为新值;否则不更新。无论成功与否,都返回该内存地址的旧值。
在 x86 架构下,对应cmpxchg指令。在 C++ 中,可以通过std::atomic的compare_exchange_strong来使用。
用 CAS 实现互斥锁的简化逻辑如下:
// 假设 lock_var 是原子变量,0 表示空闲,1 表示已占用std::atomic<int>lock_var{0};voidlock(){intexpected=0;// 如果 lock_var 当前是 0(预期值),就原子地把它设为 1(新值)并返回 true// 如果 lock_var 不是 0,说明已被占用,更新 expected 为当前值并返回 falsewhile(!lock_var.compare_exchange_strong(expected,0)){// 竞争失败的处理:纯用户态自旋,或让出CPU,或进入内核休眠// 这里就是 futex 要优化的地方}}voidunlock(){lock_var.store(0);// 原子地释放锁}这完美解决了第一个问题,保证了“检查锁状态”和“占用锁”这两个动作的原子性。
2. 内核协作:futex 机制
单纯依靠 CAS 忙等会浪费 CPU,尤其在锁竞争激烈时。因此,需要操作系统内核介入,提供休眠和唤醒队列的功能。Linux 上,现代互斥锁通过 futex(Fast Userspace Mutex,快速用户态互斥锁)系统调用来实现用户态和内核态的协作。
futex 的核心思想是:无竞争时,加解锁完全在用户态用原子指令完成,零内核开销;仅在发生竞争时,才陷入内核进行休眠或唤醒。
futex 机制在内核中维护一个等待队列,并关联一个用户态的 int 变量(称为 futex 字),其值含义如下:
| 值 | 含义 |
|---|---|
| 0 | 锁空闲,无人等待 |
| 1 | 锁被占用,但无人排队 |
| 2 | 锁被占用,且有人在等待队列中 |
Lock(加锁)
- 快速路径:线程执行原子 CAS,尝试将 futex 字从 0 改为 1。若成功,说明无竞争,直接进入临界区,全程无系统调用。
- 慢速路径:若 CAS 失败,说明锁已被占用。此时线程进入内核态。
- 先将 futex 字从 1 原子地设为 2,告诉持有者"有人在排队"。
- 然后调用
syscall(SYS_futex, &futex_word, FUTEX_WAIT, 2, ...)系统调用。 - 内核检查 futex 字确实是 2(防止在系统调用间隙锁被释放),然后将线程挂起,加入该 futex 字的等待队列。
Unlock(解锁)
- 执行
atomic_fetch_sub(&futex_word, 1)对 futex 字减 1。 - 若原值是 1,减后为 0,说明无人排队,无需唤醒,直接返回,全程无系统调用。
- 若原值是 2,说明有人排队。内核将 futex 字设为 0,并调用
syscall(SYS_futex, &futex_word, FUTEX_WAKE, 1, ...)唤醒等待队列中的一个线程。
3. 总结:两种锁的逻辑对比
futex 的本质是让内核作为锁竞争的"最终裁判"。
| 锁类型 | 实现机制 | 有竞争时行为 | 适用场景 |
|---|---|---|---|
| 自旋锁 | 纯用户态,仅用 CAS 忙等 | CPU 空转,不陷入内核 | 临界区极短(纳秒级) |
| 互斥锁(mutex) | 用户态 CAS + 内核 futex | 线程休眠,CPU 切换走 | 临界区较长或不可控 |
正是 futex 这种精巧的设计,使得互斥锁在无竞争时拥有接近自旋锁的性能,而在激烈竞争时又能让出 CPU,保证系统的整体吞吐率。
五、读写锁(rwlock)——重点
语义
读写锁有两种锁模式:
| 模式 | 规则 |
|---|---|
| 读锁(共享锁) | 多个线程可以同时持有读锁 |
| 写锁(排他锁) | 独占,互斥所有其他锁(包括其他读锁和写锁) |
关键澄清(这是面试最容易答错的点):
读操作必须加读锁,不是"不加锁"。
只是多个读锁之间不互斥,可以共存。
但读锁和写锁之间是互斥的——有人读的时候不能写,有人写的时候不能读。
读写状态转移图
注意:
- 有读锁时不能加写锁(除非所有读锁都释放)
- 有写锁时不能加读锁也不能加写锁
核心特点
| 特性 | 说明 |
|---|---|
| 加读锁失败时 | 睡眠等待(有写锁在持) |
| 加写锁失败时 | 睡眠等待(有读锁或写锁在持) |
| 上下文开销 | 和互斥锁一样会睡眠 |
| 适用场景 | 读多写少(如配置表、缓存) |
| 写饥饿问题 | 默认策略下,如果读操作持续不断,写操作可能长期拿不到锁 |
代码示例
#include<iostream>#include<thread>#include<shared_mutex>// C++17 读写锁std::shared_mutex rwlock;intconfig_value=42;// 读线程:加读锁voidreader(intid){std::shared_lock<std::shared_mutex>lock(rwlock);// 读锁(共享)std::cout<<"reader "<<id<<": config = "<<config_value<<std::endl;}// 写线程:加写锁voidwriter(intnew_val){std::unique_lock<std::shared_mutex>lock(rwlock);// 写锁(排他)config_value=new_val;std::cout<<"writer: config updated to "<<config_value<<std::endl;}intmain(){std::threadr1(reader,1);std::threadr2(reader,2);// 两个读线程可以同时读std::threadw1(writer,100);std::threadr3(reader,3);r1.join();r2.join();w1.join();r3.join();return0;}注意:
std::shared_lock→ 读锁(共享)std::unique_lock→ 写锁(排他)- 多个 reader 可以同时持有 shared_lock,但 writer 持有 unique_lock 时其他人都不能加锁
Linux 中的读写锁
#include<pthread.h>pthread_rwlock_trwlock=PTHREAD_RWLOCK_INITIALIZER;// 读锁pthread_rwlock_rdlock(&rwlock);// 加读锁pthread_rwlock_unlock(&rwlock);// 解锁// 写锁pthread_rwlock_wrlock(&rwlock);// 加写锁pthread_rwlock_unlock(&rwlock);// 解锁六、三种锁对比总表
| 互斥锁 | 读写锁 | 自旋锁 | |
|---|---|---|---|
| 加锁失败时 | 睡眠等待 | 睡眠等待 | 忙等待 |
| 读操作 | 互斥 | 共享(多个读锁共存) | 互斥 |
| 写操作 | 互斥 | 排他 | 互斥 |
| 上下文切换 | 有 | 有 | 无 |
| CPU 浪费 | 无(睡眠时不占CPU) | 无 | 有(空转) |
| 适用场景 | 通用 | 读多写少 | 临界区极短/不能睡眠 |
| C++ 标准库 | std::mutex | std::shared_mutex(C++17) | std::atomic_flag手动实现 |
| Linux | pthread_mutex_t | pthread_rwlock_t | pthread_spinlock_t |
七、选锁决策树
临界区能睡眠吗? ├─ 不能(中断上下文) → 自旋锁 └─ 能 ├─ 临界区极短(<几十条指令)? → 自旋锁 └─ 临界区较长/不确定 ├─ 读多写少? → 读写锁 └─ 读写相当/只写 → 互斥锁创作充满挑战,但若我的文章能为你带来一丝启发或帮助,那便是我最大的荣幸。如果你喜欢这篇文章,请不吝点赞、评论和分享,你的支持是我继续创作的最大动力!