尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

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

多线程锁详解:互斥锁·自旋锁·读写锁(CAS + futex 原理) 一、临界区锁保护的是临界区——一段不能被并发执行的的代码此时共享的资源就是临界资源。比如多个线程同时修改一个共享变量线程通过数据的共享完成交互可不加锁就会数据竞争。接下来我将介绍三种常见的锁互斥锁读写锁自旋锁二、互斥锁mutex语义同一时刻只有一个线程能持有锁。其他线程调用 lock() 时会让出CPU挂到等待队列中。当锁被释放操作系统唤醒一个等待线程。核心特点特性说明加锁失败时线程睡眠进入等待队列释放锁时唤醒等待队列中的线程上下文开销有——睡眠和唤醒涉及内核态切换适用场景通用临界区长度不确定时代码示例#includeiostream#includethread#includemutexstd::mutex mtx;intshared_counter0;voidincrement(intn){for(inti0;in;i){std::lock_guardstd::mutexlock(mtx);// 构造时加锁析构时解锁shared_counter;}}intmain(){std::threadt1(increment,100000);std::threadt2(increment,100000);t1.join();t2.join();std::coutcounter shared_counterstd::endl;// 200000return0;}分析如果不加锁极大概率会出现下面的情况导致最后counter数值不是200000。假设初始shared_counter 0线程 A 刚读到 0还没来得及加就被切换走了等它恢复时世界已经变了。步骤正在执行的线程发生的事件线程 A 的寄存器状态 (eax)线程 B 的寄存器状态 (ebx)内存 shared_counter上下文切换说明1A从内存读取 shared_counter 的值到 eaxeax 0—0A 在运行CPU 执行mov eax, [shared_counter]2(内核态)A 的时间片用完硬件触发时钟中断CPU 从用户态陷入内核保存 A 的上下文eax0 被保存到 A 的内核栈 / TCB—0内核将 A 的所有通用寄存器包括 eax0、程序计数器等存入 A 的线程控制块准备切换3B内核调度器选择线程 B 运行从 B 的 TCB 中恢复 B 的寄存器上下文(A 的上下文仍保存着 eax0)ebx 未知后变为 00B 被调度上 CPU它的 eip 指向上次停下的位置现在开始执行读取4B读取 shared_counter → ebx(A 仍保存 eax0)ebx 00B 此时从内存读到的也是 0因为 A 还没写回5B计算 ebx 1 → ebx (1)(A 仍保存 eax0)ebx 10B 在它的寄存器里完成了加 16B将 ebx 的值写回内存 shared_counter(A 仍保存 eax0)ebx 11B 成功把 1 写回内存7(内核态)B 的时间片也到了或主动让出CPU 陷入内核保存 B 的上下文(A 仍保存 eax0)ebx1 被保存到 B 的 TCB1B 的所有寄存器被保存此时 B 的现场是 ebx18A内核再次调度 A从 A 的 TCB 恢复 A 的寄存器eax 重新变成 0eax 0 (刚被恢复)(B 的 ebx1 已保存)1关键点A 被恢复时eax 还是它之前读到的 0它完全不知道内存现在已经是 1 了9A计算 eax 1 → eax (1)eax 1(B 的上下文保存)1A 基于过时的值算出了 110A将 eax 的值写回内存 shared_countereax 1(B 的上下文保存)1A 把 1 再次写回覆盖了 B 之前写入的 1B 的更新就这样“丢失”了三、自旋锁spinlock语义加锁失败时线程不睡眠而是在一个 while 循环中反复检查锁状态直到获取锁。// 自旋锁的伪代码while(!try_lock()){// 空转忙等}// 拿到锁了执行临界区核心特点特性说明加锁失败时忙等待while循环检查不睡眠上下文开销无——不涉及内核态切换CPU 浪费有——空转时占用 100% CPU适用场景临界区极短几十条指令且多核 CPU单核上注意单核自旋没意义被自旋的线程没CPU执行释放锁除非关中断代码示例#includeatomic#includethread// 用 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 flagATOMIC_FLAG_INIT;};// 使用方式SpinLock spinlock;intcounter0;voidincrement(intn){for(inti0;in;i){spinlock.lock();counter;spinlock.unlock();}}Linux 中的自旋锁#includepthread.hpthread_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 之间被中断加锁失败时如何让线程安全休眠并被唤醒而不是空转浪费 CPU1. 硬件基石CAS 原子指令问题的根源在于shared_counter的读取-修改-写回不是原子的。同样锁变量本身的检查-修改也不是原子的。现代 CPU 提供了CASCompare And Swap比较并交换指令来解决这个问题。CAS 指令接收三个参数内存地址、预期值、新值。它的语义由硬件保证原子执行如果内存地址的当前值与预期值相等则将该内存值更新为新值否则不更新。无论成功与否都返回该内存地址的旧值。在 x86 架构下对应cmpxchg指令。在 C 中可以通过std::atomic的compare_exchange_strong来使用。用 CAS 实现互斥锁的简化逻辑如下// 假设 lock_var 是原子变量0 表示空闲1 表示已占用std::atomicintlock_var{0};voidlock(){intexpected0;// 如果 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 上现代互斥锁通过 futexFast 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——重点语义读写锁有两种锁模式模式规则读锁共享锁多个线程可以同时持有读锁写锁排他锁独占互斥所有其他锁包括其他读锁和写锁关键澄清这是面试最容易答错的点读操作必须加读锁不是不加锁。只是多个读锁之间不互斥可以共存。但读锁和写锁之间是互斥的——有人读的时候不能写有人写的时候不能读。读写状态转移图注意有读锁时不能加写锁除非所有读锁都释放有写锁时不能加读锁也不能加写锁核心特点特性说明加读锁失败时睡眠等待有写锁在持加写锁失败时睡眠等待有读锁或写锁在持上下文开销和互斥锁一样会睡眠适用场景读多写少如配置表、缓存写饥饿问题默认策略下如果读操作持续不断写操作可能长期拿不到锁代码示例#includeiostream#includethread#includeshared_mutex// C17 读写锁std::shared_mutex rwlock;intconfig_value42;// 读线程加读锁voidreader(intid){std::shared_lockstd::shared_mutexlock(rwlock);// 读锁共享std::coutreader id: config config_valuestd::endl;}// 写线程加写锁voidwriter(intnew_val){std::unique_lockstd::shared_mutexlock(rwlock);// 写锁排他config_valuenew_val;std::coutwriter: config updated to config_valuestd::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 中的读写锁#includepthread.hpthread_rwlock_trwlockPTHREAD_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(C17)std::atomic_flag手动实现Linuxpthread_mutex_tpthread_rwlock_tpthread_spinlock_t七、选锁决策树临界区能睡眠吗 ├─ 不能中断上下文 → 自旋锁 └─ 能 ├─ 临界区极短几十条指令 → 自旋锁 └─ 临界区较长/不确定 ├─ 读多写少 → 读写锁 └─ 读写相当/只写 → 互斥锁创作充满挑战但若我的文章能为你带来一丝启发或帮助那便是我最大的荣幸。如果你喜欢这篇文章请不吝点赞、评论和分享你的支持是我继续创作的最大动力
返回列表