摸过内核并发编程或者多线程高并发开发的人,对自旋锁这个原语应该都不陌生。Linux 在 4.2 版本把 x86、arm64 等主流架构的 spinlock 默认实现从 Ticket Lock 换成了队列自旋锁(Queued Spin Lock),就是为了根治它祖传的缓存行颠簸毛病。这篇文章我想把 qspinlock 的来龙去脉讲透:它解决什么问题、底层怎么设计、加锁解锁的完整流程,以及我实际调内核锁竞争时用到的观测方法。不管你是在写内核模块、搞嵌入式,还是单纯想搞懂高性能同步机制,这篇应该都能给你省下不少啃源码的时间。
1. 从 Ticket Lock 到 Queued Spin Lock:一个性能问题的演进史
坦白讲,任何一种同步原语的设计都不是凭空冒出来的。qspinlock 之所以能在今天成为内核主流架构默认的 spinlock 实现,是因为它精准踩中了多核时代自旋锁面临的最痛性能问题:缓存一致性协议下的“惊群”效应。先花点时间把这个背景讲清楚,后面看代码才知道每个字段、每个分支是干什么的。
1.1 传统自旋锁的天生短板:缓存行颠簸
最简单的自旋锁实现,就是用一个原子变量 + CAS 循环:
while (atomic_cmpxchg_acquire(&lock->val, 0, 1) != 0) cpu_relax();单核或双核下这套东西勉强能用,但只要核数一多,问题就出来了。所有等待锁的 CPU 都在不停执行 cmpxchg,这意味着它们都在反复读同一个内存地址,也就是同一个缓存行。多核 CPU 通过缓存一致性协议(比如 x86 的 MESI/MESIF,ARM 的 MoESI)维护这个缓存行的状态,当一个 CPU 成功持有锁、其他 CPU 的缓存行全部失效时,下一步就是所有竞争者同时重试,又是新一轮的 RFO(Read For Ownership)广播。
这个现象在业界有个很形象的说法:cache line bouncing,缓存行颠簸。想象一群人站在一扇门口,门开的一瞬间所有人都挤上去,但最终只有一个人能进去,剩下的人被弹回来,再等下一次开门,再挤一次。核数越多,这种碰撞越严重,最坏情况下锁竞争带来的开销能拖垮整个系统的扩展性。
另一个隐藏问题是公平性缺失。用 CAS 实现的自旋锁不保证公平,先到的竞争者不一定先拿到锁。极端情况下,一个 CPU 可能一直在等锁,而其他 CPU 反复抢占成功,这就是所谓的“饥饿”问题。虽然饥饿在单次短临界区中不常发生,但在高竞争场景下确实会出现延迟抖动。
1.2 Ticket Lock 保证公平但没救得了性能
Linux 在切换到 qspinlock 之前,x86 上使用的是 Ticket Lock(排队自旋锁)。它的思路很直接:锁内部维护两个计数器,next 和 owner。新来的竞争者原子地获取一个 ticket(next 的值,然后 next++),同时把自己的 ticket 和当前 owner 比较,两者相等就说明轮到它持有锁了。释放锁时 owner++。这保证了严格的 FIFO 顺序,谁先来谁先拿锁,公平性得到了保证。
但 Ticket Lock 有一个致命问题:所有等待者都在轮询同一个 owner 字段。哪怕它们已经拿到了自己的 ticket,也依然要不停地读 owner 这个共享变量。当锁释放、owner 加一的那一瞬间,所有等待者的缓存行都会失效,然后大家一窝蜂地重试。也就是说,Ticket Lock 只是解决了“谁下一个进门”的问题,却没能解决“门开时所有人都涌上来”的问题。缓存行颠簸依旧,只是拥挤的时机被推迟到了每个 ticket 被叫号的瞬间。在高竞争场景下,这个开销依然可观。
1.3 队列化思想的引入:MCS 锁
上世纪 90 年代,John Mellor-Crummey 和 Michael Scott 提出了 MCS 锁(Mellor-Crummey and Scott lock),核心思想彻底颠覆了传统自旋锁的模型:每个等待者不再自旋在锁变量上,而是自旋在自己的本地节点上。每个节点是一个 mcs_spinlock 结构,节点之间通过 next 指针串成一条 FIFO 队列。当锁持有者释放锁时,它只需要找到队头节点并把队头节点的 locked 字段置 1,队头 CPU 就能立刻感知到,其他 CPU 根本不知道锁已经释放了。
这个设计从根本上解决了缓存行颠簸:每个 CPU 自旋的地址是各自私有的 mcs_spinlock 节点,缓存行互不干扰,释放锁时只影响一个后继节点的缓存行。公平性也天然得到保证,因为节点按到达顺序入队,先入队的先被唤醒。
qspinlock 的漂亮之处在于,它不是简单地把 MCS 锁原样搬进内核,而是把 MCS 队列和一个小型的“快速路径”结合了起来:无竞争时用一次 cmpxchg 解决问题,有少量竞争时用一个 pending 位容纳一个等待者,只有竞争真正激烈时才退化为完整的 MCS 队列。这三种路径叠在一起,就是 Queued Spin Lock 的核心设计。
2. 队列自旋锁核心结构与加锁流程
理解了设计动机,现在可以看具体实现了。Linux 的 qspinlock 并不是一个单一文件堆出来的功能,它分布在 include/asm-generic/qspinlock.h、include/asm-generic/qspinlock_types.h 和 kernel/locking/qspinlock.c 这几个地方。我建议你打开这几个文件跟着走,边看边在心里模拟状态流转。
2.1 一个原子变量装下整个世界
先看最核心的数据结构,在 include/asm-generic/qspinlock_types.h 中:
typedef struct qspinlock { union { atomic_t val; /* * By using the whole 2nd least significant byte for the * pending bit, we can allow better optimization of the lock * acquisition for the pending bit holder. */ struct { u8 locked; u8 pending; }; struct { u16 locked_pending; u16 tail; }; }; } arch_spinlock_t;整个锁只用一个 32 位原子变量 val,但这个变量被切成了三个逻辑段:bit 0 是 locked,表示锁是否被持有;bit 8 是 pending,表示是否有一个等待者在“加速通道”上排队;bit 16 到 bit 31 是 tail,表示当前 MCS 队列尾部的节点位置,其中又细分为 idx(2 位)和 CPU 编号(14 位)。
| 字段 | 位范围 | 作用 |
|---|---|---|
| locked | 0-7 | 锁是否被持有,1 表示持有 |
| pending | 8-15 | 是否存在一个额外的等待者 |
| tail.idx | 16-17 | 当前 CPU 使用的 MCS 节点索引 |
| tail.cpu | 18-31 | 队尾节点所在 CPU 编号(编码为 cpu+1) |
为什么 tail 要编码 CPU 编号?因为 Linux 给每个 CPU 都预分配了一组 mcs_spinlock 节点(per-CPU 变量),只要知道 CPU 编号和节点索引,就能通过 per-CPU 指针定位到具体的 MCS 节点,不需要在锁变量里存完整的指针,一个 32 位原子变量就足够容纳所有状态。
这里还有个细节:当 CONFIG_NR_CPUS 超过 16384 时,tail 的 idx 字段会被挤掉,变成 tail 高 16 位全给 CPU 编号用。这是 qspinlock 为超大规模系统留的一条后路,不过现实中单系统同时在线这么多核的场景极少,大多数情况下还是 idx + cpu 的组合。
2.2 快速路径:零竞争场景一把梭
qspinlock 的快速路径非常简单,在 include/asm-generic/qspinlock.h 中:
static __always_inline void queued_spin_lock(struct qspinlock *lock) { u32 val = 0; if (likely(atomic_try_cmpxchg_acquire(&lock->val, &val, _Q_LOCKED_VAL))) return; queued_spin_lock_slowpath(lock, val); }这段代码的含义是:如果锁当前值 val 为 0,即锁完全空闲,也没有任何等待者,那就直接把 locked 位设为 1,加锁成功返回。注意 atomic_try_cmpxchg_acquire 这个操作,如果 cmpxchg 失败,val 会被更新为当前锁的实际值,然后带着这个实际值进入慢路径。
这里最妙的是,快速路径没有额外读一次锁变量。如果没有竞争,一次 cmpxchg 加上一个分支就完成了整个加锁操作,开销极其可控。这也是 qspinlock 能在绝大多数无竞争场景下保持和传统自旋锁几乎相同性能的原因。
2.3 慢路径下的排队与交接
一旦快速路径失败,就进入 queued_spin_lock_slowpath,也就是 kernel/locking/qspinlock.c 中最核心的函数。这里的状态流转可以用几个关键分支来理解。
第一步,如果锁此刻实际上已经空闲(val 为 0,可能刚被释放),尝试再次用 cmpxchg 获取锁:
if (val == 0) { if (atomic_try_cmpxchg_acquire(&lock->val, &val, _Q_LOCKED_VAL)) return; }这个分支处理的是“快速路径失败后,锁恰好被释放了”的窗口期。如果这里也失败了,说明确实有人在竞争。
第二步,如果当前锁值只有 locked 位,说明没有 pending 等待者,也没有 MCS 队列,那么尝试设置 pending 位,成为“第二个等待者”:
if (val == _Q_LOCKED_VAL) { val = atomic_cmpxchg_acquire(&lock->val, val, val | _Q_PENDING_VAL); if (val == _Q_LOCKED_VAL) { atomic_cond_read_relaxed(&lock->val, !(VAL & _Q_LOCKED_MASK)); val = atomic_cmpxchg_acquire(&lock->val, _Q_PENDING_VAL, _Q_LOCKED_VAL); if (val == _Q_PENDING_VAL) return; /* * 如果 cmpxchg 失败,说明又有新的等待者插队, * 只能放弃 pending 这条快路,进入队列。 */ goto queue; } }这是 qspinlock 一个非常巧妙的设计。传统自旋锁在竞争到来时只能直接把所有等待者都塞进队列,而 qspinlock 用 pending 位开了一个“只容一人的快速通道”。当锁被持有、且只有一个人等待时,这个人设置 pending 位,然后自旋等待锁释放。锁释放时,把 pending 和 locked 同时清零,pending 等待者通过一个 cmpxchg 把 pending 变成 locked,直接拿到锁,完全不需要进 MCS 队列,也避免了昂贵的队列节点操作。
只有当竞争非常激烈,连 pending 位都被占用时,新的竞争者才会走 queue 分支,进入真正的 MCS 队列。这套分层设计保证了一个精妙的性能特性:竞争越少,开销越低;越激烈的竞争,锁越能通过队列把缓存行颠簸限制在单点。
2.4 排队时的 MCS 节点选择
进入 queue 分支后,第一步是获取当前 CPU 的 MCS 节点,相关定义在 kernel/locking/qspinlock.c 和 mcs_spinlock.h 中:
static DEFINE_PER_CPU_ALIGNED(struct mcs_spinlock, mcs_nodes[4]);每个 CPU 拥有 4 个 mcs_spinlock 节点。为什么是 4 个?原因主要在于嵌套场景:一个 CPU 可能先在一把锁的 MCS 队列中等待,然后由于中断或抢占又去获取另一把锁,这时它就需要第二个节点。4 个节点给常见的嵌套层次留足了空间,同时 tail 的 idx 字段正好是 2 位,可以对应 0-3 四个索引。
排队的核心逻辑简化后大致是:
n = this_cpu_ptr(&qnodes[0].mcs); idx = n->count++; tail = encode_tail(smp_processor_id(), idx); node = MCS_NODE(idx); node->locked = 0; node->next = NULL; old = xchg_tail(lock, tail); if (old & _Q_TAIL_MASK) { prev = decode_tail(old); WRITE_ONCE(prev->next, node); arch_mcs_spin_lock_contended(&node->locked); } /* 等锁释放,拿锁,处理 tail */这里每次排队会使用不同的 idx,目的是避免同一个 CPU 连续两次排队时复用同一个节点,防止旧状态干扰新加锁流程。xchg_tail 原子地把锁的 tail 更新为新队尾,返回旧队尾。如果旧队尾存在,就把新节点挂到旧队尾的 next 上,形成队列;然后新节点开始自旋等待自己的 locked 字段变成 1,也就是等待前驱节点释放。如果旧队尾不存在,说明当前节点是队头,它直接自旋等待锁释放即可。
从状态机的角度概括,qspinlock 的加锁路径经历了三种递进状态:零竞争走 cmpxchg 快路,有一个竞争者走 pending 加速通道,多个竞争者才走 MCS 队列。这套分层逻辑既保证了低竞争下的效率,也保证了高竞争下的可扩展性。
3. 解锁的原语设计:释放如何激活等待链
很多人讲 qspinlock 只讲加锁,不讲解锁,我觉得这有点可惜。解锁的流程虽然短,但它直接决定了“锁释放后谁被唤醒、缓存行如何变化”这些性能关键点。
3.1 快速解锁:只有一条简单指令
qspinlock 的解锁函数同样在 include/asm-generic/qspinlock.h 中:
static __always_inline void queued_spin_unlock(struct qspinlock *lock) { smp_store_release(&lock->locked, 0); }这个函数就干了一件事:把 locked 位清零。smp_store_release 保证了内存序:在解锁之前对共享数据的修改,一定会在锁释放后对其他 CPU 可见。换句话说,它是把“临界区结束”这个语义用一条 store 指令表达出来的。
这里注意,解锁操作并没有直接去唤醒 MCS 队头。它只是把锁标记为释放,真正“醒”过来的是那些自旋等待的竞争者,它们早就等在那里了。这个设计有意为之:与其由解锁者去做一堆复杂的指针操作,不如让等待者自己去发现锁已释放。这样解锁的延迟非常可控,不会因为等待队列复杂而拖慢持有者的退出速度。
3.2 三种等待者如何被激活
我在前面提到,qspinlock 中存在三条等待路径:快速路径失败后再次尝试的竞争者、pending 位的等待者、MCS 队列中的队头。解锁时,这三类等待者的激活机制各不相同。
快速路径竞争者其实没有真正的等待过程。它们发现锁释放后,下一次 retry 就能通过 cmpxchg 成功获取锁。这个路径只适用于“锁已经空闲但竞争刚发生”的极短时间内,本质上跟无竞争场景的加锁一样。
pending 等待者被激活的方式是:它们自旋在 atomic_cond_read_relaxed 上,持续观测锁的 locked 位。一旦 locked 变为 0,它们是唯一能立即获取锁的等待者,因为此时锁值应该是 _Q_PENDING_VAL,通过一次 cmpxchg 把 pending 位清除并设置 locked 位,就完成了锁的获取。
MCS 队头的激活方式更隐蔽。这个路径最为关键。MCS 队头会自旋等待锁值中 locked 和 pending 均为 0,然后尝试用 cmpxchg 把锁值从“完全空闲”设为 _Q_LOCKED_VAL。注意,队头获取锁的同时还要处理 tail。如果自己就是队尾,直接通过 cmpxchg 将 tail 置 0 即可;如果后面还有节点,则需要把 tail 保留并传递到正确的后继位置。
这里我补充一个实际经验:很多人刚开始读 qspinlock 源码时,会困惑“队头拿到锁后 tail 怎么处理”。其实关键在 atomic_cmpxchg 的参数里。队头会先读取当前锁值 val,此时如果 tail 指向它自己,就说明它是队尾,可以直接把 val 中的 tail 清掉再 cmpxchg;如果尾指针指向后边的节点,那么不能清除 tail,必须把整个 val 的 tail 段保留,同时设置 locked 位。这个逻辑看起来绕,但只要明白 cmpxchg 是“CAS 整个 32 位值”就能理解:它是在一个原子操作内同时更新锁定状态和队尾状态。
3.3 内存屏障与同步语义
最后聊一下内存屏障。qspinlock 的加锁使用了 acquire 语义,解锁使用了 release 语义。在 x86 上,xchg、cmpxchg 等原子操作天然具有全屏障的效果,所以额外的内存屏障指令往往可以省略;但在 ARM64 等弱内存序架构上,acquire/release 语义需要通过指令或编译器屏障来保证。
对于内核开发者来说,不需要在代码里手动加 barrier,使用原生的 queued_spin_lock / queued_spin_unlock 就能获得正确性保证。真正要小心的是:如果一个临界区里使用了自定义的原子操作或内存屏障,破坏了 acquire/release 的配对关系,就可能引入隐蔽的并发 bug。这一点我在第 5 章的排障实录里会展开说。
4. 性能优化背后的硬件考量与适用场景
聊完实现细节,有必要退一步说说硬件层面的原理。qspinlock 的每一项设计,几乎都能在缓存一致性协议上找到对应的动机。
4.1 缓存一致性协议下的锁竞争代价
在 x86 的 MESIF 协议中,缓存行可以处于 Modified、Exclusive、Shared、Invalid、Forward 等状态。多核竞争一个锁变量时,锁变量的缓存行会在多个核的 L1/L2 缓存之间来回跳动。每次锁状态变更,都可能触发一次从 Invalid 到 Modified 的转换,这需要发送消息让其他持有 Shared 副本的缓存行失效,同时等待响应。
如果锁变量被几十个核争抢,这一轮消息风暴带来的总线流量是非常可观的。最坏情况下,锁竞争的开销与临界区本身的开销相比能差两个数量级。qspinlock 的思路是把共享变量的访问尽量降到最低:无竞争时只碰一次锁变量,有竞争时让每个 CPU 自旋在自己私有的 mcs_spinlock 节点上,锁变量本身只在入队和出队时被更新。
4.2 每个排队者自旋在本地节点为什么快
MCS 队列能消除“惊群”的关键在于,每个排队者等待的是一个不在共享缓存行上的字段:node->locked。它与锁变量无关,其他 CPU 不会去写它,唯一会写它的是自己的前驱节点。所以,每个等待者的自旋循环只在自己的 L1/L2 缓存行上转圈,没有外部流量干扰。当前驱释放锁时,它只会把队头的 node->locked 置 1,这个写操作只会影响队头 CPU 的缓存行,其他排队者的缓存行完全不受影响。
用一个生活化的类比:传统自旋锁是一间教室里的一个公共铃铛,所有人都盯着铃铛,铃铛一响大家都冲向门口;MCS 队列则像银行排队,每个人只关注前面那个人有没有办完业务,自己站在原地不动,只有前一个人离开时才会提醒下一个人。这种“逐位交接”的机制天然把唤醒开销从 O(n) 降到了 O(1)。
4.3 配置项与适用场景分析
在实际项目中,真正到了性能调优阶段,相关的内核配置选项就变得极其关键。如果要真正让队头错误地进入 MCS 队列而不触发错误,我们一般很少需要手改 qspinlock 的逻辑,但有几个配置项会对它的行为有直接影响。
首先是 CONFIG_QUEUED_SPINLOCKS,这个开关在 x86、arm64 等架构下默认开启,使用 qspinlock 作为 spinlock 实现。如果你的发布内核架构比较特殊,比如某些老旧的嵌入式架构,可能需要确认是否启用了该配置。
其次是 CONFIG_PARAVIRT_SPINLOCKS,虚拟化场景中相当关键。在虚拟机里,qspinlock 的 MCS 等待者可能在宿主机上被调度出去,导致自旋等待变成无意义地占着 CPU。paravirt spinlock 会在锁竞争中加入 halt 与 kick 机制,让等待的 vCPU 主动让出 CPU,等待锁释放时再被唤醒。这个配置在云场景、虚拟化场景中非常影响性能,我建议在 QEMU/KVM 或 VMware 这类环境下跑负载,都尽量打开 CONFIG_PARAVIRT_SPINLOCKS。
再有就是 CONFIG_NR_CPUS,它决定了 tail 字段的编码方式。当 NR_CPUS 超过 16384 时,tail 会失去 idx 字段,这种巨型服务器场景相对少见,但在规划大规模机器时有参考意义。
从适用场景来说,原子自旋锁适合临界区内执行时间很短的情形,临界区内一定不能睡眠、不能调用可能睡眠的函数(比如多数 kmalloc、msleep)。一旦临界区可能长时间占用锁,合理选择是 mutex、读写锁或者 RCU。我见过不少人为了追求“高性能”过度使用 spinlock,结果临界区里做了文件 I/O,系统直接死锁。这个坑我在第 5 章详细说。
5. 锁竞争观测与问题排查实录
知道 qspinlock 的原理,还得知道在实际系统里怎么观测它的行为。这里分享几个我常用的工具和排查思路。
5.1 用 /proc/lock_stat 查看锁竞争
内核提供了 CONFIG_LOCK_STAT,开启后可以通过 /proc/lock_stat 查看系统中锁的竞争情况。使用非常简单:
# 先清零统计 echo 0 > /proc/sys/kernel/lock_stat # 跑你的压力负载 # 打开统计 echo 1 > /proc/sys/kernel/lock_stat # 查看结果 cat /proc/lock_stat输出里会列出各个锁的名称、竞争次数、等待时间、持有时间等数据。如果某个自旋锁的 contention 次数特别高,wait time 平均值很大,就要考虑它是否是性能瓶颈。在排查时,我会特别关注高竞争的锁对应的临界区:是不是临界区太长、访问频率太高,或者是否需要拆锁、分片锁、读写锁等替代方案。
注意,/proc/lock_stat 只能在开启了 CONFIG_LOCK_STAT 的内核中使用,而且它在统计本身会增加一定开销,不建议在追求极限性能的长时间运行环境中一直开启。跑完测试记得关掉。
5.2 用 perf lock 分析锁事件
perf 工具也提供了锁相关的事件分析,最常用的是 perf lock:
perf lock record -a -- ./your_benchmark perf lock reportperf lock 利用内核的 lock 事件(例如 lock:acquire、lock:release)记录锁操作的时间信息,可以给出每个锁的持有时间、等待时间和竞争分布。相比 lock_stat,perf lock 的开销更低,输出也更直观,适合做整体概览。
不过 perf lock 只能看到启用了 tracepoint 的通用锁事件,对于 qspinlock 这种更底层的自旋锁,也要结合内核版本对相应 tracepoint 的覆盖情况判断结果。
5.3 常见误区与排查经验
这里整理几个我在实际项目中踩过、或者帮别人排查过的典型问题,都可以直接用“问题 + 原因 + 解法”对照看。
| 现象 | 可能原因 | 排查与处理 |
|---|---|---|
| 系统启动后频繁卡死,加锁处无响应 | 临界区依赖的代码路径中调用了可能睡眠的函数,比如 kmalloc(GFP_KERNEL)、msleep | 自查临界区所有函数调用,换成不睡眠的原子版本,或者改用 mutex |
| 多核性能无法线性扩展,perf top 里大量 qspinlock 相关开销 | 锁竞争激烈,缓存行颠簸严重 | 用 perf lock 定位热点锁,考虑拆分锁、引入 RCU 或无锁化结构 |
| 虚拟机中锁等待时间异常高 | 缺少 paravirt spinlock 支持,vCPU 被调度后仍在自旋 | 开启 CONFIG_PARAVIRT_SPINLOCKS,观察效果 |
| 内核崩溃时显示 spinlock 相关 warning | 可能锁未正确初始化,或释放时锁已被修改 | 开启 CONFIG_DEBUG_SPINLOCK、CONFIG_PROVE_LOCKING(lockdep)排查 |
特别强调一下 lockdep。在内核开发中,CONFIG_PROVE_LOCKING 几乎是必备的调试利器,它能在检测到潜在死锁(比如 ABBA 死锁、递归加锁、锁顺序颠倒)时直接打印警告并阻塞。我在开发自定义同步逻辑时,总是先开着 lockdep 跑一遍测试,确认没有锁依赖问题,再关闭它去压测性能。这个习惯帮我避免了很多隐蔽的锁序 bug。
另一个容易被坑的点是 PREEMPT_RT 补丁内核。在实时内核中,常规自旋锁会被转化为基于 rtmutex 的实现,qspinlock 的部分特性会被绕过以避免优先级反转问题。如果你在实时性要求很高的环境中开发,需要确认你依赖的是哪一种锁行为,别再拿普通自旋锁的性能表现去套实时内核。
最后分享一个排查锁竞争时的小技巧:观察锁等待的时间分布,不要只看平均值。qspinlock 在低竞争和高竞争下等待时间差异极大,平均时间往往把快速路径的成功次数和慢路径的高延迟混在一起。用直方图或者 p99、p999 分位数来看锁等待时间,比只看平均更有诊断价值。
我个人在实际调优中的体会是,qspinlock 最值得学的并不是那条慢路径里的复杂指针操作,而是它在设计上体现的“分路径处理”哲学:绝大多数场景走最便宜的路,极少数高竞争场景才付出更大的代价。这种循序渐进、延迟均摊的思路,同样适用于分布式锁、数据库事务冲突处理、甚至业务系统的流量治理。如果你要为一个高并发模块设计锁策略,可以反复回味 qspinlock 的这套快速路径 + 加速通道 + 队列退让的思路——它不会让你失望。