做嵌入式Linux也有几个年头了自旋锁spinlock是我在内核驱动开发和并发调试里打交道最多的同步原语之一。很多朋友一提到自旋锁第一反应就是忙等锁觉得它简单不就是在临界区外面转圈吗可真到项目里一个spin_lock用错地方轻则CPU占用率飙到100%重则整机死机、看门狗复位。而且这类问题往往不是必现的压力一大就冒出来排查起来相当考验功底。这篇文章我想把自旋锁这件事从头到尾捋一遍为什么内核需要它、它底层是怎么用原子指令和缓存一致性协议实现的、内核里那堆spinlock家族API到底该怎么选以及真实项目中我们是怎么一步步排查一起自旋锁死锁的。内容的核心还是围绕自旋锁的底层原理展开兼顾linux底层原理和linux运维故障案例这两个容易被问到、也容易被考到的方向。无论你是刚接触内核的初学者还是在做嵌入式Linux项目的开发者或者是准备内核方向面试的人这篇都值得从头看到尾。1. 自旋锁到底在锁什么从临界区保护的第一个教训说起很多内核新手第一次接触自旋锁是在《Linux内核设计与实现》或者《深入理解Linux内核》里读到那几句经典的话自旋锁是一个互斥设备最多只能被一个执行线程持有如果一个执行线程试图获得一个被争用的自旋锁那么该线程就会一直忙等待直到锁可用。听起来很简单但忙等待三个字背后的门道远比字面意思复杂。1.1 临界区并发代码的事故高发区先退一步说说自旋锁到底在保护什么。内核里多个执行流——进程上下文、中断上下文、软中断、多核CPU上的不同进程——可能同时访问同一份共享数据。比如一个驱动里维护的设备状态结构体一个全局计数器一张缓冲队列。只要有两个执行流同时对这份数据做读-修改-写数据就会错乱。这里有个特别容易误会的点临界区critical section不是代码段而是访问共享数据的那一小段代码。保护临界区本质上是保护数据不是保护代码。很多刚入行的驱动工程师把锁加在函数外面函数里面一大坨分支逻辑全包进去临界区拖得很长锁的粒度粗得一塌糊涂。后面谈并发性能优化的时候这都是要还的债。我的习惯是写任何会访问共享数据的代码之前先在脑子里画一遍执行流哪些路径会进到这里这些路径分别在什么上下文里有没有可能同时进来画清楚了再决定用什么锁、锁的范围有多大。1.2 自旋锁 vs 互斥锁用CPU时间换响应速度自旋锁和互斥锁mutex是内核里两种风格截然不同的锁。互斥锁在拿不到锁的时候会把当前任务放到等待队列里主动调度出去让出CPU等持有者释放锁的时候再唤醒。这个过程叫睡眠等待。自旋锁则完全不同拿不到锁我就站在门口死等不停地检查锁的状态直到拿到为止。用生活化的方式类比你去一家热门餐厅吃饭。互斥锁的行为是取个号坐到大堂沙发上刷手机等叫号自旋锁则直接站在出餐口旁边盯着厨师他手一停你就往前凑。前者不占着门口但需要服务员来回叫号后者CPU空转但延迟极低、立刻就能接手。这两种设计各有各的适用场景。我通常用一个简单的判断标准来选型维度自旋锁互斥锁拿不到锁时的行为忙等、空转CPU睡眠、让出CPU临界区耗时必须极短微秒级可以较长毫秒级能否在中断上下文使用可以关中断变体不可以是否会发生进程调度不会会内核配置选项影响受SMP/UP影响受内核抢占配置影响为什么自旋锁能在中断上下文用、互斥锁不行因为中断上下文不是一个进程它不能睡眠睡眠这个概念在中断里有很严重的后果——调度器无处安放这个任务。所以自旋锁在嵌入式Linux项目里最常见的使用场景就是驱动里的寄存器操作、数据队列的入队出队、状态标志位的翻转这一类微秒级的短临界区。一旦临界区里出现了耗时的IO操作、大块内存拷贝、可能触发调度的函数调用自旋锁就进入了危险区这部分的坑我在后面第四章会详细讲。2. 自旋锁的底层机制从原子指令到缓存一致性面试的时候被问到自旋锁的原理很多人的回答停留在一个while循环不停测一个标志位这个层面。这个回答方向对但太浅了。真正的自旋锁实现至少要牵扯三个层面的问题怎么保证标志位的检测和置位是原子的、怎么保证多核之间能看到彼此对标志位的修改、怎么防止编译器和CPU重排指令把临界区的代码挪到锁外面去。依次讲。2.1 原子操作CPU如何保证读改写不被拆散自旋锁的核心动作就一句话看看锁是不是空闲如果是把它置成占用状态。这在逻辑上是个读-改-写三步操作。问题在于在SMP系统上两个CPU可能同时读到锁空闲然后同时把它置为占用——如果没有硬件级的原子保证锁就直接失效了。所以自旋锁的底层必然依赖CPU提供的原子指令。x86上最典型的是xchg交换指令它能把寄存器的值和内存地址的值一步交换整个过程对别的CPU不可见。ARM上的实现思路略有差异ARMv8架构上通常使用LDXRLoad Exclusive Register和STXRStore Exclusive Register这对独占加载/存储指令来实现。这个大概的逻辑是先用LDXR读标志位并打上独占标记修改后尝试用STXR写回如果写回失败说明这个地址被别的CPU动过了就重试整个循环。讲这些的时候我常跟团队说一句话**自旋锁不是靠软件里那个循环实现的循环只是重试机制真正的锁语义是硬件原子指令给的。**没有原子指令你写再多层while也白搭。2.2 缓存一致性协议与自旋锁的可见性原子指令解决的是原子性但还没解决可见性问题。现代CPU每个核都有自己的一级二级缓存CPU 0改了锁标志位这个修改可能还躺在CPU 0的缓存里CPU 1如果直接读自己的缓存看到的还是老值。这个问题由缓存一致性协议来解决。以x86的MESI协议为例每个缓存行有四种状态Modified已修改、Exclusive独占、Shared共享、Invalid无效。当一个CPU对某个地址做原子写操作时协议会把这个地址对应的缓存行在其他CPU核上的副本置为Invalid状态其他核再读的时候就会触发缓存未命中从内存或其他核的缓存里拿最新值。这个过程对自旋锁的性能影响非常大也是很多性能问题的根源。锁变量放在哪个缓存行里这个缓存行在多个CPU之间反复切换状态会产生大量的缓存一致性流量。这个问题在NUMA架构下尤其明显一个被多个CPU争用的自旋锁可能会成为整个系统性能的瓶颈。我在写多核并行程序时倾向于把频繁争用的锁变量和对立的缓存行对齐、让不同CPU各自偏向本地缓存这个细节在第四章还会提到。2.3 内存屏障编译器与CPU重排规则的博弈如果说原子性和缓存一致性是硬件问题那内存屏障memory barrier就是编译器和CPU共同制造的坑。自旋锁的本质是锁保护临界区意味着临界区里的内存操作不能跑到锁外面去。可是编译器和CPU为了优化性能是会把指令重排的。如果你不加任何屏障理论上CPU完全可以把临界区里对共享数据的写操作挪到释放锁的那条写操作之后执行——这样一来另一个CPU拿到了锁看到的却是没更新完的数据。所以自旋锁的实现里必然包含屏障指令。在Linux内核里spin_lock和spin_unlock的底层实现会生成对应的内存屏障指令阻止临界区代码越过锁的边界。x86上因为TSO内存模型相对较强某些屏障可以省略ARM这类弱内存模型架构上该加的屏障一条都不能少。这也是很多从x86平台移植到ARM平台的工程师栽跟头的地方。在x86上碰巧能跑的自旋锁代码到了ARM上可能直接出问题因为两边内存模型不一致。3. 内核里的自旋锁家族从raw_spinlock到读写锁与顺序锁真正翻开内核源码你会发现自旋锁不是一个孤零零的API而是一整个家族。spinlock_t、raw_spinlock_t、rwlock_t、seqlock_t还有带_bh、_irq、_irqsave后缀的各种变体。每个变体都不是摆设它们解决的是不同上下文下的同步需求。3.1 raw_spinlock与spinlock_tRT补丁带来的区分先讲一个很多刚接触内核的人会疑惑的点为什么有了raw_spinlock_t还要有spinlock_t这要从PREEMPT_RT补丁说起。实时Linux补丁的目标是降低内核的最大延迟让内核本身可以被抢占。在RT补丁里一个很重要的改动就是把绝大多数的自旋锁改造成了一种可睡眠的锁实际上底层换成了rtmutex机制。这个改动的结果就是spinlock_t在RT内核上其实不完全等同于原来那种纯粹的忙等锁。但问题是有些临界区处于绝对不允许睡眠的上下文——比如真正的硬中断处理、调度器内部、某些底层硬件操作的路径。这些地方不能用RT改造后的spinlock_t必须用不会变成可睡眠锁的原始自旋锁。内核于是把原来的实现保留为raw_spinlock_t而spinlock_t在RT内核下通过raw_spinlock_t来封装实现。在我们自己的BSP开发中判断标准很简单**如果代码会在硬中断上下文或者调度器内部执行用raw_spinlock_t普通驱动临界区直接用spinlock_t让内核根据配置自动选实现。**很多新人直接对着一堆类型做全局替换替换完系统跑着跑着就死锁多半是没搞明白这两者的区别。3.2 上下半部与bh变体为什么锁的API要分场景spin_lock_bh是另一个容易用错但又极其常见的变体。它的语义是获取自旋锁的同时关闭当前CPU上的软中断处理bottom half。为什么要这么设计考虑一个典型场景进程上下文里要读写一个网络协议栈的共享队列同时网卡中断触发的软中断比如NET_RX_SOFTIRQ也会操作这个队列。如果进程上下文只拿spin_lock保护队列操作那么它持锁期间软中断仍然会被调度执行而软中断里的代码也想要这把锁——同一CPU上的软中断可不管你有没有持锁它照样运行一旦它尝试获取同一把锁就是死锁。_bh变体能在加锁的同时把当前CPU的软中断关掉从根本上杜绝同一CPU上软中断来抢锁的可能。在驱动的下半部处理里这几乎是所有共享数据保护的标准姿势。我自己写驱动时只要临界区可能被软中断访问一律用spin_lock_bh而不是spin_lock省得后续排查莫名其妙的死锁。带_irq和_irqsave的变体处理的是硬中断场景。_irqsave会在加锁的同时保存中断状态并关中断在释放锁时恢复原状态。为什么需要保存原状态因为如果当前本身就处于关中断状态贸然打开中断反而是引入风险。这个变体在持锁路径要到硬中断上下文共享数据的场景里是必需品。3.3 读写自旋锁与顺序锁多读单写场景的优化如果临界区的访问模式是读多写少那普通的自旋锁就有点浪费了——多个读者之间完全可以并行进入临界区不需要互相排斥。rwlock_t就是为这种场景设计的它允许多个读者同时持有读锁写者必须独占。但读写锁有个经典问题写者饥饿。如果读者来得特别频繁写者可能长时间抢不到锁。内核还提供了seqlock_t顺序锁来应对另一种更极端的模式写者优先、操作非常快。顺序锁的做法是维护一个序列号写者进入时把序列号加一离开时再加一读者进入时记录序列号读完再检查序列号有没有变化变了就重读。顺序锁有个重要的使用前提临界区里的数据必须是读者能接受短暂不一致的。因为读者在读的过程中写者可能正在修改数据读者读到的可能是中间状态。如果数据之间有强一致性要求用顺序锁就是灾难。在真实项目里我见过把两个紧密关联的变量放在seqlock里保护结果读者频繁读到A是新值、B是旧值这种撕裂状态的案例最后只能换回普通自旋锁。这个教训说明一个道理并发原语的选型不是越高级越好而是要匹配数据访问的真实模式。4. 真实项目中的自旋锁事故一次死锁排查的完整链路理论说多了容易飘回到实际操作。今年上半年我在一个多核ARM平台项目上遇到了一起自旋锁死锁从出现到定位花了两天多。整个排查过程很典型我把链路完整记录下来对做嵌入式Linux和内核开发的朋友应该很有参考价值。4.1 故障表现与初步定位内核线程卡死的特征场景是一个外设驱动的数据通路改造。改造之后压测跑到高强度时系统的网络吞吐突然掉到接近0串口控制台敲什么命令都没反应但系统没有完全死机——LED心跳灯还在闪。用硬件看门狗的话可能要再过几十秒才复位。这是典型的内核线程卡死但中断还能响应的症状。注意这个细节如果连中断都完全瘫痪那多半是关中断后死锁如果中断还在跑、任务调度卡死更可能是进程上下文里某个锁被长时间占用导致所有相关任务阻塞。这两种故障特征对应的排查方向完全不同。当时我们第一步是确认具体是哪些任务卡住了。由于串口已经没法交互只能通过硬件调试器JTAG挂上去看寄存器状态或者干脆等看门狗复位之后抓/proc下的信息。运气好的是串口虽然没响应但另一个调试专用的UART口上内核hung_task检测机制还在工作——系统在一段时间内检测到某个内核线程处于D状态不可中断睡眠并且打印出了该线程的内核栈。4.2 排查工具与方法从lockdep到perf从栈里看到线程卡在spin_lock_irqsave上更准确地说它卡在某驱动的加锁函数里。看到这个信息我第一反应不是去看代码而是确认内核有没有开CONFIG_DEBUG_SPINLOCK和CONFIG_LOCKDEP——很遗憾release版本为了性能都没开。这是个非常重要的教训调试并发问题内核的锁调试选项比什么高级工具都管用。CONFIG_DEBUG_SPINLOCK能检查锁的未初始化、重复加锁、锁状态不一致等问题CONFIG_LOCKDEP更是能做静态的锁依赖检查在系统运行早期就把潜在的死锁环路报出来。我们的release版本没开这些选项导致很多问题等到线上压测才暴露。后续我把调试内核和release内核分开管理压测前期用调试内核跑稳定后再切release。lockdep不可用的情况下我们用的另一个有效手段是perf。perf record采样虽然抓不到内核栈里的精确锁信息但能看到高频热点在哪个函数。配合perf sched看调度事件基本能确认哪些任务在抢锁、锁在谁手里。这一轮下来我们把嫌疑锁定在一个驱动里的自旋锁上这个锁保护的是一块DMA描述符池。4.3 修复方案与常见误用清单定位到具体代码时问题一眼就看见了我们在持锁的临界区里调用了一个可能会睡眠的函数。具体是kmalloc(GFP_KERNEL)。在内存紧张时GFP_KERNEL的分配操作可能触发内存回收、阻塞等待这本质上就是一次隐式的睡眠。自旋锁的持有者是禁止睡眠的。一旦持锁路径上睡眠会发生什么持锁的任务A被调度出去锁一直没人释放别的CPU上想拿锁的任务B、C、D全在忙等如果系统里恰好有内核线程在等任务A完成某个动作那么任务A又可能永远等不到被唤醒——这就形成了一个多CPU之间、进程与内核线程之间的复合死锁。这个案例还叠加了软中断路径上的抢锁局面更复杂。修复方案本身不复杂把kmalloc从临界区里挪出来在加锁之前完成内存分配真正在锁内做的只有链表插入这个微秒级操作。如果分配失败直接在锁外返回错误。这个改法把临界区的耗时从几十微秒降到不到一微秒。复盘整个排查过程我总结了一份自旋锁误用的检查清单现在每次code review都拿它过一遍临界区里有没有调用可能睡眠的函数kmalloc(GFP_KERNEL)、copy_to_user、msleep、mutex_lock都是高危中断处理路径和进程路径是否共享数据如果共享是否用了_irq或_irqsave变体软中断路径是否访问同一把锁如果是进程上下文是否用了_bh变体有没有可能两个任务以不同顺序获取两把锁锁顺序不一致导致ABBA死锁持锁时间是否可能超过一个调度周期持锁时间越长系统延迟越大死锁概率越高。多核争用严重时锁的缓存行是否成为性能瓶颈5. 自旋锁的使用边界这些场景千万别用锁硬扛最后一部分聊点实战中最容易踩的边界问题。自旋锁不是不能用而是要在正确的场景里用。以下几个场景我全都见人在真实项目里踩过。5.1 临界区里有睡眠操作前文提到的kmalloc(GFP_KERNEL)只是众多睡眠函数中的一个。内核里究竟哪些函数会睡眠很多时候并不直观。printk在某些配置下可能触发调度do_gettimeofday在高精度模式下可能需要读取硬件并等待连spin_unlock以后紧接着调用up这个信号量操作都可能引入睡眠路径。我的一个底线原则是**自旋锁保护的临界区里除了简单的内存读写、寄存器操作、位图操作之外其他什么都别干。**如果临界区超过了一两百行代码或者里面出现了后缀为_locked以外的任何带操作语义的函数就要重新设计数据结构把真正必须互斥的部分压缩到最小。5.2 中断上下文里的死锁中断上下文使用自旋锁如果不配_irq或_irqsave死锁几乎是必然的。还是那个老场景CPU 0在进程上下文持有锁此刻中断来了CPU 0进去处理中断中断处理代码也想拿这把锁——同一个CPU上进程拿了锁中断又来抢这是自己等自己的死锁。解决方案是加锁时关中断。但关中断本身的代价不小它会推迟所有中断的响应所以临界区必须极短。如果中断处理里需要访问的数据比较复杂常见的做法是把工作量拆成两块硬中断里只做最必要的硬件操作剩下的事情委托给软中断或者workqueue在进程上下文用普通锁处理。5.3 双核争用与优先级反转自旋锁本身不参与调度拿不到锁的任务不会让出CPU。这就引出一个微妙的问题高优先级任务和低优先级任务争用同一把锁时可能出现高优先级任务在忙等低优先级任务持锁却因为各种原因没有被调度执行的情况。虽然Linux的调度器有一定的机制缓解优先级反转但自旋锁场景下长时间忙等浪费的CPU时间仍然不可忽视。多核平台还有一个隐藏的成本缓存行的颠簸。两个CPU反复争用同一把锁锁变量所在的缓存行会在两个核的缓存之间来回失效、重传这个流量比锁本身的指令消耗大得多。争用越激烈性能越差。如果锁是全局的、频繁争用的通常会考虑用per-CPU数据来消除争用或者用读写锁、RCU这类更适合并发读的机制替换。在做锁选型的时候我习惯画一张简单的决策表场景特征推荐的同步机制临界区极短硬中断路径访问spin_lock_irqsave临界区极短软中断路径访问spin_lock_bh临界区极短只有进程上下文访问spin_lock临界区较长进程上下文访问mutex读多写少数据结构可短暂不一致seqlock_t读多写少强一致要求rwlock_t或RCU每个CPU各自维护数据per-CPU变量无需加锁实际上我自己在驱动开发里用得最多的反而是spin_lock_bh和mutex的组合数据结构内部的关键链表用自旋锁保护外层业务逻辑用互斥锁串行化。把锁的粒度分好并发性能和数据安全都能兼顾。写在最后。自旋锁这门技术看起来只是一个很小的内核同步原语但把它彻底吃透你会发现它牵引出了原子指令、缓存一致性、内存屏障、内核抢占、上下文分类这一整条知识链。做内核开发这些年我最大的体会是并发问题的调试成本永远高于开发成本。与其出了问题再拿调试器硬啃不如动手前多花十分钟弄清楚你要保护的临界区处于什么上下文、粒度该多大、选哪把锁。真做到这一点你会发现很多死锁和卡死根本就不会出现。