互斥锁、自旋锁、读写锁使用场景以及底层实现

互斥锁、自旋锁、读写锁使用场景以及底层实现

在多线程程序中,锁的本质是协调多个线程对共享资源的访问,但不同锁的设计目标并不一样。互斥锁更关注"抢不到锁时不要浪费 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。无竞争时,线程只需要在用户态通过原子操作把 __lock0 改成 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),它的含义是尝试把 *__futex0 改成 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 = 读读共享、写独占,适合读多写少

相关推荐
Season4501 小时前
C++11并发支持库(condition_variable | future全家桶)
java·jvm·c++
落羽的落羽1 小时前
【项目】C++从零实现JsonRpc框架——项目引入
linux·服务器·开发语言·c++·人工智能·算法·机器学习
Andy1 小时前
C++ 容器适配器_栈_队列_双端队列
开发语言·网络·c++
思麟呀2 小时前
在C++基础上理解Csharp-2
开发语言·jvm·c++·c#
桀人2 小时前
类和对象——上篇
开发语言·c++
智者知已应修善业2 小时前
【51单片机独立按键和定时器中断的疑惑验证】2023-11-2
c++·经验分享·笔记·算法·51单片机
老花眼猫2 小时前
C语言矩形旋转算法介绍
c语言·经验分享·青少年编程·课程设计
handler012 小时前
滑动窗口(同向双指针)算法:模板与例题解析
c语言·c++·笔记·算法·蓝桥杯·双指针·滑动窗口
Brilliantwxx2 小时前
【算法题】基础计算器的不同实现方式
c++·算法