我最早接触队列自旋锁(Queued Spin Lock)的时候,还是在排查一个多核系统上网络吞吐量严重抖动的问题。当时用 perf 一看,好几个 CPU 都在native_queued_spin_lock_slowpath里转圈,锁竞争成了系统瓶颈。后来我仔细读了一遍 Linux 内核里qspinlock.c的实现,才真正意识到这玩意远比教科书里写的“自旋锁”要精巧:它本质上是一个分布式排队系统,用原子变量维护一个链表,让每个等待者只在自己的缓存行上自旋,从而把无谓的缓存一致性流量降到最低。这篇文章我想把这套同步原语的核心设计、实现逻辑、以及我在实际内核开发中踩过的坑,完整地拆开讲一遍。适合正在读内核源码、做驱动开发、或者被多核性能问题折磨过的朋友参考。
1. 从朴素自旋锁到排队自旋锁:到底解决了什么问题
1.1 传统自旋锁为什么会在高竞争下“爆炸”
要理解队列自旋锁的价值,得先回到最原始的自旋锁实现。经典的自旋锁本质上就是一个内存中的整数,加锁用原子指令(比如 x86 的lock cmpxchg)把这个整数从 0 改成 1,解锁再把它改回 0。如果一个 CPU 发现锁已经被占,它就原地循环反复读这个值,直到锁被释放。
听起来很直接对吧?但这个设计有一个致命问题:所有未抢到锁的 CPU 都在同一个共享变量上轮询。当一个持锁者最终释放锁、把锁变量改成 0 的那一瞬间,几十个等待者会同时看到这个变化,然后一起发起原子修改操作来抢锁。结果是只有一个人成功,剩下的人又一次全部扑空,继续自旋。整个过程会反复制造大量的缓存行失效和原子指令重试。
在这背后,缓存一致性协议成了最大的受害者。比如 x86 的 MESI 协议里,共享的锁变量在每个 CPU 的 L1 缓存里都会有副本。一旦某个 CPU 写入锁变量,其他所有持有该副本的缓存行必须失效。大量 CPU 高频地同时读写同一个变量,就会形成所谓的“缓存行颠簸”或者“锁抖动”。我见过一个 40 核的机器上,某个临界区明明只执行几百纳秒,但整体加锁开销因为抖动被放大到了几微秒。
另外就是公平性问题。朴素自旋锁完全无法保证“先到先得”,极端情况下一个线程可能反复抢不到锁,也就是饥饿。虽然从吞吐量的角度看,饥饿不一定会立刻触发错误,但在实时性要求较高的系统里,这种不确定性很难接受。
1.2 排队思想的直观类比
队列自旋锁的解决思路,可以类比成餐厅排队叫号。朴素自旋锁相当于所有人都挤在柜台前面抢着递单子,谁力气大谁先办;而排队锁则是在门口放了一台取号机,每个到来的人先拿一个号,然后在自己的位置上等着,柜台叫到号才过去。
计算机里的“取号机”就是锁对象内部维护的一个队列。每个等待的 CPU 把自己封装成一个节点,挂到这个队列的尾部,然后只看自己前一个节点是否已经释放锁。这样每个 CPU 只在属于自己的私有节点上自旋,不会去碰共享的全局锁变量。当锁被释放时,持锁者会显式地唤醒队列中的下一个节点,于是整个流程变得完全有序。
这个方案不仅消除了缓存行颠簸,还自然实现了 FIFO 公平性。每一个等待者被唤醒的顺序和它们进入队列的顺序完全一致,不会有人插队,也不会有人被饿死。可以说,队列自旋锁用一套非常巧妙的“排队通知”机制,换来了性能和公平性的双重提升。
1.3 为什么是现在才普遍默认启用
队列自旋锁的核心算法很早就有了,学术界叫 MCS 锁(Mellor-Crummey 和 Scott 两位研究者提出)。真正把它合入 Linux 内核主线并默认启用,是经历了一个相当长的过程。早期内核在单核或者少核系统上运行,锁竞争根本不严重,朴素自旋锁足够用。后来多核、NUMA 架构普及,大规模并行程序让锁竞争越来越常见,上游才在 4.x 内核里逐步完善了qspinlock的实现。
还有一个很重要的原因:队列自旋锁的代码路径比朴素自旋锁更长,加锁解锁的指令数量也更多。如果锁竞争率很低,也就是绝大多数加锁操作都能一次成功,那么“快速路径”必须足够快,不能让所有用户都去承担排队机制的额外开销。所以 Linux 的实现里专门区分了 fast path 和 slow path:第一次尝试加锁如果成功,就在几条指令内返回;只有当锁已经被占,才会进入复杂的排队逻辑。这种分层的设计决定了它可以在各种负载下都表现得不错。
2. 核心设计拆解:数据结构与加解锁路径
2.1 一个整型如何同时表达锁状态和队列信息
Linux 内核中的qspinlock在 64 位系统上使用一个 32 位的原子变量作为锁的主体。这一点很多人第一次看会有点懵:一个 32 位数既要表示“锁是否被持有”,又要表示“队尾是谁”,而且还要把等待队列串起来,怎么做到?
答案是把这 32 位拆成几个字段。低位的一位叫locked,表示锁是否被持有;剩余的高位则作为一个尾指针的索引,指向队列中最后一个节点。由于每个 CPU 的等待节点是预先定义好的、排列在内存中的结构体数组,所以可以用编号来定位节点,而不是真正的内存地址。
Linux 内核里定义了一套辅助宏和函数来编码解码这些字段。比如tail的值由 CPU 编号和节点索引拼出来的,组合起来可以唯一确定是哪个 CPU 的哪个节点。这样做的最大好处是:在 x86 平台上,所有对锁对象的修改都能用一个原子cmpxchg完成,避免引入额外的锁或复杂的事务。
我画一张简化的字段布局,大家感受一下(以 32 位锁值为例):
| 位段 | 含义 | 说明 |
|---|---|---|
| bit 0 | locked | 1 表示锁被持有,0 表示已释放 |
| bit 1 | pending | 表示有 CPU 正在等待获取快速路径(可选,某些架构实现有) |
| bit 8..31 | tail | 队尾节点的编码,用于唯一确定队列最后一个等待者 |
这个 packed 布局是队列自旋锁的精髓。它允许加锁时用一个cmpxchg同时完成“检查锁是否空闲”和“把当前 CPU 追加到队尾”两个操作。如果锁空闲,cmpxchg成功,当前 CPU 直接成为持锁者;如果锁忙,cmpxchg失败,正好把当前 CPU 的信息写进 tail 字段,相当于完成了排队动作。
2.2 加锁路径:fast path 与 slow path 的配合
每次加锁都是一次queued_spin_lock()调用,在 Linux 内核中通常被实现为一个静态函数或宏。核心流程是这样的:先用原子操作尝试把 locked 位从 0 改成 1。如果成功,说明当前 CPU 拿到了锁,整个加锁过程结束。这个路径只会执行很少的指令,是绝大多数低竞争场景下的常态。
如果失败,就要进入 slow path。此时的局面是:锁已经被其他人持有,当前 CPU 需要排队。慢路径会先尝试一种介于快速路径和完整排队之间的优化:设置 pending 位,表示有一个 CPU 在等待。如果此时锁被释放,pending CPU 可以直接接过锁,连完整队列都不用建立。但这只是一个短暂的过渡状态,如果等待者太多,最终还是要走完整的入队流程。
完整入队时,当前 CPU 会把自己的节点索引通过cmpxchg写入锁对象的 tail 字段,并把自己挂到前一个节点的 next 指针上。这一步要处理一个并发窗口:如果当前 CPU 是第一个入队的,它必须把前一个节点的 next 指过来;如果队列已经存在,它就只负责让前一个节点知道“你后面有人了”。
在整个慢路径中,一个常见的同步细节是:每个 CPU 的节点里会有一个locked标志,表示前驱是否已经把锁交给自己。等待者实际上是在自己的节点上自旋,反复读取这个字段,直到前驱把它的值改成 1。注意,这个节点通常正好落在当前 CPU 的本地缓存里,所以自旋成本非常低,不会触发全局的缓存一致性广播。
2.3 解锁路径:只通知下一个节点
解锁操作比加锁要简单得多,但里面也有一个关键的分支判断。如果锁对象的尾部遍历之后发现当前持锁者就是队尾,说明没有其他人在排队,直接把 locked 位清零即可。这对应无竞争场景下的快速解锁。
如果有等待者,那么持锁者不能简单地清 locked 位,而是要从自己的节点出发,找到 next 指针指向的下一个节点,把那个节点里的locked字段置位,通知它“锁已经是你的了”。这一步通常用一个带释放语义的写操作完成,确保临界区里的所有内存访问在解锁之前对下一个持锁者可见。
这里有一个非常容易被忽视的边界情况:当前持锁者的 next 指针可能还是空的,但这不代表没有等待者。因为存在一个时间窗口:某个 CPU 已经通过cmpxchg把自己写成了队尾,但还没来得及更新前驱的 next 指针。所以解锁时如果发现 next 为空,不能直接认为队列为空,而是需要回到锁对象的 tail 字段再检查一下,必要时还要忙等一小会儿,确保入队操作完成。这个细节在阅读源码时很容易被跳过去,但它恰恰是并发正确性的关键。
2.4 为什么队列节点必须用 per-CPU 变量
每个 CPU 的节点在 Linux 内核中被定义为 per-CPU 变量。这意味着 CPU0 的节点和 CPU1 的节点在物理内存上相隔很远,每个节点很大程度上只被对应的 CPU 访问。这也是队列自旋锁性能好的核心原因:等待者自旋时读的是自己的节点,而不是一个全局共享的缓存行。
如果这些节点不是 per-CPU 的,而是集中放在一起,那么多个 CPU 可能因为共享同一个缓存行而互相干扰。这种干扰即使逻辑上没有直接竞争同一个变量,也会因为硬件缓存行的粒度造成“伪共享”。per-CPU 布局从物理上隔离了这种干扰,配合缓存行对齐,几乎可以确保每个等待者在自旋期间不产生跨 CPU 的一致性流量。
不过这也带来了一个新问题:内存占用变大了。每个 CPU 都要预分配一个节点,而且节点之间要用缓存行对齐填充,避免共享缓存行。好在系统里 CPU 数量最多也就几百个,一个节点不过几十字节,总开销完全可以接受。相比它带来的锁竞争性能收益,这点内存微不足道。
3. 从源码角度看关键实现:x86 平台的落地细节
3.1 核心数据结构和常用宏
Linux 内核的include/asm-generic/qspinlock_types.h里定义了锁的基本结构。x86 架构实际使用的是原生原子类型,没有额外的包装结构。简化后大约是:
typedef struct qspinlock { union { atomic_t val; struct { u8 locked; u8 pending; }; struct { u16 tail; }; }; } arch_spinlock_t;这段代码里可以看到前面提到的字段:locked一个字节,pending一个字节,tail两个字节。合在一起刚好一个 32 位原子变量。在实际的汇编操作里,x86 经常用 32 位宽的cmpxchg同时操作整个结构,而不是只改其中某一个字节。
队列节点的定义则长这样:
struct mcs_spinlock { struct mcs_spinlock *next; int locked; int count; };next是指向下一个等待者的指针,locked是前驱释放锁时置位的标志,count主要用于嵌套或调试场景。每个 CPU 都有一个mcs_spinlock实例,并且会做缓存行对齐,确保没有伪共享。
3.2 x86 上的加锁汇编优化
x86 的内核实现在很多情况下会把手写的汇编和 C 代码混合起来。快速路径的加锁在arch_spin_lock中实现,通常会用到LOCK前缀的原子指令。核心思想是:尽可能用一条指令完成“判断 + 修改”。
内联汇编中常见的做法是先做一个普通的mov读取当前锁值,判断 locked 位是否已经被置位。如果没有,才执行lock cmpxchg尝试获取;如果读出来已经置位,直接跳到 C 语言的 slow path。这个预处理的意义在于:原子指令的成本远高于普通指令,而且会锁总线或锁缓存行,如果能在普通读取阶段就发现锁不可用,就可以避免昂贵的原子操作。
ARM64 平台稍有不同,它使用LDXR/STXR这样的独占加载和存储指令来实现无锁更新,但整体思路还是保持一致。内核通过arch_spin_lock这个抽象层把这些架构差异都屏蔽掉了,驱动开发者通常不需要关心,但理解这一点有助于你分析不同架构下的性能差异。
3.3 slow path 的核心循环逻辑
深入到kernel/locking/qspinlock.c里的queued_spin_lock_slowpath函数,会看到这个函数处理了各种复杂的竞争窗口。我简化一下它的核心逻辑,方便大家理解:
void queued_spin_lock_slowpath(struct qspinlock *lock, u32 val) { if (val & _Q_LOCKED_MASK) { // 锁正被持有,尝试设置 pending 位,用来标记有等待者 if (!(val & _Q_PENDING_MASK)) { // 尝试从 0 个等待者变为 1 个等待者 } } // 等待 pending 状态的变化,并最终入队 node = this_cpu_ptr(&mcs_nodes[0]); tail = encode_tail(smp_processor_id(), idx); // 原子地把当前节点追加到队尾 old = xchg_tail(lock, tail); if (old) { // 队列已经存在,让前驱节点知道自己的后继是谁 ... // 在自己的节点上自旋,等待前驱把 locked 置位 while (!READ_ONCE(node->locked)) cpu_relax(); } // 拿到锁,进入临界区 ... }这段伪代码略过了许多细节,比如多个等待者在同一时刻竞争 tail 字段时,xchg_tail需要确保每个 CPU 拿到的 old 值都是不同的。如果两个 CPU 同时调用xchg_tail,原子指令会保证它们依次执行,所以 old 值会形成一个有效的链表顺序。
在自旋等待前驱释放锁时,代码使用了READ_ONCE而非普通读取。这背后有编译器层面的因素:如果直接读一个普通变量,编译器在优化时可能把这个读取抬出循环,导致死循环。READ_ONCE告诉编译器“每次都要真正从内存里重新读”,这是自旋锁实现中不可省略的细节。
3.4 抢占、中断与嵌套加锁的坑
内核自旋锁的使用场景有一个铁律:持有自旋锁的临界区不能被抢占,也不能睡眠。在 Linux 中,这通过关抢占来实现。获取自旋锁时,内核会调用preempt_disable()或者直接依赖架构层面的中断屏蔽来保证当前 CPU 不会在临界区内被调度出去。
但这里隐藏着一个实现上的麻烦:如果获取自旋锁时已经关了抢占,而这个 CPU 在自旋等待的过程中来了一个中断,中断处理程序又尝试获取同一把锁,会发生什么?在没有特殊处理的情况下,这就是一个典型的死锁。因为中断处理程序会一直自旋,而锁的持有者又是当前被中断打断的这个 CPU,它不可能继续执行到解锁步骤。
正因如此,锁 API 才区分了普通自旋锁和spin_lock_irqsave/spin_lock_bh这样的变体。使用spin_lock_irqsave时,内核会在加锁前保存当前 CPU 的中断状态,并显式关中断,防止当前 CPU 在持有锁期间被中断打断,进而避免中断上下文里再来抢同一把锁导致死锁。这些约束在阅读队列自旋锁源码时尤其重要,因为慢路径的自旋过程更不能被中断,否则中断上下文可能无限等待。
4. 性能特征、配置开关与调试手段
4.1 fast path 与慢路径的性能分水岭
队列自旋锁的设计最聪明的地方,是它给用户提供了一条“几乎免费”的快速路径。理想情况下,一个加锁操作涉及一次原子cmpxchg,大约二三十个周期;解锁操作也只是写一次 locked 位,几个周期。这个开销比一次函数调用大不了多少。
但一旦发生竞争,成本就完全不一样了。慢路径不仅要执行xchg_tail这样的原子操作,还要经历缓存行同步、节点链接、自旋等待、前驱唤醒等一长串流程。在有 NUMA 架构的机器上,从一个 NUMA 节点跳到另一个 NUMA 节点访问内存,延迟可能多出几百纳秒。这种开销直接反映在内核的调度、文件系统锁、网络协议栈等各个模块上。
因此,在评估自旋锁性能时有一个经验法则:临界区越小、竞争越少,快速路径占比越高,队列自旋锁优势越明显;临界区大而竞争激烈时,不管用什么自旋锁都会很吃力,这时候就该考虑读写锁、RCU或者其他并发模型了。我自己在测试中经常先跑一个基准工具观察锁竞争率,再决定是否值得换锁类型。如果锁竞争率已经超过 30%,我会优先看看能否缩小临界区,而不是单纯优化锁实现。
4.2 内核配置选项与编译影响
要启用队列自旋锁,需要在内核配置中确认CONFIG_QUEUED_SPINLOCKS已打开。在大多数主流发行版的内核配置里,这个选项默认是开启的。不过它会受到架构支持的限制,某些架构如果没实现相关的汇编辅助函数,可能无法启用。
还有一个重要关联选项是CONFIG_PARAVIRT_SPINLOCKS。这个选项在虚拟化环境下特别有意义。当客户机里的 vCPU 在物理 CPU 上等待自旋锁时,如果它一直在空转,会白白浪费宿主机资源。开启这个配置后,VMM 可以介入锁的等待过程,把等待的 vCPU 调度出去,等锁释放再把它唤醒。这就是所谓半虚拟化自旋锁。它让队列自旋锁在云环境和虚拟化场景中变得更加友好。
对于嵌入式或实时性要求高的系统,CONFIG_PREEMPT_RT配置也会和自旋锁产生交互。在 RT 内核中,很多自旋锁会被替换成支持优先级继承的rt_mutex,这是为了避免高优先级任务被低优先级任务持有锁时发生优先级反转。但这已经不是队列自旋锁本身的问题了,而是整个同步框架需要配合实时调度策略做出的改变。
4.3 用 lockdep 和性能工具识别锁问题
Linux 内核里最强大的锁调试工具是 lockdep。它在编译内核时通过CONFIG_PROVE_LOCKING开启。lockdep 会静态分析代码中所有的加锁顺序,并建立一个“锁依赖图”。如果程序试图在持有锁 A 的情况下再获取锁 B,而另一条路径又试图在持有锁 B 的情况下获取锁 A,lockdep 会认为存在潜在死锁,并在日志中打印详细的调用链。
我在开发驱动时,遇到过很多次 lockdep 告警。那些告警往往不是立即可见的死锁,而是错误的加锁顺序,可能在特定并发条件下才爆发。lockdep 的价值在于把这些隐患提前暴露出来。每当你在调试一个和自旋锁有关的疑难杂症时,第一件事应该是检查内核日志里有没有 lockdep 的输出,这能省下大把抓头发的时间。
除了 lockdep,perf lock子命令也很有用。它可以统计每个锁的竞争次数、等待时间、持有时间等指标。通过perf lock record抓一段数据,再用perf lock report查看报告,能直观看到哪个锁是热点,以及不同的调用路径竞争分布。这个工具是我定位大规模并发性能瓶颈的首选。
另外,hung task检测器也能在系统卡死时提供线索。如果某个进程在内核态自旋了太久,hung task会在控制台输出当时的栈回溯。配合sysrq的show-state或show-blocked-tasks,往往能快速定位到底是哪把锁导致系统响应全无。
4.4 一个小实验:对比不同锁实现的竞争耗时
几周前我在一台 16 核测试机上跑过一个简单实验:用两个内核模块分别统计在临界区只做一次计数递增的场景下,传统 ticket spinlock 和 qspinlock 的并发耗时。实验的方法是让 8 个线程同时去抢一把锁,循环一千万次,测量总耗时和平均每次加锁耗时。
结果和我预期一致:在低竞争(两个线程)时,两者差距很小,因为大多数加锁都走快速路径成功;但把线程数升到 8 个以后,qspinlock的耗时大约只有 ticket spinlock 的 60% 左右。更明显的是,如果我在每个线程做完原子操作后主动sched_yield()一下,制造更大的调度扰动,传统自旋锁的耗时波动非常剧烈,而队列自旋锁则稳定很多。这说明排队机制除了降低缓存一致性开销,还让整体的等待模式变得更可预测。
5. 实际踩坑记录:从死锁到伪共享
5.1 案例一:中断上下文与自旋锁导致的“假死”
有一次我在调试一个网卡驱动,系统在跑满流量时会突然失去响应。一开始以为是中断风暴,后来通过串口控制台看到内核日志,才发现是死锁:驱动的主处理路径持有一把自旋锁,此时网卡产生中断,中断处理程序里又尝试获取同一把锁,而主处理路径已经被中断打断,永远无法释放锁。
这个问题的根源非常典型。解决方法有两个:一是驱动里不要用普通的spin_lock,而是用spin_lock_irqsave把中断关掉;二是把中断处理程序改成只能获取锁的_bh版本,用下半部机制把真正加锁的逻辑延后到软中断中执行。无论哪种方式,核心原则都是:如果同一个 CPU 上可能发生上下文重叠的临界区,必须保证它们不会交叉获取同一把锁。
队列自旋锁本身并不能帮你挡住这种玩法,它只是保证“锁变量”本身不会崩溃,但逻辑上的死锁依然会发生。这也是为什么内核文档反复强调:自旋锁的正确性,九成在调用者的使用姿势,一成在锁的实现。
5.2 案例二:伪共享带来的隐性性能劣化
另一个让我印象深刻的问题是伪共享。当时我在写一个多线程的统计模块,每个线程维护一个本地计数器,最后再合并到全局结构里。我起初把所有计数器的值放在一个连续数组中,结果性能一直上不去。用 perf 看缓存未命中率,发现各个 CPU 之间存在频繁的缓存行同步。
这其实是典型的伪共享场景:两个线程虽然访问的是不同变量,但这些变量恰好落在同一个 64 字节缓存行里。每次任一线程写自己的计数器时,都会使整个缓存行失效,迫使其他线程重新加载。后来我给每个计数器的起始地址做了 64 字节对齐,或者直接把变量填充到一个完整的缓存行大小,性能立刻回升。
队列自旋锁的 per-CPU 节点也遵循同样的道理。这就是为什么源码里会看到节点之间有____cacheline_aligned这样的宏。如果你在写自己的并发数据结构,一定要记住亚线级的“变量分离”和无形的“缓存行罚单”。
5.3 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 加锁后长时间不返回 | 持锁者被中断打断或陷入死循环 | 检查是否存在同 CPU 重复加锁、是否使用irqsave变体 |
| lockdep 报 invalid lock context | 锁使用场景不合适,比如中断上下文用普通 spin_lock | 改用spin_lock_irqsave或下半部机制 |
| 高竞争下性能急剧下降 | 临界区过大或锁粒度太粗 | 缩小临界区、拆分锁、考虑 RCU 或者读写锁 |
| 多线程计数器性能差 | 伪共享 | 缓存行对齐,隔离热变量 |
| 虚拟机上自旋严重浪费 CPU | 缺少半虚拟化支持 | 开启CONFIG_PARAVIRT_SPINLOCKS |
5.4 内核对锁调优的几条经验法则
我在 code review 时经常提醒团队,锁的选取要按场景来,而不是看哪个锁“最新”就用哪个。队列自旋锁当然是优秀的设计,但它也不是万能的。如果临界区包含大量 I/O 操作或者复杂的计算,即便排队机制再高效,锁本身也会成为串行瓶颈。此时需要考虑:
- 用读写锁分离读者和写者,前提是读多写少;
- 用 RCU 让读者几乎不需要加锁,但写者必须承担更重的同步成本;
- 用 per-CPU 数据尽量消除共享状态,让锁根本不需要存在;
- 如果内核版本足够新,也可以关注
ww_mutex、futex等更上层机制。
队列自旋锁真正发力的领域,是那些短小精悍、高频访问的临界区。调度器、文件系统索引节点锁、内存管理中的某些分配路径,这些地方才是它的主场。理解了这一点,你在系统设计时就不会走偏。
6. 写在最后:我依然会反复阅读的两个文件
每次有新同事加入内核开发组,我都会推荐他们先去读两个文件:include/asm-generic/qspinlock_types.h和kernel/locking/qspinlock.c。前者让你看到锁的数据结构是多么紧凑,后者让你理解一个简单的“排队”如何在并发环境下变成一门艺术。
这些年我越来越觉得,内核同步原语的精髓不在于某个原子指令多漂亮,而在于它如何从全局视角管理缓存一致性、调度延迟和公平性。队列自旋锁把这些因素揉进了一个 32 位的整数里,实在值得反复揣摩。如果你正在学习内核,建议不只停留在 API 层面,而是亲手编译一个带调试信息的内核,在queued_spin_lock_slowpath里加几个trace_printk,然后跑一个高竞争的基准程序,看看加锁过程到底经历了多少次节点跳转。这种动手过程带来的理解,远胜于我在此写下的任何文字。