驱动开发里最折磨人的故障,往往不是稳定复现的崩溃,而是这些现象:单线程测试一直正常,一上并发就偶尔丢数据;压测几个小时后出现内存泄漏;某次关闭设备卡在 `D` 状态;日志最后停在一次拿锁前,重启后又什么都查不到。
面对这类问题,第一反应通常是“这里可能有竞争,加把锁”。可不少故障恰恰发生在已经有锁的代码里:锁保护错了对象,临界区漏掉了状态转换的一半,进程路径与中断路径用了不同规则,或者对象释放时还有回调握着旧指针。
LDD3 第五章给出了一条更适合工程现场的判断:先写清共享状态必须满足什么规则,再决定谁负责保护它、谁可能睡眠,以及对象什么时候才允许释放。
先说结论:锁保护的不是代码行,而是一条不变量
假设设备里有一块按需分配的缓冲区。它的关键规则不是“给指针赋值时要加锁”,而是:同一个槽位从空变成可用只能成功一次;指针一旦对外可见,对象必须已经初始化完成;最后一个使用者离开前,内存不能释放。
这三句话分别涉及状态转换、发布顺序和生命周期。只在赋值语句外面包一把锁,最多解决其中一部分。
工程中可以先问四个问题:
- 哪些字段共同描述一个状态,不能被拆开观察?
- 哪些路径会访问它,包括系统调用、中断、定时器和工作队列?
- 哪些路径允许睡眠,哪些路径必须保持原子上下文?
- 谁能证明对象不会在并发使用期间被释放?
四个问题答不全,锁的类型选得再漂亮,也只是把偶发错误藏得更深。
痛点一:最普通的“先检查再执行”,就能制造泄漏
书中 `scull` 的例子非常接近日常代码:发现槽位指针为空,就分配内存,再把地址写回去。单线程看不出问题,多线程却可能同时通过“为空”检查,各自完成一次分配,最后一次赋值覆盖前一次地址。
图 1 LDD3 第五章第 107 页:两个写者都通过空指针检查后分别分配内存,后一次赋值覆盖前一次结果。
问题不在 `kmalloc()` 能不能并发调用,而在“检查”和“发布”之间存在可插入窗口。类似写法在驱动里很常见:
if (!dev->buffer) dev->buffer = kmalloc(size, GFP_KERNEL);直接把分配放进 mutex 临界区可以保证正确,但分配可能睡眠,也会拉长锁持有时间。更常见的工程写法是先准备私有对象,再在锁内复查并发布:
new_buffer = kmalloc(size, GFP_KERNEL); if (!new_buffer) return -ENOMEM; mutex_lock(&dev->lock); if (!dev->buffer) { dev->buffer = new_buffer; new_buffer = NULL; } mutex_unlock(&dev->lock); kfree(new_buffer); /* 别人抢先发布时,释放备用对象 */这段代码的重点不是“双重检查”这个名字,而是锁内再次验证判断依据。锁外完成的工作只能算准备,真正改变共享状态时必须以锁内看到的最新事实为准。
如果这类竞态很难在测试中复现,可以优先检查所有“先判断、后修改”的组合:空指针后分配、计数为零后销毁、队列非满后入队、设备在线后发起 I/O。判断和动作之间只要能被另一条路径插入,就需要重新证明。
痛点二:有锁仍然出错,通常不是锁不够多
1. 只锁了字段,没有锁住状态转换
设备状态往往由多个字段共同描述。例如 `buffer`、`size` 和 `ready` 必须同时一致。若写者分别更新三个字段,读者即使每次读取都加锁,也可能在两段独立临界区之间看到半完成状态。
锁的范围应覆盖“从一个合法状态变成另一个合法状态”的最小完整过程,而不是机械覆盖每次赋值。反过来,日志格式化、用户空间复制、耗时计算和外部回调通常不属于状态转换,应尽量移到锁外。
2. 不同执行路径遵守了不同规则
进程路径用 mutex,中断路径直接改同一字段;读路径拿 `state_lock`,关闭路径只拿 `open_lock`;工作队列认为引用计数足够,卸载路径却不等回调退出。这些代码局部看起来都“有同步”,整体却没有唯一的保护规则。
锁旁边的注释最好明确到字段和上下文,例如:
/* * state_lock protects head, tail and pending. * Accessed from process context and IRQ handler. * Process-side users must use spin_lock_irqsave(). */ spinlock_t state_lock;如果注释只能写成“protect data”,通常说明保护范围还没有真正想清楚。
3. 字段安全了,对象却提前消失了
锁可以保证一次访问期间字段不被同时修改,却不会自动保证宿主对象仍然存在。设备拔出、文件关闭、模块卸载和错误回滚都可能释放对象;定时器、异步工作或另一个文件描述符仍可能持有旧地址。
这类问题需要引用计数、同步取消、RCU 宽限期或关闭阶段等待来解决。看到 use-after-free 时,不要只在对象字段周围继续加锁,应追踪“谁取得引用、谁释放引用、最后一次释放前等待了哪些异步路径”。
痛点三:mutex、spinlock 和 completion 不是速度档位
很多选型错误来自一句话:“spinlock 更轻,所以这里换成 spinlock。”同步原语首先表达上下文和关系,性能是后面的事。
mutex:保护任务上下文里的所有权
mutex 适合允许睡眠的任务上下文。拿不到锁时,任务可以让出 CPU。临界区里如果可能调用会睡眠的接口,至少不能使用传统原子上下文的锁来包围它。
mutex 不是递归锁。同一任务再次获取同一把 mutex 会把自己堵住。驱动持锁调用辅助函数时,可以把内部接口明确命名为 `foo_locked()`,表示调用者已经持锁,避免辅助函数再次获取同一把锁。
spinlock:保护不能睡眠路径里的极短更新
普通非实时内核上,自旋锁拿不到时会占着 CPU 等待,因此临界区必须短,不能做用户空间复制、`GFP_KERNEL` 分配或任何可能调度的工作。若同一状态也会被本地硬中断访问,进程侧通常要使用 `spin_lock_irqsave()`,否则进程持锁时被中断打断,中断再等同一把锁,就会原地死锁。
PREEMPT_RT 会改变 `spinlock_t` 的底层语义,所以不要把“持有 spinlock 一定关闭抢占或中断”当作业务逻辑。普通驱动也不该为了追求更“硬”的行为随意换成 `raw_spinlock_t`。
completion:等待事件完成,不负责互斥所有权
提交硬件请求后等中断回应、启动工作后等退出完成,这类需求不是“谁拥有共享状态”,而是“某件事完成没有”。completion 的语义比拿一把锁充当通知令牌清楚得多。
left = wait_for_completion_interruptible_timeout( &dev->request_done, msecs_to_jiffies(5000)); if (left < 0) return left; if (left == 0) return -ETIMEDOUT;重新开始下一轮事件时使用 `reinit_completion()`,必须确认没有等待者正与重置竞速,否则刚发出的完成通知可能被清掉。
痛点四:多把锁出现以后,顺序就是接口的一部分
一把锁保护状态,两把锁就开始保护系统的拓扑。下面两条路径单独看都合理,放在一起却构成 ABBA:
/* 路径一 */ mutex_lock(&dev->config_lock); mutex_lock(&dev->io_lock); /* 路径二 */ mutex_lock(&dev->io_lock); mutex_lock(&dev->config_lock);只要两个路径同时运行,双方就可能各持一把锁等待另一把。解决方法不是增加超时,而是定义全局一致的获取顺序,并让所有调用路径遵守。若锁属于多个同类对象,还要规定按设备编号、树层级或对象地址排序。
锁顺序应该成为代码接口的一部分。例如写在结构体旁边:`config_lock -> io_lock -> queue_lock`。新增路径如果无法遵守顺序,就应重新设计调用关系,而不是在现场用 `mutex_trylock()` 掩盖死锁。
排查 hung task 时,可以重点看调用栈里最后一次锁获取,结合 lockdep 报告寻找反向依赖。若问题只在压力下出现,先不要怀疑“CPU 太快”,多半是某条少见错误路径或关闭路径打破了既定顺序。
痛点五:atomic_t 不能把多个字段变成一个事务
把普通整数换成 `atomic_t` 很容易带来“已经线程安全”的错觉。原子类型只能保证规定的单个操作不可分割,不能自动维护多个字段之间的关系。
例如队列指针和元素计数分别原子更新,读者仍可能看到“队列已非空,但计数还是零”的中间状态。如果正确性依赖跨字段不变量,仍要用合适的锁、序列计数或经过证明的数据结构。
环形缓冲区就是一个典型例子。单生产者只修改写指针、单消费者只修改读指针,并遵守数据发布顺序时,可以减少额外同步;一旦变成多个生产者或多个消费者,原来的证明立即失效。
图 2 LDD3 图 5-1:环形缓冲区的空、满和普通状态由读写指针关系共同决定。
实际工作中优先使用 `kfifo` 等现成抽象,并严格确认生产者和消费者数量。seqlock 适合小型、读多写少且允许读者重试的状态;RCU 适合读侧热点明显的指针结构,并把“从结构摘除”和“等待旧读者离开后回收”分开。它们都不是为了显得高级,而是以更强的使用约束换取特定访问模式下的成本优势。
出现并发故障时,先按症状缩小范围
- 偶发内存泄漏:查找空指针后分配、计数后创建等“先检查再执行”路径,确认锁内是否复查。
- 偶发 use-after-free:追踪对象引用、异步回调和关闭顺序,不要只检查字段锁。
- 任务卡在 `D` 状态:查看锁获取顺序、持锁等待和等待方是否握着生产者需要的锁。
- 只在中断压力下死锁:核对进程路径是否需要 `_irqsave` 或 `_bh` 变体,以及中断路径是否访问同一状态。
- 加锁后性能骤降:先测量争用和持锁时间,再把耗时工作移出临界区;不要直接拆成更多细锁。
- 原子计数正确但队列仍损坏:检查多个字段之间的不变量,atomic 并不等于事务。
开发内核可以配合 lockdep 检查锁依赖,使用 KCSAN 捕捉数据竞争,并在 SMP、抢占和并发压力下测试。工具能扩大错误暴露概率,却不能替代对共享关系和生命周期的设计说明。
带回工作中的并发审查清单
- 给每组共享字段写出一句可验证的不变量。
- 列出任务、工作队列、软中断、硬中断和关闭路径等全部访问者。
- 先减少共享,再决定锁;能拆成实例私有状态就不要放进全局对象。
- mutex 用于可睡眠的所有权保护,completion 用于事件完成,原子上下文的短更新才考虑 spinlock。
- 规定并记录多把锁的全局顺序,不在持锁期间调用行为未知的外部回调。
- 把对象发布、引用取得、异步取消和最终释放放进同一套生命周期设计。
- 先用粗粒度锁做出可证明的正确版本,再根据测量结果优化热点。
结语
并发问题之所以难,不是因为 Linux 的锁太多,而是错误往往藏在两条路径的交汇处。真正可靠的设计顺序应当是:先定义共享状态的合法规则,再确认所有访问者和执行上下文,最后选择能维持这条规则的同步机制。
LDD3 第五章提供的是这套思考框架。把它带回日常驱动开发,最有价值的变化不是“代码里多了一把锁”,而是每把锁都能回答三个问题:它保护什么、谁必须遵守、对象何时才允许消失。
本文先解决并发设计和故障定位思路;lockdep、KCSAN、并发压测及可复现竞态的具体操作,请继续关注配套实操篇。