news 2026/8/16 8:25:13

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

作者头像

张小明

前端开发工程师

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

驱动开发里最折磨人的故障,往往不是稳定复现的崩溃,而是这些现象:单线程测试一直正常,一上并发就偶尔丢数据;压测几个小时后出现内存泄漏;某次关闭设备卡在 `D` 状态;日志最后停在一次拿锁前,重启后又什么都查不到。

面对这类问题,第一反应通常是“这里可能有竞争,加把锁”。可不少故障恰恰发生在已经有锁的代码里:锁保护错了对象,临界区漏掉了状态转换的一半,进程路径与中断路径用了不同规则,或者对象释放时还有回调握着旧指针。

LDD3 第五章给出了一条更适合工程现场的判断:先写清共享状态必须满足什么规则,再决定谁负责保护它、谁可能睡眠,以及对象什么时候才允许释放。

先说结论:锁保护的不是代码行,而是一条不变量

假设设备里有一块按需分配的缓冲区。它的关键规则不是“给指针赋值时要加锁”,而是:同一个槽位从空变成可用只能成功一次;指针一旦对外可见,对象必须已经初始化完成;最后一个使用者离开前,内存不能释放。

这三句话分别涉及状态转换、发布顺序和生命周期。只在赋值语句外面包一把锁,最多解决其中一部分。

工程中可以先问四个问题:

  1. 哪些字段共同描述一个状态,不能被拆开观察?
  2. 哪些路径会访问它,包括系统调用、中断、定时器和工作队列?
  3. 哪些路径允许睡眠,哪些路径必须保持原子上下文?
  4. 谁能证明对象不会在并发使用期间被释放?

四个问题答不全,锁的类型选得再漂亮,也只是把偶发错误藏得更深。

痛点一:最普通的“先检查再执行”,就能制造泄漏

书中 `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、抢占和并发压力下测试。工具能扩大错误暴露概率,却不能替代对共享关系和生命周期的设计说明。

带回工作中的并发审查清单

  1. 给每组共享字段写出一句可验证的不变量。
  2. 列出任务、工作队列、软中断、硬中断和关闭路径等全部访问者。
  3. 先减少共享,再决定锁;能拆成实例私有状态就不要放进全局对象。
  4. mutex 用于可睡眠的所有权保护,completion 用于事件完成,原子上下文的短更新才考虑 spinlock。
  5. 规定并记录多把锁的全局顺序,不在持锁期间调用行为未知的外部回调。
  6. 把对象发布、引用取得、异步取消和最终释放放进同一套生命周期设计。
  7. 先用粗粒度锁做出可证明的正确版本,再根据测量结果优化热点。

结语

并发问题之所以难,不是因为 Linux 的锁太多,而是错误往往藏在两条路径的交汇处。真正可靠的设计顺序应当是:先定义共享状态的合法规则,再确认所有访问者和执行上下文,最后选择能维持这条规则的同步机制。

LDD3 第五章提供的是这套思考框架。把它带回日常驱动开发,最有价值的变化不是“代码里多了一把锁”,而是每把锁都能回答三个问题:它保护什么、谁必须遵守、对象何时才允许消失。

本文先解决并发设计和故障定位思路;lockdep、KCSAN、并发压测及可复现竞态的具体操作,请继续关注配套实操篇。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/16 8:25:07

NVIDIA Nemotron 3.5 Lightning:极致推理速度与本地部署实践指南

这次我们来看 NVIDIA 最新发布的 Nemotron 3.5 Lightning 模型&#xff0c;以及谷歌 Gemini 月活突破 10 亿的消息。对于开发者而言&#xff0c;这不仅仅是两条新闻&#xff0c;更意味着大模型技术栈的格局正在发生新的变化。NVIDIA 的 Nemotron 系列一直以高效推理和易部署著称…

作者头像 李华
网站建设 2026/8/16 8:25:03

嵌入式入门实战:从零搭建循迹小车,掌握硬件调试与PID控制

1. 循迹小车到底是什么&#xff0c;以及它为什么是入门嵌入式的最佳选择 循迹小车&#xff0c;简单说就是一个能自己沿着地面上的黑线&#xff08;或白线&#xff09;跑的智能小车。它看起来像玩具&#xff0c;但却是学习嵌入式开发、传感器应用、电机控制和基础算法最经典的练…

作者头像 李华
网站建设 2026/8/16 8:21:49

抽象类与接口核心区别:从IS-A与CAN-DO关系到实战场景选择

1. 项目概述&#xff1a;为什么我们需要区分抽象类和接口&#xff1f;在Java或者C#这类面向对象的编程语言里混久了&#xff0c;你肯定绕不开两个概念&#xff1a;抽象类&#xff08;Abstract Class&#xff09;和接口&#xff08;Interface&#xff09;。乍一看&#xff0c;它…

作者头像 李华
网站建设 2026/8/16 8:20:34

AI写论文哪个软件最好?答案可能和你想的完全不一样

“AI写论文哪个软件最好&#xff1f;” 每次直播&#xff0c;弹幕里总有人刷这个问题。我一般会反问一句&#xff1a;“你问的是‘哪个能帮你写’&#xff0c;还是‘哪个敢让你查’&#xff1f;” 这两个问题的答案&#xff0c;天差地别。 先泼一盆冷水&#xff1a;通用AI的…

作者头像 李华
网站建设 2026/8/16 8:14:58

钉钉客户端--oauth2网页登录

直接使用桌面钉钉客户端登录系统&#xff1a;代码案例&#xff1a;import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import jakarta.servlet.http.HttpSession; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; imp…

作者头像 李华