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

一、临界区

锁保护的是临界区 ------一段不能被并发执行的的代码,此时共享的资源就是临界资源。比如多个线程同时修改一个共享变量,线程通过数据的共享完成交互,可不加锁就会数据竞争。

接下来,我将介绍三种常见的锁:

  • 互斥锁
  • 读写锁
  • 自旋锁

二、互斥锁(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);

自旋锁的特殊使用场景

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

  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 实现互斥锁的简化逻辑如下:

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 失败,说明锁已被占用。此时线程进入内核态。
    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)------重点

语义

读写锁有两种锁模式

模式 规则
读锁(共享锁) 多个线程可以同时持有读锁
写锁(排他锁) 独占,互斥所有其他锁(包括其他读锁和写锁)

关键澄清(这是面试最容易答错的点):

读操作必须加读锁 ,不是"不加锁"。

只是多个读锁之间不互斥,可以共存。

但读锁和写锁之间是互斥的------有人读的时候不能写,有人写的时候不能读。

读写状态转移图

注意:

  • 有读锁时不能加写锁(除非所有读锁都释放)
  • 有写锁时不能加读锁也不能加写锁

核心特点

特性 说明
加读锁失败时 睡眠等待(有写锁在持)
加写锁失败时 睡眠等待(有读锁或写锁在持)
上下文开销 和互斥锁一样会睡眠
适用场景 读多写少(如配置表、缓存)
写饥饿问题 默认策略下,如果读操作持续不断,写操作可能长期拿不到锁

代码示例

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


七、选锁决策树

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

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

相关推荐
初级代码游戏1 小时前
iOS开发 Swift 速记7:结构体和类
开发语言·ios·swift
golang学习记2 小时前
Go 项目使用docker compose的正确方式
开发语言·docker·golang
Dr.kangder3 小时前
嵌入式总线设备解析——TTE总线应用与实践
开发语言·网络·算法·嵌入式·多任务·同步机制
-银雾鸢尾-3 小时前
C#中的多线程
开发语言·c#
yyy(十一月限定版)3 小时前
016【入门】双端队列
数据结构·c++
2401_858286114 小时前
OS82.【Linux】设计线程池
java·linux·运维·服务器·开发语言·算法·线程池
数据知道4 小时前
IDOR 不安全的直接对象引用:API 越权实战检测
java·开发语言·网络·安全·网络安全
c238564 小时前
# Mini OJ — 项目详解文档
开发语言·数据库·c++