资讯动态

Linux内核队列自旋锁(qspinlock)设计原理与性能调优深度解析

发布时间:2026/9/19 15:26:54 来源:尧图企业网站定制
摸过内核并发编程或者多线程高并发开发的人对自旋锁这个原语应该都不陌生。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/MESIFARM 的 MoESI维护这个缓存行的状态当一个 CPU 成功持有锁、其他 CPU 的缓存行全部失效时下一步就是所有竞争者同时重试又是新一轮的 RFORead For Ownership广播。这个现象在业界有个很形象的说法cache line bouncing缓存行颠簸。想象一群人站在一扇门口门开的一瞬间所有人都挤上去但最终只有一个人能进去剩下的人被弹回来再等下一次开门再挤一次。核数越多这种碰撞越严重最坏情况下锁竞争带来的开销能拖垮整个系统的扩展性。另一个隐藏问题是公平性缺失。用 CAS 实现的自旋锁不保证公平先到的竞争者不一定先拿到锁。极端情况下一个 CPU 可能一直在等锁而其他 CPU 反复抢占成功这就是所谓的“饥饿”问题。虽然饥饿在单次短临界区中不常发生但在高竞争场景下确实会出现延迟抖动。1.2 Ticket Lock 保证公平但没救得了性能Linux 在切换到 qspinlock 之前x86 上使用的是 Ticket Lock排队自旋锁。它的思路很直接锁内部维护两个计数器next 和 owner。新来的竞争者原子地获取一个 ticketnext 的值然后 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 队列尾部的节点位置其中又细分为 idx2 位和 CPU 编号14 位。字段位范围作用locked0-7锁是否被持有1 表示持有pending8-15是否存在一个额外的等待者tail.idx16-17当前 CPU 使用的 MCS 节点索引tail.cpu18-31队尾节点所在 CPU 编号编码为 cpu1为什么 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 lockperf lock record -a -- ./your_benchmark perf lock reportperf lock 利用内核的 lock 事件例如 lock:acquire、lock:release记录锁操作的时间信息可以给出每个锁的持有时间、等待时间和竞争分布。相比 lock_statperf 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_LOCKINGlockdep排查特别强调一下 lockdep。在内核开发中CONFIG_PROVE_LOCKING 几乎是必备的调试利器它能在检测到潜在死锁比如 ABBA 死锁、递归加锁、锁顺序颠倒时直接打印警告并阻塞。我在开发自定义同步逻辑时总是先开着 lockdep 跑一遍测试确认没有锁依赖问题再关闭它去压测性能。这个习惯帮我避免了很多隐蔽的锁序 bug。另一个容易被坑的点是 PREEMPT_RT 补丁内核。在实时内核中常规自旋锁会被转化为基于 rtmutex 的实现qspinlock 的部分特性会被绕过以避免优先级反转问题。如果你在实时性要求很高的环境中开发需要确认你依赖的是哪一种锁行为别再拿普通自旋锁的性能表现去套实时内核。最后分享一个排查锁竞争时的小技巧观察锁等待的时间分布不要只看平均值。qspinlock 在低竞争和高竞争下等待时间差异极大平均时间往往把快速路径的成功次数和慢路径的高延迟混在一起。用直方图或者 p99、p999 分位数来看锁等待时间比只看平均更有诊断价值。我个人在实际调优中的体会是qspinlock 最值得学的并不是那条慢路径里的复杂指针操作而是它在设计上体现的“分路径处理”哲学绝大多数场景走最便宜的路极少数高竞争场景才付出更大的代价。这种循序渐进、延迟均摊的思路同样适用于分布式锁、数据库事务冲突处理、甚至业务系统的流量治理。如果你要为一个高并发模块设计锁策略可以反复回味 qspinlock 的这套快速路径 加速通道 队列退让的思路——它不会让你失望。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价