ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux 驱动加了锁,为什么还是会死锁?5 个并发 Bug 带你读懂 LDD3 第五章

Linux 驱动加了锁,为什么还是会死锁?5 个并发 Bug 带你读懂 LDD3 第五章 驱动开发里最折磨人的故障往往不是稳定复现的崩溃而是这些现象单线程测试一直正常一上并发就偶尔丢数据压测几个小时后出现内存泄漏某次关闭设备卡在 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、并发压测及可复现竞态的具体操作请继续关注配套实操篇。
返回列表