ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

深入解析Linux队列自旋锁:缓存一致性瓶颈与MCS算法实现

2026/9/19 18:14:18 拓冰建站 浏览量
深入解析Linux队列自旋锁:缓存一致性瓶颈与MCS算法实现 去年给一套数据库中间件做压测时遇到一个特别典型的现象服务从8并发调大到16并发吞吐量不光没涨反而明显下跌CPU倒是跑满了。perf top往里一看spin_lock相关的调用占了将近70%。当时第一反应是数据库SQL有问题结果排查到最后才发现瓶颈是一个被几百个线程疯狂抢占的全局计数器。真正让系统变慢的不是业务逻辑而是那个看起来“又轻又小”的自旋锁。后来我花了两天时间把Linux内核里Queued Spin Lock队列自旋锁从算法到源码彻底过了一遍才算是想明白了自旋锁在大规模竞争下到底是怎么崩的以及内核社区为什么要用一套这么复杂的实现去替代传统ticket锁。这篇文章不聊虚的直接从传统自旋锁的瓶颈讲起再把MCS锁算法和Linux内核的实现拆开揉碎最后说说实际排查竞争热点时能用上的手段。这套队列自旋锁机制主要解决的是多核高竞争场景下缓存一致性协议导致的性能崩塌问题。它改造了传统自旋锁“所有等待者盯着同一个锁变量自旋”的结构改为每个等待者在自己独立的本地节点上自旋通过队列形式精确传递锁的归属权。如果你平时写内核模块、弄驱动、做嵌入式或者正在跟多核性能问题打交道这篇文章应该能帮你彻底搞清楚锁竞争背后到底发生了什么。1. 传统自旋锁高竞争下的性能崩溃到底崩在哪1.1 自旋锁“轻量”的前提是没人跟你抢先说清楚自旋锁为什么在临界区非常短的时候是个好东西。它做的事情很简单先检查锁变量锁被占用就原地循环等待锁释放了立刻抢。整个过程不涉及线程调度和上下文切换在临界区只有几十纳秒的场景下开销极小比互斥锁mutex在用户态和内核态之间来回切换划算得多。这也是为什么中断上下文、软中断处理里只能使用自旋锁的原因——这些路径根本不能睡眠一睡眠整个系统就乱了。但自旋锁的“轻量”有一个非常苛刻的前提竞争不能激烈。如果两个CPU偶尔争一下各自自旋一两圈就能拿到锁性能确实漂亮。可一旦CPU核心数量上升到16个、32个甚至更多所有人都瞄着同一个锁变量打转事情就开始失控了。1.2 缓存一致性协议在抢锁时引发的“乒乓效应”这里要引入一个很多人忽略的底层机制缓存一致性协议MESI协议。每个CPU核心都有自己私有的L1/L2缓存同一个内存地址的数据会被多个核心分别缓存一份副本。当某个CPU要修改锁变量时它必须通知其他所有CPU把对应的缓存行标记为失效这个操作叫缓存行颠簸cache line bouncing。想象一下32个CPU同时在同一个锁变量的缓存行上自旋读取一开始大家都在共享状态相安无事。一旦锁持有者释放锁某个CPU通过原子比较交换CAS指令抢到了锁这个写操作立刻让其他31个CPU的本地缓存副本全部失效。接下来这31个CPU的每一次自旋读取都要跨越总线去重新拉取最新的缓存行。然后下一个释放锁的瞬间历史重演。几个来回下来系统总线被无意义的缓存同步流量塞满锁的获取/释放操作本身只需要几十纳秒但整个一致性协议同步过程可能消耗几微秒。更严重的是硬件在总线层面对原子指令的锁定会让所有原子操作变成串行化执行抢锁的核心数越多串行化延迟越高。1.3 ticket锁解决了公平没解决颠簸可能有读者会问Linux内核以前的ticket自旋锁不是保证了先来后到的公平性吗怎么还是会出现性能崩溃Ticker锁确实解决了公平问题。它为每个等待者发一个排队号锁释放时按顺序叫号避免后到的线程反复插队导致前边的线程饿死。但问题在于ticket锁的所有等待者仍然是在同一个全局锁变量上自旋。叫号这个动作本身就是一个全局共享状态的变更依然要触发大规模缓存行失效。公平性上去了缓存行颠簸问题原封不动。所以社区才意识到解决高竞争自旋锁性能问题的关键不是“让谁先拿到锁”而是“等待者到底在哪里等锁”。如果每个人都盯着同一个地方不管调度策略多公平总线流量都会爆炸。2. MCS锁算法队列自旋锁的理论核心2.1 每个等待者都应该有自己的“自旋房间”回到你上学时上自习的经历。传统自旋锁相当于大家挤在一间教室里抢唯一的一把椅子所有人眼睛都盯着这把椅子谁起身谁坐下都会引发一阵骚动。而MCS锁的意思是教室门口放一个接待员来一个人接待员就把他领到一间独立的小隔间告诉他“上一间的人出来时会通知你”。MCS锁以John M. Mellor-Crummey和Michael L. Scott两位计算机科学家的名字命名的核心思路就是给每个等待者分配一个本地节点所有人只需要在自己的节点状态上自旋而不是在全局共享的锁变量上自旋。全局只有一个tail指针用来指向队列末尾的节点它记录的是“谁是新来的”而不是“锁现在什么状态”。2.2 MCS获取锁和释放锁的算法流程用一个极简结构来描述MCS锁的核心逻辑struct mcs_node { struct mcs_node *next; int locked; // 1表示当前节点已经获得锁 }; struct mcs_lock { struct mcs_node *tail; // 指向队列尾部节点 }; void mcs_lock_acquire(struct mcs_lock *lock, struct mcs_node *my_node) { struct mcs_node *prev; // 初始化自己的节点 my_node-next NULL; my_node-locked 0; // 把自己注册为新的队尾并拿到之前的队尾 prev __atomic_exchange_n(lock-tail, my_node, __ATOMIC_ACQUIRE); if (prev NULL) { // 说明队列本来为空自己直接获得锁 my_node-locked 1; return; } // 把前一个节点指向自己然后在自己节点上自旋 prev-next my_node; // 自旋等待前驱节点把 locked 置 1 while (!READ_ONCE(my_node-locked)) cpu_relax(); // 到这里锁已经属于自己 } void mcs_lock_release(struct mcs_lock *lock, struct mcs_node *my_node) { struct mcs_node *next; next READ_ONCE(my_node-next); if (next NULL) { // 如果自己是队尾尝试把tail清零 if (cmpxchg(lock-tail, my_node, NULL) my_node) return; // 释放完成没有后继者 // 说明在判断next和cmpxchg之间有人入队了 while (!(next READ_ONCE(my_node-next))) cpu_relax(); } // 直接把锁“交给”后继节点而不是把锁变量清零 WRITE_ONCE(next-locked, 1); }获取锁的流程可以拆成三步第一步把自己的节点状态初始化为“未获得锁”第二步通过原子交换把tail更新为自己的节点同时拿到之前的队列尾节点第三步如果之前队列不为空就让前驱节点的next指向自己然后在自己的locked字段上原地自旋。这里面的关键是自旋操作的对象是自己的本地缓存行前驱节点在释放锁时只写一次next指向的后继节点locked字段。修改后继节点的locked字段只会让被修改的那个CPU的缓存行失效其他等待者完全不受影响缓存一致性流量从原来的“一次释放影响所有人”降为“一次释放只影响一个人”。2.3 释放锁的“精准交接”MCS释放锁的过程跟传统自旋锁完全不同。传统锁释放时把锁变量置0所有人都看到锁空闲了然后一拥而上MCS释放时只把后继节点的locked置1相当于点名把锁交到下一个人手里。整个队列里只有拿到锁的那个节点知道锁已经释放了其他节点依然在自己的缓存行上安静自旋互不干扰。这个“精准交接”的设计让每轮锁的释放/获取操作涉及的缓存一致性流量从O(n)降到了O(1)。这是队列自旋锁在理论性能上能碾压传统自旋锁的根本原因。3. Linux内核struct qspinlock的数据布局与设计取舍3.1 一个32位锁字如何同时承载队列信息和锁状态理解了MCS锁的理论基础后再看Linux内核的具体实现就轻松多了。Linux从4.x开始引入的struct qspinlock在include/asm-generic/qspinlock_types.h中定义核心思想是在MCS锁的基础上进一步把状态和队列信息压缩到一个32位的原子变量里避免额外分配一整块lock结构体。一个struct qspinlock的锁字被拆成如下几个部分位段字段作用bit 0-7locked锁是否被持有bit 8-15pending是否有等待者处于“待定”状态bit 16-31tail队列尾部节点的位置信息其中低2位是per-CPU节点索引高14位是CPU编号这里最巧妙的是tail字段。MCS锁在理论算法里用的是一个指向节点的链表尾指针但在内核里这个“指针”没办法直接保存一个完整的地址——每个CPU维护自己的MCS节点后用一个(CPU号 节点索引)的组合就能唯一定位到某个CPU上的哪个MCS节点比保存64位内存地址省了一半空间。3.2 pending位低竞争不做队的高效捷径qspinlock的设计者很清楚一个事实内核里绝大多数的锁竞争都不激烈。如果每次锁冲突都老老实实走MCS队列光是排队入队的开销就够喝一壶了。所以qspinlock特意设置了一个pending位用来处理“竞争很轻微”的场景。如果当前锁字里tail字段为空说明没人排队并且pending位也是0新来的等待者可以尝试把pending位置1。这意味着它不需要入队只需要等待当前的locked位清零后直接获得锁然后清掉自己的pending位。这个路径比完整的MCS排队快很多几乎就是传统自旋锁的开销。只有当pending位已经被占用时后来的等待者才需要进入MCS队列。也就是说内核实际的行为是前两个抢锁的人用“fast path形态”快速处理第三个及之后的等待者才排队。这个折中设计在真实负载里非常有效因为绝大部分临界区冲突只会有一两个CPU在争。3.3 为什么没有cmpxchg16b的平台要用qrwlock回退看到这里你可能会产生一个疑问qspinlock把locked、pending、tail打包在一个32位变量里那64位系统岂不是只用了一半实际上64位系统上对qspinlock的tail和locked字段进行同步需要使用cmpxchg16b指令这要求CPU支持128位原子比较交换。对于早期的x86平台或者部分ARM32平台没有cmpxchg16b指令内核会退回到一套基于qrwlock队列读写锁本质是ticket锁思想的实现。虽然它不像qspinlock那样有MCS队列优化但保持了正确的同步语义只是高竞争下性能差一些。所以在看内核代码时CONFIG_QUEUED_SPINLOCKS配置项决定到底使用哪套实现。3.4 per-CPU节点池为什么要预留两个MCS节点qspinlock的每个CPU上会维护一个struct mcs_spinlock数组通常包含两个节点。有人第一次看到时不明白为什么不只用一个原因是防止死锁。假设CPU A上的代码正在qspinlock的慢路径中自旋这时来了一个中断中断处理程序里又尝试获取同一个锁或其他锁而且又走入了MCS排队路径。如果只有一个节点中断处理程序就只能复用正在使用中的节点一旦它把节点地址写入tail当前慢路径的排队数据就被破坏了整个锁状态直接乱掉。所以内核为每个CPU预留两个节点一个用于“普通路径”一个用于“在慢路径中被中断嵌套打断时再次排队”。这让qspinlock在中断嵌套环境下依然安全。4. 获取与释放锁的完整路径从fast path到slow path4.1 fast path里的一条原子指令一般情况下获取一把qspinlock要走fast path核心代码简单得令人发指void queued_spin_lock(struct qspinlock *lock) { u32 val atomic_read(lock-val); if (likely(!val)) return; // 真正的fast path是 atomic_cmpxchg_acquire queued_spin_lock_slowpath(lock, val); }如果读到的锁值是0说明锁完全空闲接下来只是用一条原子比较交换指令把locked位置1整个获取锁的路径就是一条原子指令的开销。这也是为什么自旋锁在低竞争时比mutex快几个数量级的原因没有线程调度、没有用户态/内核态切换、没有上下文保存恢复只有一次原子操作。4.2 slow path里的多级分流当锁已经有持有人时就进入queued_spin_lock_slowpath。这个过程可以概括为下面几条分支路径尝试pending路径如果tail字段为0无人排队并且pending位空闲那么用原子指令把pending位置1。置成功后等待locked位清零然后获取锁并清掉pending完成。如果pending路径走不通说明已经有人在pending位上等着或者已经有人排队了。此时开始在per-CPU的MCS节点上登记把自己的节点挂到队列尾部。入队之后如果之前tail为空自己其实是第一个排队者等pending位的持有者处理完后就能直接上位如果之前tail非空就要把自己的节点链接到前驱节点的next上然后在前驱节点指向自己的next字段上等待前驱把自己的locked置1。入队过程中用到的原子操作是atomic_cmpxchg更新tail字段。这里要特别注意更新tail的原子操作和等待前驱节点的自旋这两个动作的内存序要求非常严格。Linux内核用acquire和release语义的原子API来保证入队的初始化动作不能乱序到tail更新之后释放锁的写操作不能乱序到临界区操作之前。4.3 释放锁的“翻牌”过程与内存屏障释放锁时代码会判断自己是否有后继者如果没有后继者直接把tail清空同时清掉locked位锁完全空闲。如果有后继者不把锁变量置0而是直接找到队列中的下一个节点把该节点的locked字段置1。这一个写操作直接唤醒后继等待者不需要让别人去抢锁字也就避免了所有其他CPU的缓存行被误伤。内存屏障在这里扮演了重要角色。在x86强内存序模型下smp_store_release几乎等同于一个普通的写操作加上显式屏障而在ARM64等弱内存序平台release语义保证临界区内所有写操作在释放锁之前对其他CPU可见。如果没有这层屏障后继者拿到锁后读到的临界区数据可能还是旧的整个锁机制就形同虚设了。具体实现中queued_spin_unlock会先__this_cpu_dec节点引用计数再通过atomic_add或smp_store_release完成状态切换。5. 实践遇到qspinlock热点怎么定位、怎么调优5.1 什么场景下你会真正撞上qspinlock热点很多人写驱动时感觉不到锁竞争的存在因为锁一直是“偶尔等几圈”的状态。但一旦你的代码运行在几十核的机器上并且临界区访问频繁qspinlock就能从perf里“冒出头来”。常见的qspinlock热点来源全局计数器比如一个所有线程都要更新的大统计结构体归一到单个链表/哈希表上的共享数据结构比如全局路由表、权限表某些驱动里为了图省事把整个状态机保护在一把大锁里数据库或存储引擎中的日志序号分配、缓冲池元数据更新。这些场景的共性是临界区极其短小但调用频次极高。正是“短小而高频”的组合才会让自旋锁的缓存一致性开销被无限放大。如果临界区本身要执行好几微秒自旋锁的成本反而被稀释了那时真正的问题是CPU空转而不是缓存一致性流量。5.2 一次真实竞争热点的完整排查链路假设你现在遇到了性能下降怀疑是qspinlock竞争可以按下面的链路一步步走第一步确认热点函数在锁里。用perf top -g看内核态热点分布如果lock前缀函数如queued_spin_lock_slowpath、native_queued_spin_lock_slowpath的占比超过10%锁竞争基本实锤。第二步找到具体是哪把锁。这是最费劲的一步。打法很多开启内核的CONFIG_LOCK_STAT通过/proc/lock_stat查看锁的竞争次数、等待时间排行用echo 0 /proc/sys/kernel/lock_stat清零统计压测几分钟后再读取对比前后差异配合perf record -g -e cycles抓取调用栈从栈顶的queued_spin_lock_slowpath往上追找到调用它的业务函数trace-cmd record -e lock:lock_acquire跟踪锁获取事件筛选长等待锁。第三步定位临界区之后做减法。我自己的习惯是看到一个锁竞争高先不看锁本身好不好先问一句“这块数据真的需要全局共享吗”很多时候答案都是不需要。下面是几个常规且有效的优化方向把全局统计拆成per-CPU变量定时或只在需要汇总时合并用原子变量替代自旋锁比如atomic_inc_return用于序号分配读多写少的场景改成读写锁或RCU让读路径完全无锁实在改不了共享结构再考虑细分锁粒度比如把一张大表拆成多个桶每个桶有自己的锁。下面是一张我常用到的同步原语选型快速对照表原语适用场景不适用场景原始自旋锁临界区极短、中断上下文高竞争、大临界区qspinlock高竞争、多核可睡眠路径不可用rwlock读多写少读写都频繁mutex可睡眠、临界区较长中断上下文RCU读极多、可延迟回收写频繁、不能容忍旧数据原子变量单一计数器、标志位复合状态变更5.3 实测中需要注意的坑第一务必开启lockdepCONFIG_PROVE_LOCKING。很多人在自研驱动里不习惯开这个选项觉得它拖慢性能。但lockdep能在你踩到锁序死锁问题前发出警告一次死锁定位的成本远高于这点性能损耗。我自己在内核开发调试时从不关闭lockdep只有在做最终性能基准测试时才关。第二区分“伪热点”和“真热点”。有时候perf显示qspinlock占用高但当临界区里的业务本身就很慢时锁的等待时间会被放大到不合理的程度。这时候你优化锁是没用的应该优化临界区内部的逻辑。判断方法是看平均临界区耗时如果临界区要跑好几个微秒锁的开销反而是次要矛盾。第三虚拟化环境下要额外考虑pvqspinlock。当多个vCPU共享同一个物理核时一个vCPU在自旋等待的锁可能被调度到另一个vCPU上运行了导致自旋的vCPU其实是在空转。内核提供了CONFIG_PARAVIRT_SPINLOCKS来缓解这个问题借用于hypervisor的kick机制主动唤醒锁持有者。在云主机或虚拟机里做嵌入式开发时关闭这个选项可能导致严重的vCPU调度抖动。6. 关于锁和并发几个这些年攒下来的私人忠告第一次真正看明白qspinlock的实现时我心里挺震撼的——一个“几行代码就能写出来”的自旋锁在Linux内核里被设计成如此精巧的结构这背后本质上是对真实世界性能问题的尊重。写内核代码也好写用户态并发程序也好锁永远不是问题的最终答案它只是把共享访问做得安全的一种手段。我自己的体会是遇到锁竞争先做减法把共享数据减少再做优化换更合适的锁类型最后才考虑是不是要上无锁算法。另外分享一个很实用的调试技巧在开发阶段遇到莫名其妙的死锁或数据错乱先怀疑锁然后马上打开lockdep。它能直接告诉你两个锁的获取顺序在哪里冲突了省去几个通宵的人工排查时间。有一回我写一个字符设备驱动两个ioctl路径获取锁的顺序写反了死锁概率只有千分之一左右普通测试根本跑不出来开了lockdep之后第一次压测就直接报了锁序问题。从那以后新内核或者新驱动的第一件事就是确认lockdep开关是打开的。队列自旋锁是Linux内核同步机制里很有代表性的一个设计理解了它再去看读写锁的排队机制、RCU的延迟回收思想会发现很多底层逻辑是相通的尽量少动共享状态尽量让局部热点留在本地尽量把通知精确到真正需要的人。这套思路放在分布式系统、数据库事务的性能优化上同样成立。