Linux驱动并发控制:从原子操作到锁机制的原理与实战
你知道驱动里那些偶发崩溃多半是并发惹的祸做Linux驱动开发的人大概率都经历过这种场景驱动跑起来功能一切正常测试一压测或者开个多线程调用系统就偶发崩溃、数据错乱或者直接卡死。你去查日志发现崩溃点毫无规律甚至每次崩的地方都不一样。这种问题十有八九是并发与竞争导致的。这是我写Linux驱动开发基础的第五篇前面聊过设备模型、字符设备、中断处理和内核内存管理。今天这篇专门把并发与竞争这件事掰开揉碎。它讲的是驱动里多个执行流同时访问同一份数据时怎么保证数据不错乱、系统不崩溃。谁适合读这篇正被偶发Bug折磨的驱动开发者刚入门内核编程想建立并发思维的初学者还有想系统梳理同步机制选型的同学都能从这里找到有价值的东西。先说句大实话并发与竞争是驱动开发里最难调试、也最考验经验的模块之一。很多Bug不是写出来的是设计阶段没考虑并发才埋下的。这篇文章我会从问题根源开始把原子操作、自旋锁、互斥锁这些手段逐个讲透最后用一段真实风格的驱动代码演示怎么正确落地。1. 为什么说并发与竞争是驱动开发的必考题1.1 上下文驱动里的并发到底从哪里来很多人写驱动时有个惯性思维我的代码是顺序执行的只要逻辑写对就没问题。这是最危险的想法。驱动不是普通应用程序驱动运行在内核态它的代码有多个“入口”每个入口都可能被不同的执行流同时进入。驱动里有哪几类并发来源我平时排查问题时通常这么分类多个进程同时打开设备文件、同时调用read/write/ioctl这是最直观的并发路径中断处理程序随时可能打断正在执行的进程上下文打断的那一瞬间CPU转去执行中断处理函数软中断、tasklet、工作队列这些下半部机制会异步执行和进程上下文并行跑多核SMP系统上多个CPU核心真正地在物理上同时执行同一个驱动里的代码内核线程、定时器回调、异步通知等机制也都会作为独立执行流进入你的驱动这里有一个关键认知只要驱动里有任何一份数据会被多个执行流访问这份数据就是共享资源就需要保护。而硬件寄存器、全局变量、静态缓冲区、设备私有数据里的状态字段都是典型的共享资源。我见过一个非常典型的案例某驱动用全局变量记录当前请求的序列号主流程先读再写中断回调里也要读这个序列号来确认硬件状态。平时运行没问题一旦网络数据量上来中断频率变高系统就开始不稳定。最后定位原因就是中断里读到的序列号和主流程里写的不一致。1.2 竞争的本质当两个执行流同时操作同一份数据竞争race condition的本质是多个执行流对同一份数据的访问顺序不确定。访问顺序不确定结果就不确定程序行为就不可预期。举个例子假设驱动里有一段这样的逻辑if (dev-status BUSY) { return -EBUSY; } dev-status BUSY; /* 操作硬件 */两个进程同时在两个CPU上执行这段代码。CPU0判断status不是BUSY还没等它把status设为BUSYCPU1也开始判断status看到的也不是BUSY。结果两个进程都认为设备空闲同时去操作硬件硬件行为就完全不可控了。这个例子的精髓在于判断和设置之间有一个时间窗口任何并发执行流都可能在这个窗口里插进来。竞争不是说一定发生而是“有机会发生”。一旦发生后果就是数据错乱、逻辑判断失误甚至内核崩溃。所以驱动里常说“临界区”——访问共享资源的那段代码必须互斥执行。互斥的本质是当一个执行流在临界区里时其他执行流必须等待不能同时进入。2. 先画地图再动手并发控制的整体设计思路2.1 第一步不是选锁而是先找出共享资源我见过很多新手一上来就问“自旋锁和互斥锁哪个好”这其实问早了。正确的第一步是把驱动里的共享资源全部列出来搞清每个资源有哪些访问路径。我通常用一张表来梳理共享资源访问路径上下文类型需要保护吗设备状态字段 statusread()、ioctl()、中断处理进程上下文 中断上下文是数据缓冲区 bufread()、write()、工作队列进程上下文 软中断是硬件寄存器组所有路径混合是打开计数 open_countopen()、release()进程上下文是做完这张表你就知道临界区分布在哪里了。然后要做的就是一件事给每个临界区选择合适的同步机制。这里有个我踩过很多次的坑不要想着“一个锁保护所有资源”。全局大锁虽然简单但会严重拖累并发性能而且容易制造死锁。合理的做法是分资源加锁锁的粒度尽量小保护的范围尽量明确。2.2 第二步用三个问题选同步机制面对一个临界区我通常会连续问自己三个问题这个临界区里的操作要花多长时间几微秒还是几十毫秒我所在的执行上下文允许睡眠吗中断上下文里绝对不能睡访问模式是什么读多写少、写多读少还是读写都频繁这三个问题的答案基本就决定了该用哪种同步手段。先说临界区长度。自旋锁的特点是“等不到就原地打转”所以只适合很短的操作你要是持锁几十毫秒其他CPU上的等待者就在那白白空转烧CPU。反观互斥锁等不到就让出CPU去睡觉等锁可用再被唤醒所以适合长临界区但代价是睡眠唤醒的开销不小。再说是否允许睡眠。中断上下文连调度器都不能调用睡眠是绝对的禁忌所以中断里只能使用自旋锁、原子操作这类不会睡眠的手段。互斥锁、信号量、完成量这类会睡眠的机制在中断上下文里用了必崩。最后看访问模式。如果共享数据被大量读、偶尔写且读者拿锁时间很短读写锁或RCU能显著提升性能。如果读者多、写者也有一定频率顺序锁也可以考虑。我把常见机制适配场景整理成了下面这个表方便对照同步机制临界区时长能否睡眠典型场景原子操作极短否计数器、标志位自旋锁短否中断上下文、共享寄存器保护互斥锁长是进程上下文、大数据结构操作读写锁短/中读者可睡也可不睡读多写少且写频率低顺序锁短取决于实现读多写少但写也频繁RCU读者极短读者可睡读极多写极少、指针替换3. 核心同步机制拆解每个手段解决什么问题3.1 原子操作最朴素的保护手段原子操作是内核提供的“硬件级不可分割”的操作。它在底层由CPU的原子指令保证整个读-改-写过程不会被打断连自旋都省了。内核里有两类原子操作接口一类是atomic_t整型原子操作一类是set_bit/clear_bit/change_bit位原子操作。它们的API都很简洁atomic_t count ATOMIC_INIT(0); atomic_inc(count); /* count 1全程原子 */ atomic_dec_and_test(count); /* 减1后判断是否为0常用于引用计数 */驱动里最常见的用法有两个。一个是设备引用计数一个设备被多个进程打开时记录打开次数另一个是共享标志位比如用一个atomic_t标记设备是否处于可用状态用原子读写来避免竞争。原子操作的使用限制也必须说清楚它只适合单一整型或单一bit的操作如果你要保护的是一个结构体、一段内存或者一个硬件时序逻辑原子操作是做不到的。有人试图用原子变量拼凑复杂的判断逻辑结果往往顾此失彼。复杂场景老老实实用锁。3.2 自旋锁短临界区的利器但别在锁里睡觉自旋锁的核心行为是当一个执行流尝试获取锁时如果锁已被持有它就在原地不断循环检测锁是否释放这个过程叫“自旋”。你可以理解为在门口不停按门铃直到门开了才进去。自旋锁的使用接口很标准spinlock_t lock; spin_lock_init(lock); spin_lock(lock); /* 临界区只能执行短操作 */ spin_unlock(lock);不过真正写驱动时我几乎不用裸的spin_lock因为中断随时可能打断当前临界区。假如进程在临界区里持锁时来了中断中断处理程序也想拿同一把锁就会自己等自己——死锁。所以我实际开发里用的更多是关闭本地中断的版本unsigned long flags; spin_lock_irqsave(lock, flags); /* 临界区本地中断已被关掉 */ spin_unlock_irqrestore(lock, flags);spin_lock_irqsave会保存当前中断状态并关掉本地中断解锁时再把中断状态恢复回来。这样既保护了临界区也避免了中断上下文的干扰。自旋锁最核心的禁忌是持有自旋锁的代码里不能睡眠。因为睡眠会触发调度调度器可能把CPU让给别的进程而那个进程如果也想拿同一把锁就会在这个CPU上空转系统就卡死了。所以自旋锁临界区里不能调用msleep、不能调用copy_from_user、不能申请GFP_KERNEL内存。这些都是新手高频踩雷点。3.3 互斥锁允许睡眠的长临界区方案互斥锁mutex和自旋锁最大的区别在于等待方式。拿不到mutex时当前执行流会被放到等待队列里主动让出CPU去睡眠直到锁被释放再被唤醒。这就像取号排队你先去旁边坐着等叫到号再过去。典型接口struct mutex lock; mutex_init(lock); mutex_lock(lock); /* 临界区可以执行耗时操作可以睡眠 */ mutex_unlock(lock);mutex适合进程上下文里那些比较重的操作比如操作设备数据结构、准备一次较大的数据拷贝、处理用户态的复杂请求。因为临界区可以睡得下你可以在里面使用copy_to_user、msleep这类接口。mutex有两条硬性规则第一不能用于中断上下文中断里不能用互斥锁第二mutex不支持递归加锁同一个执行流连续两次mutex_lock同一把锁会触发死锁检测。这两条记住了能避开大部分内核报错。顺带提一下信号量。信号量本质上是计数型的互斥机制可以允许多个执行流同时进入临界区。但在驱动开发里信号量用得越来越少因为大部分场景要么需要完全互斥用mutex要么需要等待某个事件完成用completion。信号量那种“允许N个读者”的语义很鸡肋除非你有明确的资源池计数需求否则不如直接用mutex。3.4 读写锁、顺序锁与RCU应对不同读写比例的进阶武器前面说的自旋锁和互斥锁都是“一刀切互斥”。但真实的驱动场景里共享数据的访问往往不是均匀的有的数据结构读得非常频繁、写极少。这种场景还拿互斥锁保护所有读者之间也互斥性能会很难看。读写锁rwlock_t把锁区分成读者和写者两个角色。读者拿读锁时可以多个读者并行持有写者拿写锁时必须独占。内核接口是这样的rwlock_t rwlock; rwlock_init(rwlock); read_lock(rwlock); /* 多个读者可并行进入 */ read_unlock(rwlock); write_lock(rwlock); /* 写者独占 */ write_unlock(rwlock);但读写锁有个调度策略问题如果读者源源不断写者可能一直等不到锁产生“写者饥饿”。内核里有读写锁的变体但实际驱动里用读写锁之前要想清楚读写锁并不是万能药。还有一种武器是顺序锁seqlock它的设计思路很独特读者不加锁直接读靠读完后比较序号判断读取过程中有没有发生写操作。写者持有的是一把自旋锁写之前序号加一写完后序号再加一。读者发现序号变了说明数据被改写过了需要重读。seqlock_t lock; seqlock_init(lock); write_seqlock(lock); /* 写数据 */ write_sequnlock(lock); unsigned int seq; do { seq read_seqbegin(lock); /* 读数据 */ } while (read_seqretry(lock, seq));顺序锁适合读多写少但写频率也还行的场景读者几乎无开销。但它有个前提读者能接受“偶尔读到不一致数据后重读”如果读者必须一次读到完整一致数据顺序锁就不合适了。最后说RCURead-Copy-Update这是Linux内核里最复杂也最强大的同步手段之一。RCU的核心思想是读者完全不加锁直接访问旧指针指向的数据写者要修改数据时先复制一份副本在副本上修改然后原子地替换指针旧数据等所有读者都离开后再释放。RCU对驱动开发者来说属于“高级选项”。普通驱动里有90%的并发场景用不到RCU但如果你的驱动有极高频的读操作和偶发的指针替换需求RCU能带来质的飞跃。RCU的难点在于理解宽限期grace period的语义新手建议先掌握前面几种RCU留到有明确性能瓶颈时再研究。4. 实战落地选型决策与编码细节4.1 先判断上下文再决定能不能睡这一节非常重要。很多内核错误比如“sleeping function called from invalid context”“BUG: scheduling while atomic”根本原因就是在不该睡眠的上下文里尝试了睡眠操作。怎么判断当前代码运行在什么上下文我总结了一套很实用的判断方法判断特征所在上下文允许的操作用户调用read/write触发的代码进程上下文可睡眠可用mutex中断处理函数、被硬件触发的回调中断上下文不可睡眠只能用自旋锁/原子操作软中断、tasklet回调软中断上下文不可睡眠类似中断限制工作队列workqueue回调进程上下文内核线程可睡眠可用mutex一个常见误区中断处理里有部分代码是可以延迟处理的所以实践中往往采用“中断只做标记工作队列处理耗时操作”的套路。中断里标记一个flag或者push一个数据到队列唤醒一个工作队列去干活。这样既保证了中断的快速响应又能在进程上下文里拿着mutex慢慢处理复杂逻辑。我自己的代码习惯是进驱动函数第一时间想清楚当前是哪个上下文并把结论写在注释里防止后来的人加代码时用错同步机制。这个习惯帮我躲过好几次事故。4.2 锁的粒度与顺序死锁的预防比排查更重要死锁的四个经典条件驱动开发里几乎都会命中互斥、持有并等待、不可剥夺、循环等待。最让人头疼的就是ABBA死锁两个执行流各持有一把锁又都在等对方的锁。举个例子/* 线程A */ mutex_lock(lock_a); mutex_lock(lock_b); /* 等B */ /* 线程B */ mutex_lock(lock_b); mutex_lock(lock_a); /* 等A */两个线程各不让步就死锁了。内核里对此有自带的检测工具lockdep但最好的策略是从源头避免——给所有锁定一个全局顺序所有代码路径都按这个顺序拿锁就能打破循环等待。我在团队里定的规矩是驱动里所有需要同时持有两把以上锁的路径必须按照“资源编号从小到大”的顺序获取。这个约定写成注释放在文件头部所有人都遵守。如果你的驱动里出现了两把以上的锁花一点时间规划锁顺序绝对值得。锁的粒度也是老生常谈。粒度太粗所有路径抢一把大锁并发性能差粒度太细死锁风险升高代码复杂度暴涨。我的经验是优先从中等粒度开始先保证正确性等性能测试发现锁竞争是瓶颈后再考虑拆分锁。过早优化锁粒度只会让你调试到怀疑人生。4.3 一个可复现的驱动并发处理实例这里我写一段风格真实的字符设备驱动代码演示并发控制怎么落地。场景一个简单的设备用户态可以发起传输请求传输由硬件完成硬件完成后触发中断通知驱动。驱动里需要保护两份数据一是传输状态标志state二是请求结构体指针req。struct my_dev { struct mutex io_lock; /* 保护传输请求结构体 */ spinlock_t state_lock; /* 保护状态标志 */ unsigned long state; /* 0IDLE, 1BUSY */ struct request *req; wait_queue_head_t wq; }; /* 用户发起传输 */ static ssize_t my_write(struct file *filp, const char __user *buf, size_t count, loff_t *off) { struct my_dev *dev filp-private_data; struct request req; unsigned long flags; if (copy_from_user(req, buf, sizeof(req))) return -EFAULT; mutex_lock(dev-io_lock); if (dev-req) { mutex_unlock(dev-io_lock); return -EBUSY; } dev-req req; spin_lock_irqsave(dev-state_lock, flags); dev-state BUSY; spin_unlock_irqrestore(dev-state_lock, flags); /* 触发硬件传输 */ hw_start_transfer(dev); mutex_unlock(dev-io_lock); /* 等待传输完成 */ wait_event_interruptible(dev-wq, dev-state ! BUSY); return count; } /* 中断回调硬件传输完成 */ static irqreturn_t my_isr(int irq, void *data) { struct my_dev *dev data; unsigned long flags; spin_lock_irqsave(dev-state_lock, flags); dev-state IDLE; spin_unlock_irqrestore(dev-state_lock, flags); wake_up_interruptible(dev-wq); return IRQ_HANDLED; }这段代码里有几个值得学习的细节第一io_lock是mutex用在write路径的进程上下文里可以睡眠可以调用copy_from_user这类接口所以数据结构保护交给它。第二state_lock是自旋锁用在中断回调里中断上下文不能睡眠所以状态标志的保护必须交给自旋锁。第三请求结构体req的指针在持有io_lock期间赋值避免和别的路径发生竞争。这段设计我特别想强调的一点不同资源用不同锁不是因为“花哨”而是因为它们的访问上下文不同。如果你试图用一把自旋锁去保护req指针那么copy_from_user在持锁期间就不能被调用数据拷贝就得改到锁外面逻辑一下子复杂很多。反之如果你试图用mutex去保护state那中断回调里就要出大事——中断上下文里调用mutex_lock会直接触发内核调度异常。选择同步机制本质上是让锁的睡眠特性和访问路径的上下文约束对齐。5. 常见问题与排查技巧实录5.1 驱动并发Bug速查表症状、原因和解决方案和并发有关的问题现象千奇百怪但根因往往并不复杂。我把自己排查过的典型问题整理成一个速查表实战中对号入座非常管用。错误现象常见原因排查方向解决方案偶发崩溃崩溃点每次不同共享数据无保护多执行流并发写入检查全局变量、设备结构体的所有访问路径给共享资源加锁或改用原子操作内核报“BUG: sleeping function called from invalid context”在中断/软中断里调用了可睡眠接口看调用栈定位睡眠函数改用自旋锁或把操作移到工作队列系统卡死无响应CPU占用高自旋锁临界区里睡眠或持有时间过长触发magic sysrq看每个CPU栈缩短临界区换mutex检查持锁期间是否有睡眠调用报“possible circular locking dependency detected”存在ABBA死锁风险lockdep会自动给出锁依赖图统一锁获取顺序拆分锁的持有范围设备数据时而对时而错读和写没有用同一把锁保护检查是否用了两把不同锁保护同一资源一个共享资源全局只允许一把锁保护第一行那个“偶发崩溃”是并发问题最容易伪装的样子。普通逻辑错误崩溃通常是稳定的、可复现的并发类Bug则相反它依赖时序可能几小时才触发一次。遇到这种“毫无规律的偶发问题”我的第一反应就是查并发。5.2 排查利器lockdep、KCSAN和肉眼Review内核里有一套非常强大的并发调试机制用好了能省一半排查时间。首先说lockdep锁依赖检查器。只要内核开启了CONFIG_PROVE_LOCKINGlockdep会在运行时自动记录每把锁的获取顺序并在检测到潜在的死锁循环时打印详细警告。它不会等到死锁真正发生而是在可能存在风险的那一瞬间就报警。开发者通常不需要读懂lockdep的全部输出只需要看它提示的锁依赖链和“possible circular locking dependency detected”这段文字就能定位到问题锁。其次说KCSANKernel Concurrency Sanitizer这是近年内核里新增的动态数据竞争检测工具它通过编译器插桩配合模拟随机延迟能主动暴露那些“理论上会发生”的数据竞争。KCSAN的内存开销和性能影响比较大一般不在生产环境开但在测试验证阶段开一轮能把很多隐蔽的竞争揪出来。还有一个经常被忽略的手段肉眼Review。代码写完后找一个同事你一条条讲每个共享资源的访问路径让对方帮忙找有没有遗漏的并发入口。这个“讲代码”的过程往往能逼你自己发现漏洞。不要小看这个方法很多难缠的并发Bug就是这么找出来的。5.3 我总结了三条并发编码的护身法则写了很多年代码踩过无数并发坑我沉淀了三条非常朴素但有效的护身法则供你参考。第一先画图再写代码。动手前花十分钟列出所有共享资源清单和访问路径这个习惯看起来麻烦但能帮你从设计源头规避80%的并发问题。第二临界区越短越好锁的生命周期要和上下文约束匹配。这是选型的第一原则违背它就要付出代价。第三给锁写注释。每把锁的职责、保护对象、获取顺序、禁用的操作都写进注释里。三个月后回头看代码你会感谢当时的自己。另外有一条很实在的建议驱动提交测试前强制开启lockdep和KCSAN跑一遍压力测试。压测能制造大量并发时序配合检测工具能最大化暴露问题。这比上线后让用户帮你发现偶发崩溃要稳妥得多。写在最后的一次真实体会我在一次项目开发里曾被一个并发Bug折磨了近一周。现象是驱动运行大概十几个小时后偶发卡死系统日志完全没有任何异常。最后是通过开启lockdep在压测环境下复现了锁依赖告警才定位到是两条路径获取锁的顺序不一致导致的ABBA死锁。从那以后我养成了两个习惯一是给任何驱动代码都保留lockdep检测选项二是每次加共享资源访问路径时先问自己“这是不是又多了一条并发入口”。如果你现在正被某个偶发Bug折磨不妨先放下定位崩溃点的思路回头检查一遍你的驱动里每个共享数据都有哪些执行流在访问。很多时候问题不在代码逻辑本身而在逻辑之间那几毫秒的竞争窗口。想清楚这一点Bug往往就自己现形了。