
聊到Linux并发自旋锁spinlock大概是躲不掉的。你不管是准备linux面试题测试被问到“自旋锁和互斥锁的区别”还是在做嵌入式linux项目时调一个诡异卡死的驱动最后都会撞到这个名字上。它本身并不复杂但说真的把API背下来和真正在项目里用对是两码事。我见过太多人在驱动里无脑加spin_lock临界区里又调copy_from_user又调printk结果不是死锁就是系统调度混乱最后一脸茫然地到处删代码。自旋锁本质上是一种忙等待锁锁被占着的时候后来的CPU不会睡过去而是在原地反复检查锁是否可用“转圈”等到拿到为止。它名字里的“自旋”就是这个意思。这个机制决定了它能极快地保护短小临界区也决定了它绝对不能出现在需要睡眠的路径上。搞懂这个权衡比记住几十个API函数重要得多。这篇我就用实际驱动里遇到的场景把自旋锁从内核实现、API族谱、加锁实操到排错工具一次讲透。刚入门的朋友按顺序读已经开始用但踩过坑的可以直接跳到后面的坑位清单和排查方法。1. 自旋锁到底解决什么问题先搞懂“忙等”模型1.1 竞态从哪来没有锁的世界什么样举个例子你就明白了。假设驱动里维护了一个全局计数器两个CPU同时执行event_count。这段C代码在编译后底层通常是一串“读、改、写”操作先把这个变量的值加载到寄存器在寄存器里加一再写回内存。两个CPU像两个人同时抢一个钱包一个刚把数读走另一个也读了同一个旧值两人都加一后写回结果只增加了一次而不是两次。这就是竞态英文叫race condition核心问题是“读-改-写”的几步操作不是原子的中途被另一个执行流插了一脚。在Linux内核里并发来源还不止多核。同一个变量可能同时被进程上下文访问被中断处理函数访问被软中断softirq访问被内核线程访问。你要保护的其实不是某一段代码而是某个共享数据。谁都有可能动它你就得在动它之前把其他想动它的人挡在外面。锁就是这么个门卫。自旋锁是其中一种门卫它的特点是门被锁住的时候后来的人不离开在门口原地转圈等着门一开立刻冲进去。1.2 自旋 vs 睡眠为什么你不该什么都用mutex自旋锁和互斥锁mutex最直观的区别就在“等待方式”上。自旋锁是忙等等待者占据着CPU空转互斥锁是睡眠等待等待者会把自己挂起让出CPU等锁释放了再由调度器唤醒。生活里打个比方自旋锁就像医院窗口排队你人必须守在窗口前队伍一动你就得跟着往前挪mutex是拿了个号先去旁边的长椅上坐着玩手机叫到号再过去。自旋锁的优点是没有上下文切换开销。睡眠和唤醒涉及调度器、任务状态切换、cache失效一次下来通常要几个微秒甚至更多。如果临界区只是修改一个寄存器、更新一个计数可能几十纳秒就完事用mutex反而亏了。反过来自旋锁的缺点是等待时白白烧着CPU如果临界区要执行几百微秒那抢锁的人全在空转整机性能直接报废。所以自旋锁的适用条件一句话就能说清临界区极短并且期间绝对不能睡眠。这也是它名字里“短平快”的定位。顺带提醒一句中断上下文比如硬件中断处理函数里不能用mutex因为mutex的睡眠依赖调度器而中断上下文是异步路径根本不能进入睡眠状态。你仔细观察内核代码会发现中断处理函数里需要保护共享变量时无一例外全是自旋锁体系的变种。这也是面试官特别爱问的一个点。1.3 单核与多核锁的实现还分地形自旋锁在不同配置下的表现差别很大。在单核非抢占内核里同一时刻只有一个执行流在跑理论上不存在两个CPU同时抢一个变量的情况锁本身往往退化成空操作你甚至感觉不到它的存在。但在抢占式内核里一个进程持锁期间如果被另一个优先级更高的进程抢占而那个进程也来抢同一把锁则会因为持有者永远得不到CPU而直接死锁。所以自旋锁内部有一个容易被忽略的动作在取锁后内核会把当前CPU的抢占关掉等锁释放后再恢复。这个细节解释了为什么写驱动时你感知不到preempt_disable和preempt_enable的来回切换——锁帮你做了。在多核系统上自旋锁就是真刀真枪的原子操作加自旋循环了。x86上有带lock前缀的原子指令ARM上有ldrex/strex之类的独占加载/存储指令保证“测试并设置”这个动作是原子的。更关键的是内核同步不只要求互斥还要保证内存可见性一个CPU改了数据另一个CPU在拿到锁之后必须能看到最新值。这就要靠内存屏障和缓存一致性协议来兜底了后面第2节细讲。2. 内核里的自旋锁长什么样结构体、API 与屏障2.1 spinlock_t 的“真身”与两个变种在内核源码里spinlock_t并不是一个孤零零的结构。它内部包含raw_spinlock_t而raw_spinlock_t又包含架构相关的arch_spinlock_t。这一层一层封装给不同场景留下了切换余地。在标准内核没有开启实时抢占补丁里spin_lock()其实就是调用raw_spin_lock()二者没区别。但一旦上了 PREEMPT_RT 实时内核spinlock_t可能会被实现成可睡眠的rtmutex而raw_spinlock_t仍然保持原来的忙等语义。这个点非常关键第4.3节我会单独展开。现在你只需要记住标准内核里spin_lock和raw_spin_lock等价实时内核里它们开始分道扬镳。锁的状态在SMP架构上并不是一个“0或1”那么简单。老内核常见的是ticket锁相当于取号排队每个申请者先原子地取一个号然后盯着“当前服务号”等叫号服务号轮到自己就代表拿到锁。这种设计公平不会饿死但有个明显问题所有等待者都在同一个服务号变量上反复读取每个CPU都会往缓存一致性协议里发消息锁一释放全网蜂拥而至出现严重的cache line乒乓效应大规模多核下性能很难看。2.2 API 全家桶每个变体都是有原因的自旋锁API不止一对spin_lock和spin_unlock你用哪个变体取决于“还有谁会来碰这把锁”。先看最常见的几个API行为典型场景spin_lock()/spin_unlock()只取锁不开不关中断锁只被进程上下文访问不会被中断或软中断打断spin_lock_irq()/spin_unlock_irq()取锁并关闭本CPU硬中断要防中断来抢锁但无需保存之前的中断状态spin_lock_irqsave(flags)/spin_unlock_irqrestore(flags)取锁并保存中断状态中断状态未知或必须原样恢复推荐凡涉及中断一律用它spin_lock_bh()/spin_unlock_bh()取锁并关闭本CPU软中断锁会和下半部softirq/tasklet竞争spin_trylock()拿不到锁就返回0不等待希望失败后走另一条路径的场合这里最容易犯的错是在不知道当前中断是否已经关闭的情况下用了spin_lock_irq()。那把锁用完后spin_unlock_irq()会无条件打开中断如果调用前中断本来就是关着的你就把一个本不该开的中断给打开了后面的运行路径全乱套。所以要养成习惯凡是临界区可能被中断上下文访问一律用spin_lock_irqsave()把之前的中断状态保存在flags里释放时再用spin_unlock_irqrestore()恢复。宁可多带一个变量也不要赌当前状态。2.3 内存屏障与缓存一致性锁怎么保证“看得见”互斥只是锁的一半任务另一半是“数据可见性”。假设CPU0在锁内写了一个全局变量释放锁后CPU1拿锁去读它看到的必须是CPU0写入的最新值。x86因为强内存模型处理器会保证大多数访问最终一致而ARM这类弱内存模型的处理器编译器和CPU都可能把访存指令重排。如果没有屏障约束临界区里写好的数据理论上可能被移到锁释放之后才真正“生效”另一个CPU拿锁后读到旧值这个bug会让人怀疑人生。自旋锁在实现上天然带有内存屏障语义。spin_lock()是一个acquire操作后面的访存不能“上移”越到取锁之前spin_unlock()是一个release操作前面的访存不能“下移”溜到解锁之后。这样锁就像一个闸门把所有临界区内的读写在时间上框住了。底层实现里arm上的dmb指令、x86上的内存屏障指令都是干这个的。这也是为什么解锁后别人立刻能看到你写入的数据——不是靠MESI缓存一致性协议“最终看得见”更是靠屏障把顺序钉死了。2.4 qspinlock排队自旋锁的演进从内核4.18开始x86等架构的默认自旋锁换成了qspinlockqueued spinlock排队自旋锁取代了老的ticket锁。它解决的就是ticket锁在高争用下的缓存乒乓问题。qspinlock把锁拆成一个很精巧的32位结构最低的lock bit表示锁是否被持有一个pending bit允许“只有一个候补在等待”剩下的位用来编码一个MCS队列的尾部指针。当竞争不激烈时qspinlock走快速路径直接原子操作改lock bit和普通自旋锁一样轻。一旦竞争激烈后来的CPU就各自去自己的per-CPU节点上排队每个CPU自旋等待的是自己节点里的标志位而不是所有人盯着同一个共享变量。锁释放时按队列次序逐个传递唤醒权整个传递过程是点对点的cache line ping-pong大幅减少。这也是为什么现代内核在高并发下扩展性好很多本质是“大家不要在同一个窗口挤着叫号而是排成一条队伍前面的人办完再通知下一个”。3. 一个真实驱动改造中断上下文的锁怎么加3.1 项目背景状态寄存器、事件计数与竞态现场我在一块ARM板子上调过一个设备驱动硬件中断触发时驱动要从设备寄存器里读状态并累加一个事件计数。应用层通过read()接口读这两个值用来判断设备当前的健康状态。最初代码写得很简单就是中断里直接赋值加一read也直接读没有锁。单核低负载时跑得好好的一上多核压测就出问题应用层经常读到“状态已经变了但计数还没跟上”这种互相矛盾的数据严重时还会崩溃。问题根源很清楚。中断处理函数和进程上下文的read回调是两个完全独立的执行流它们可能同时在两个CPU上运行对device_status和event_count的读写互相交错。中断从中断了进程上下文进程先读走了status旧值正准备读count中断来了把status和count都改了进程继续读到的count就跟前面的status对不上号。要解决就是把这两个共享变量的访问锁起来让它们作为一个整体被原子地读取和更新。3.2 加锁改造irqsave版代码与每一步的理由改造后的代码大概是这样的定义一把锁保护状态中断处理函数和read回调都用同一把锁。static DEFINE_SPINLOCK(state_lock); static int device_status; static unsigned long event_count; /* 硬件中断的顶半部 */ static irqreturn_t dev_irq_handler(int irq, void *dev_id) { unsigned long flags; spin_lock_irqsave(state_lock, flags); device_status readl(reg_base STATUS_REG); event_count; spin_unlock_irqrestore(state_lock, flags); return IRQ_HANDLED; } static ssize_t dev_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { unsigned long flags; int st; unsigned long ev; spin_lock_irqsave(state_lock, flags); st device_status; ev event_count; spin_unlock_irqrestore(state_lock, flags); return simple_read_from_buffer(buf, count, ppos, st, sizeof(st)); }有几个地方特别注意。第一中断处理函数里明明已经在中断上下文了为什么还要用irqsave因为中断顶半部虽然是异步打断进程的但驱动可能注册了多个中断源同一个锁也可能被另一个中断处理函数访问或者在第二个CPU上触发同一个中断。用irqsave把本CPU中断关掉能防止在临界区里再次被中断打断。第二read回调里只锁“读变量”的几行不要把copy_to_user、simple_read_from_buffer这类可能睡眠的函数放进锁里。用户空间拷贝缺页时可能触发page fault最终导致进程睡眠而你在持锁这就是大坑。先把数据快照取出来在锁外再拷贝稳稳当当。3.3 验证与度量lockdep、lock_stat 与性能观察代码改完后我习惯先在调试内核里跑一遍再开正式内核。调试内核要打开几个配置项CONFIG_DEBUG_SPINLOCK、CONFIG_PROVE_LOCKINGlockdep、CONFIG_DEBUG_ATOMIC_SLEEP、CONFIG_LOCK_STAT。打开这些选项后内核会在运行时检查锁的使用是否合法比如是否递归持锁、是否在临界区里调用了休眠函数、是否形成锁依赖环。模块加载后跑一遍同样的压测dmesg里没有“BUG: sleeping function called from invalid context”之类的报错说明睡眠问题没有撞上。再用perf或者简单的时间统计看read接口的调用耗时确认锁没有把正常路径拖慢。如果想定量观察锁竞争可以打开lock_stat先echo 1 /proc/sys/kernel/lock_stat跑完业务后用cat /proc/lock_stat看统计。关键指标有contentions争抢次数、waittime等待耗时、holdtime持锁耗时。如果waittime的max值异常高说明临界区可能太长或者持锁路径里混进了不该有的操作。这套流程下来竞态问题基本可以确认解决而且能顺带发现锁本身是否用得过重。4. 自旋锁的坑位清单哪些代码一进临界区就是雷4.1 持锁期间禁睡眠为什么这些函数全是雷自旋锁最不能碰的就是“睡眠”。一个锁的本质是“短临界区里互斥”结果你在持锁期间调了kmalloc(GFP_KERNEL)内存紧张时这个函数会主动睡眠等待回收调了copy_from_user用户页不在内存时可能睡眠调了mutex_lock那更直接拿到锁之前就睡下了。持有自旋锁的进程一旦睡眠其他想取这把锁的CPU会一直在原地自旋。单核系统上更惨持锁进程让出CPU新进程来抢同一把锁抢不到就自旋而持锁进程永远没有机会继续执行直接死锁。内核为了帮你发现这类问题提供了might_sleep()调试工具很多可能睡眠的函数内部都会调用它。配合CONFIG_DEBUG_ATOMIC_SLEEP一旦你在“原子上下文”持锁、关抢占、关中断里干了会睡眠的事内核直接打印BUG: sleeping function called from invalid context。看到这句话先顺藤摸瓜看持锁的调用链里调了哪些函数把这些函数挪到锁外执行。还有个小细节临界区里不要做printk。你可能会想printk又不睡眠问题不大吧printk内部有console锁如果控制台输出卡住整个系统会被拖慢可能几秒钟都在等一个打印。持锁等待本身就是大忌。需要打印调试时先把变量快照拿出来在锁外打印。4.2 嵌套与锁顺序AB-BA死锁从哪儿冒出来当代码里同时需要两把锁时危险就来了。经典的AB-BA死锁任务A先拿锁L1再拿锁L2任务B先拿锁L2再拿锁L1。两个任务各拿了一把又都等对方手里的那把谁也等不到形成死锁环。自旋锁是忙等这个环一旦形成两个CPU就永远转圈系统直接卡死。解决AB-BA死锁的办法不是“小心点”而是定规矩同一个调用路径里多个锁的获取顺序必须全局一致。比如锁地址小的先拿锁地址大的后拿所有人的代码都按这个顺序来就不会出现交叉等待。驱动里的多锁场景尤其要留意回调函数inode-i_lock、dev-lock、private_data-state_lock一路翻下来顺序稍微不一致换个场景就卡死。lockdep第5节最厉害的地方就是能自动发现这种环形依赖把潜在的AB-BA死锁提前暴露出来。平时写代码时也要养成习惯在头文件里给锁的嵌套顺序写清楚注释。4.3 实时内核里的spinlock_t与raw_spinlock_t如果你用过带RT补丁的实时内核或者编译过开启PREEMPT_RT的发行版会发现一个反直觉的现象RT内核里的普通spin_lock()临界区居然可以睡眠了。原因是RT内核为了让实时任务不被优先级反转坑死把spinlock_t重定义为受优先级继承保护的rtmutex等待者不再忙等而是让出CPU并在锁释放后由调度器唤醒。这样一来实时任务和普通任务竞争同一把锁时普通任务持有锁期间不会被实时任务无限抢占实时性得到大幅改善。但这也带来一个新问题内核里有些路径绝对不允许睡眠比如调度器内部、硬件中断处理的核心部分。这些路径不能使用变成了可睡眠锁的spinlock_t必须使用仍然保持忙等语义的raw_spinlock_t。所以你会在一些很底层的代码里看到raw_spin_lock它们就是默认情况下不允许换语义的“硬核”锁。你在正式内核里写驱动用spin_lock没问题但只要想兼容RT内核就得考虑自己这个临界区到底能不能容忍变成“可睡眠”。如果临界区极短而且确实在中断处理里最好直接用raw_spin_lock这样最保守两种内核下行为一致。4.4 自旋锁、RCU还是mutex选型逻辑锁不是越强越好而是越适合越好。我平时做选型时判断顺序大概是这样的先问临界区能不能睡眠能睡眠且可以承受调度成本优先考虑mutex不能睡眠、临界区又很短用自旋锁读多写少、读者可以容忍偶尔读旧数据且不想背锁开销用RCU如果读写都频繁但写者很少还能接受写者优先和读不加锁可以看看seqlock。自旋锁在这套选型里就是“短小精悍”的那一类别指望它扛大活儿。真需要保护复杂链表、长时间计算、等待硬件事件及时改用mutex或完成量反而更稳。5. 排错工具与实战lockdep、lock_stat、perf lock5.1 lockdep比你先发现死锁的锁探测器lockdep是Linux内核里我最喜欢的调试神器它不是什么动态追踪黑科技而是一个运行时锁依赖关系分析器。它的思路是内核在运行过程中记录所有锁的获取顺序。比如当前持有A锁时去获取B锁就记下一条“A - B”的依赖关系。依赖关系不断累积成一张图如果什么时候发现一条新的依赖路径与已有路径形成环说明这里存在闭环的死锁隐患立刻打印警告。常见的报错样式大概是 WARNING: possible circular locking dependency detected ------------------------------------------------------ 5.10.0-rc1 #1 Not tainted ------------------------------------------------------ process A/1234 is trying to acquire lock: (dev-state_lock){....}-{2:2}, at: func_b but task is already holding lock: (dev-lock){....}-{2:2}, at: func_a which lock already depends on the new lock.看到这样的日志第一反应不是去查“谁能死锁”而是去查依赖环dev-lock先于dev-state_lock被获取另一条路径却是反过来的。解法就是统一锁顺序。最朴素的实践是驱动里多把锁所有函数都按照同一个顺序获取比如先获取外层设备锁再获取内层状态锁并且把这条规则写在文件顶部注释里让后接手的人也能遵循。5.2 常见报错速查表报错关键字含义处理方向BUG: spinlock recursion on CPU#x同一CPU递归获取同一把锁检查函数调用链确认没有重复加锁临界区必须改小或改用可重入设计BUG: spinlock already unlocked on CPU#x对没加锁的锁执行了解锁检查错误路径和提前返回锁的释放和获取必须严格配对BUG: sleeping function called from invalid context原子上下文里睡了觉把睡眠调用挪出临界区或者改用可睡眠的mutexpossible circular locking dependency detected锁依赖图出现环统一多锁获取顺序消除AB-BA型死锁隐患系统卡死、CPU占用率100%却无输出某个CPU在自旋死锁用NMI watchdog或perf查看卡死位置的调用栈在线排查死锁时先确认是不是自旋死锁系统看起来活着但某个CPU占满了top或htop里能看到某个进程烧满CPU或者所有核都烧满但业务没有任何进展。结合dmesg的lockdep警告和perf record采集的调用栈基本能把卡死现场定位到具体函数。5.3 调试自旋锁问题的实战经验我自己的调试流程很固定。第一件事不是翻代码而是先把并发上下文列一遍这个共享数据会被谁访问硬件中断、软中断、工作队列、进程上下文还是内核线程每一类执行流在什么条件下碰这个数据。把这张表列清楚锁型别和锁策略基本就定了中断会碰就irqsave软中断会碰就bh只在进程上下文就普通spin_lock。列表的过程远比抓一把锁随意加在函数开头重要得多。第二件事是把临界区压缩到最小这个“最小”是数据层面不是代码行数层面。read回调里我们只锁了三行把状态和计数读出来形成一个一致快照就够。不要在锁里拼接字符串、打印日志、拷贝用户数据。临界区小了holdtime自然低竞争自然少死锁概率也跟着降。最后再用perf lock record跑一遍压测结束后perf lock report能看到锁的竞争排行和时间分布数据比感觉靠谱得多。最后再分享一个我个人的小习惯开发期内核一定开lockdep和DEBUG_ATOMIC_SLEEP开着它跑测试也许会有额外的性能开销但能帮你把一堆潜在死锁和睡眠错误提前挖出来。等代码成熟稳定、要上正式环境时再关掉这些选项换取性能。很多莫名其妙的驱动问题都是这些选项在开发阶段没开上设备后才冒出来的。写驱动这些年我很少见到哪个自旋锁问题是“锁机制本身坏了”绝大多数都是使用姿势不对。把锁当成一件趁手的工具而不是救命稻草你的并发代码会稳得多。