去年给一套数据库中间件做压测时,遇到一个特别典型的现象:服务从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-7 | locked | 锁是否被持有 |
| bit 8-15 | pending | 是否有等待者处于“待定”状态 |
| bit 16-31 | tail | 队列尾部节点的位置信息,其中低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 path
4.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 实测中需要注意的坑
第一,务必开启lockdep(CONFIG_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的延迟回收思想,会发现很多底层逻辑是相通的:尽量少动共享状态,尽量让局部热点留在本地,尽量把通知精确到真正需要的人。这套思路放在分布式系统、数据库事务的性能优化上,同样成立。