一、临界区
锁保护的是临界区 ------一段不能被并发执行的的代码,此时共享的资源就是临界资源。比如多个线程同时修改一个共享变量,线程通过数据的共享完成交互,可不加锁就会数据竞争。
接下来,我将介绍三种常见的锁:
- 互斥锁
- 读写锁
- 自旋锁
二、互斥锁(mutex)
语义
同一时刻只有一个线程 能持有锁。其他线程调用 lock() 时会让出CPU,挂到等待队列中。当锁被释放,操作系统唤醒一个等待线程。
核心特点
| 特性 | 说明 |
|---|---|
| 加锁失败时 | 线程睡眠,进入等待队列 |
| 释放锁时 | 唤醒等待队列中的线程 |
| 上下文开销 | 有------睡眠和唤醒涉及内核态切换 |
| 适用场景 | 通用,临界区长度不确定时 |
代码示例
cpp
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx;
int shared_counter = 0;
void increment(int n) {
for (int i = 0; i < n; ++i) {
std::lock_guard<std::mutex> lock(mtx); // 构造时加锁,析构时解锁
++shared_counter;
}
}
int main() {
std::thread t1(increment, 100000);
std::thread t2(increment, 100000);
t1.join();
t2.join();
std::cout << "counter = " << shared_counter << std::endl; // 200000
return 0;
}
分析
如果不加锁,极大概率会出现下面的情况,导致最后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 循环中反复检查锁状态,直到获取锁。
cpp
// 自旋锁的伪代码
while (!try_lock()) {
// 空转,忙等
}
// 拿到锁了,执行临界区
核心特点
| 特性 | 说明 |
|---|---|
| 加锁失败时 | 忙等待(while循环检查),不睡眠 |
| 上下文开销 | 无------不涉及内核态切换 |
| CPU 浪费 | 有------空转时占用 100% CPU |
| 适用场景 | 临界区极短(几十条指令),且多核 CPU |
| 单核上注意 | 单核自旋没意义(被自旋的线程没CPU执行释放锁),除非关中断 |
代码示例
cpp
#include <atomic>
#include <thread>
// 用 std::atomic_flag 实现简易自旋锁
class SpinLock {
public:
void lock() {
while (flag.test_and_set(std::memory_order_acquire)) {
// 忙等待,不断尝试
// 生产环境可加 _mm_pause() 指令降低功耗
}
}
void unlock() {
flag.clear(std::memory_order_release);
}
private:
std::atomic_flag flag = ATOMIC_FLAG_INIT;
};
// 使用方式
SpinLock spinlock;
int counter = 0;
void increment(int n) {
for (int i = 0; i < n; ++i) {
spinlock.lock();
++counter;
spinlock.unlock();
}
}
Linux 中的自旋锁
c
#include <pthread.h>
pthread_spinlock_t spinlock;
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 实现互斥锁的简化逻辑如下:
cpp
// 假设 lock_var 是原子变量,0 表示空闲,1 表示已占用
std::atomic<int> lock_var{0};
void lock() {
int expected = 0;
// 如果 lock_var 当前是 0(预期值),就原子地把它设为 1(新值)并返回 true
// 如果 lock_var 不是 0,说明已被占用,更新 expected 为当前值并返回 false
while (!lock_var.compare_exchange_strong(expected, 0)) {
// 竞争失败的处理:纯用户态自旋,或让出CPU,或进入内核休眠
// 这里就是 futex 要优化的地方
}
}
void unlock() {
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)------重点
语义
读写锁有两种锁模式:
| 模式 | 规则 |
|---|---|
| 读锁(共享锁) | 多个线程可以同时持有读锁 |
| 写锁(排他锁) | 独占,互斥所有其他锁(包括其他读锁和写锁) |
关键澄清(这是面试最容易答错的点):
读操作必须加读锁 ,不是"不加锁"。
只是多个读锁之间不互斥,可以共存。
但读锁和写锁之间是互斥的------有人读的时候不能写,有人写的时候不能读。
读写状态转移图

注意:
- 有读锁时不能加写锁(除非所有读锁都释放)
- 有写锁时不能加读锁也不能加写锁
核心特点
| 特性 | 说明 |
|---|---|
| 加读锁失败时 | 睡眠等待(有写锁在持) |
| 加写锁失败时 | 睡眠等待(有读锁或写锁在持) |
| 上下文开销 | 和互斥锁一样会睡眠 |
| 适用场景 | 读多写少(如配置表、缓存) |
| 写饥饿问题 | 默认策略下,如果读操作持续不断,写操作可能长期拿不到锁 |
代码示例
cpp
#include <iostream>
#include <thread>
#include <shared_mutex> // C++17 读写锁
std::shared_mutex rwlock;
int config_value = 42;
// 读线程:加读锁
void reader(int id) {
std::shared_lock<std::shared_mutex> lock(rwlock); // 读锁(共享)
std::cout << "reader " << id << ": config = " << config_value << std::endl;
}
// 写线程:加写锁
void writer(int new_val) {
std::unique_lock<std::shared_mutex> lock(rwlock); // 写锁(排他)
config_value = new_val;
std::cout << "writer: config updated to " << config_value << std::endl;
}
int main() {
std::thread r1(reader, 1);
std::thread r2(reader, 2); // 两个读线程可以同时读
std::thread w1(writer, 100);
std::thread r3(reader, 3);
r1.join(); r2.join(); w1.join(); r3.join();
return 0;
}
注意:
std::shared_lock→ 读锁(共享)std::unique_lock→ 写锁(排他)- 多个 reader 可以同时持有 shared_lock,但 writer 持有 unique_lock 时其他人都不能加锁
Linux 中的读写锁
c
#include <pthread.h>
pthread_rwlock_t rwlock = 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 |
七、选锁决策树
临界区能睡眠吗?
├─ 不能(中断上下文) → 自旋锁
└─ 能
├─ 临界区极短(<几十条指令)? → 自旋锁
└─ 临界区较长/不确定
├─ 读多写少? → 读写锁
└─ 读写相当/只写 → 互斥锁
创作充满挑战,但若我的文章能为你带来一丝启发或帮助,那便是我最大的荣幸。如果你喜欢这篇文章,请不吝点赞、评论和分享,你的支持是我继续创作的最大动力!
