多线程锁详解:互斥锁·自旋锁·读写锁(CAS + futex 原理)

多线程锁详解:互斥锁·自旋锁·读写锁(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上下文切换说明
1A从内存读取 shared_counter 的值到 eaxeax = 00A 在运行,CPU 执行"mov eax, [shared_counter]"
2(内核态)A 的时间片用完,硬件触发时钟中断,CPU 从用户态陷入内核,保存 A 的上下文eax=0 被保存到 A 的内核栈 / TCB0内核将 A 的所有通用寄存器(包括 eax=0)、程序计数器等存入 A 的线程控制块,准备切换
3B内核调度器选择线程 B 运行,从 B 的 TCB 中恢复 B 的寄存器上下文(A 的上下文仍保存着 eax=0)ebx = 未知,后变为 00B 被调度上 CPU,它的 eip 指向上次停下的位置,现在开始执行读取
4B读取 shared_counter → ebx(A 仍保存 eax=0)ebx = 00B 此时从内存读到的也是 0,因为 A 还没写回
5B计算 ebx + 1 → ebx (1)(A 仍保存 eax=0)ebx = 10B 在它的寄存器里完成了加 1
6B将 ebx 的值写回内存 shared_counter(A 仍保存 eax=0)ebx = 11B 成功把 1 写回内存
7(内核态)B 的时间片也到了,或主动让出,CPU 陷入内核,保存 B 的上下文(A 仍保存 eax=0)ebx=1 被保存到 B 的 TCB1B 的所有寄存器被保存,此时 B 的"现场"是 ebx=1
8A内核再次调度 A,从 A 的 TCB 恢复 A 的寄存器(eax 重新变成 0)eax = 0 (刚被恢复)(B 的 ebx=1 已保存)1关键点:A 被恢复时,eax 还是它之前读到的 0,它完全不知道内存现在已经是 1 了
9A计算 eax + 1 → eax (1)eax = 1(B 的上下文保存)1A 基于过时的值算出了 1
10A将 eax 的值写回内存 shared_countereax = 1(B 的上下文保存)1A 把 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);

自旋锁的特殊使用场景

自旋锁在内核开发中更常见,因为:

  1. 中断上下文中不能睡眠(没有进程上下文),只能用自旋锁
  2. 内核临界区通常极短,自旋比睡眠更高效

用户态开发中,自旋锁用得少,除非你明确知道临界区只有几条指令。

面试高频追问

Q:自旋锁和互斥锁的区别?

互斥锁获取失败时线程睡眠(让出CPU),自旋锁获取失败时忙等待(不让出CPU)。互斥锁有上下文切换开销,自旋锁没有但浪费CPU。自旋锁适合临界区极短的场景,互斥锁适合临界区较长或不确定的场景。

Q:什么时候用自旋锁不用互斥锁?

①临界区极短(几十条指令),睡眠-唤醒的上下文开销比临界区本身还大;②不能睡眠的场景(如内核中断上下文);③多核CPU(单核上自旋没意义)。

Q:自旋锁为什么会浪费CPU?

线程在 while 循环中不断检查锁状态,CPU 一直被占用。如果持锁线程被调度走或临界区很长,自旋线程会长时间空转。所以自旋锁只适合极短临界区。


四、底层原理

mutex的实现是硬件原子指令和操作系统内核机制的结合。它需要解决两个核心问题:

  1. 如何原子地"检查并上锁",防止步骤 3 和步骤 4 之间被中断?
  2. 加锁失败时,如何让线程安全休眠并被唤醒,而不是空转浪费 CPU?

1. 硬件基石:CAS 原子指令

问题的根源在于,shared_counter的"读取-修改-写回"不是原子的。同样,锁变量本身的"检查-修改"也不是原子的。现代 CPU 提供了CAS(Compare And Swap,比较并交换)指令来解决这个问题。

CAS 指令接收三个参数:内存地址、预期值、新值。它的语义由硬件保证原子执行:

如果内存地址的当前值与预期值相等,则将该内存值更新为新值;否则不更新。无论成功与否,都返回该内存地址的旧值。

在 x86 架构下,对应cmpxchg指令。在 C++ 中,可以通过std::atomiccompare_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 失败,说明锁已被占用。此时线程进入内核态。
    1. 先将 futex 字从 1 原子地设为 2,告诉持有者"有人在排队"。
    2. 然后调用syscall(SYS_futex, &futex_word, FUTEX_WAIT, 2, ...)系统调用。
    3. 内核检查 futex 字确实是 2(防止在系统调用间隙锁被释放),然后将线程挂起,加入该 futex 字的等待队列。

Unlock(解锁)

  1. 执行atomic_fetch_sub(&futex_word, 1)对 futex 字减 1。
  2. 若原值是 1,减后为 0,说明无人排队,无需唤醒,直接返回,全程无系统调用。
  3. 若原值是 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::mutexstd::shared_mutex(C++17)std::atomic_flag手动实现
Linuxpthread_mutex_tpthread_rwlock_tpthread_spinlock_t


七、选锁决策树

临界区能睡眠吗? ├─ 不能(中断上下文) → 自旋锁 └─ 能 ├─ 临界区极短(<几十条指令)? → 自旋锁 └─ 临界区较长/不确定 ├─ 读多写少? → 读写锁 └─ 读写相当/只写 → 互斥锁

创作充满挑战,但若我的文章能为你带来一丝启发或帮助,那便是我最大的荣幸。如果你喜欢这篇文章,请不吝点赞、评论和分享,你的支持是我继续创作的最大动力!