1. 一次偶发乱码,把我拽进并发与竞争的坑
好几年前,我在给一块采集板卡写字符设备驱动。功能很简单:一个全局计数器统计中断发生次数,用户态通过 read() 读出来。刚开始单板单进程测试一切正常,后来换了四核平台,程序开了两个线程同时读,偶尔读出来一个完全不合逻辑的数字,比如上一秒还是 1024,下一秒直接变成 0x2c0013 这种乱七八糟的值。
我很长时间没想明白问题出在哪。代码逻辑明明是对的,控制流也经过反复检查,为什么结果会随机错?后来有个同事提醒我:你那个count++是不是会被打断?这才把我引向 Linux 驱动开发里绕不开的一个坎——并发与竞争。
所谓并发,是指多个执行路径同时进入同一段代码;所谓竞争,则是这些路径在没有同步保护的情况下,同时访问了共享数据,最终结果取决于谁先跑、谁后跑,完全不可控。驱动之所以比普通用户态程序更容易踩这种坑,是因为它的执行路径实在太多:多核 CPU 上同时跑的多个进程、随时可能到来的中断、软中断、内核抢占、甚至 CPU 热插拔和休眠唤醒,每一个都会把原本“串行”的假设打破。
这篇文章就围绕 Linux 驱动开发中的并发与竞争展开,从竞争产生的条件、各种并发原语的取舍,到真实驱动的加锁改造,再到死锁排查的工具链,完整过一遍。适合正在做驱动开发、或者内核模块写得差不多但总觉得“偶尔不对劲”的读者。
2. 竞争是怎么发生的:一条共享数据牵出四条执行路径
2.1 并发源头:不是只有“多进程”才算并发
很多初学者对并发有个误解,认为只有多个线程同时跑才叫并发。放在驱动的场景里,并发源至少有以下几类:
- SMP 多核并行:两个 CPU 核心同时执行驱动里同一个函数,两个执行流真真切切地在同一时刻访问同一变量。
- 中断与软中断抢占:进程正在 read() 里执行,突然来了硬件中断,中断服务程序(ISR)里也访问这块共享数据。ISR 和原进程是“你打断我,我还没恢复”的关系,这也是一种并发。
- 内核抢占:内核配置了
CONFIG_PREEMPT,一个进程在临界区里执行时,另一个高优先级进程可能被调度进来。 - 休眠与唤醒:进程在等待某个事件时主动睡眠,醒来后数据可能已经被其他路径改过。
所以,即使你只在一个单核大哥大式的 CPU 上跑,中断和抢占也会造成并发。驱动代码里只要存在共享数据,就必须假设“任何时刻都有别人在碰它”。
2.2 临界区与竞态条件
我们把访问共享数据的那一小段代码称为临界区。竞争发生的充要条件有三个:
- 存在至少两条执行路径;
- 这些路径都会进入同一个临界区;
- 临界区内的操作不是原子的——也就是“读-改-写”中间可以被插队。
拿最典型的counter++来说,它在汇编层面往往对应“读取变量到寄存器、寄存器加一、写回变量”三条指令。CPU0 刚读完,CPU1 也读了同一个旧值,然后各自加一写回,最终只加了一次。这个 bug 跑一百次可能只出现一次,但一旦出现,数据错乱、系统崩溃都是可能的。
还有一种隐蔽的竞争是“检查后使用”(check-then-use)。比如驱动里先判断flag == READY,再根据buf指针去操作数据。如果判断完、还没来得及用,另一个执行路径把 flag 改掉了或者把 buf 释放了,就会出现空指针或堆破坏。这类问题比简单的计数丢失更难排查,因为报错的位置往往远离修改点。
3. 并发原语选型:先看上下文,再看临界区长短
Linux 内核提供了非常多的并发控制手段,选择哪一种是驱动开发中很重要的一个决策。我的判断顺序很固定:先问能不能睡眠,再问临界区多长,最后问读写比例。把这三个问题答完,选型基本不会错。
3.1 原子操作:最短临界区的答案
如果临界区只是简单地对一个整数做加减、置位、取反,那么根本不需要上锁,用原子操作就够了。内核里atomic_t是专门为这种场景设计的:
#include <linux/atomic.h> atomic_t counter; atomic_set(&counter, 0); atomic_inc(&counter); int val = atomic_read(&counter);atomic_inc在底层会使用带锁前缀的指令,比如 x86 上的lock inc,硬件保证整个操作不可打断。对绝大多数驱动来说,中断次数统计、引用计数、标志位翻转这类需求,用原子操作既简单又高效。它本质上也是所有更复杂锁机制的“地基”。
3.2 自旋锁:中断上下文只能靠它
当临界区不止一条指令,但执行时间又很短时,自旋锁是首选。自旋锁的特点是“拿不到就原地转圈等”,不会睡眠。这个特性让它成为唯一能在中断上下文(ISR、软中断)里使用的重量级保护手段。
自旋锁有两种典型形态:普通spinlock_t和底半部版本spinlock_bh_t。实际驱动里更常见的是这一套 API:
spinlock_t lock; spin_lock_init(&lock); // 进程上下文 + 可能被中断打断的场景 unsigned long flags; spin_lock_irqsave(&lock, flags); /* 临界区 */ spin_unlock_irqrestore(&lock, flags);spin_lock_irqsave会在加锁的同时把当前 CPU 中断状态保存下来,并且关掉中断,释放时再恢复原状。为什么要关中断?因为如果不关,同一 CPU 上 ISR 可能在你持锁期间插进来,而 ISR 也想拿同一把锁,就会造成死锁:进程等锁,锁在 ISR 手里,但 ISR 永远没机会执行。
自旋锁最大的坑是“临界区不能睡眠”。因为睡眠会触发调度,调度器又试图获取任务锁,而当前任务还提着自旋锁,最终结果是BUG: scheduling while atomic或者直接死锁。临界区里绝对不能调用copy_to_user、kmalloc(..., GFP_KERNEL)、msleep这类函数。
3.3 互斥锁与信号量:进程上下文的重量级方案
如果临界区比较长,或者涉及磁盘访问、用户态拷贝等可能睡眠的操作,就必须用互斥锁struct mutex。它跟自旋锁最大的区别是:锁不可得时,任务会进入睡眠状态,让出 CPU,等锁释放后由内核唤醒。
struct mutex lock; mutex_init(&lock); mutex_lock(&lock); /* 任意长临界区,允许睡眠 */ mutex_unlock(&lock);互斥锁的约束也很明确:
- 只能在进程上下文使用,中断里睡眠本身就不合法;
- 同一个锁不能递归获取,否则会自死锁;
- 续持锁时间可以很长,但也不宜过长,否则其他进程全部 D 状态排队,看起来像系统卡死。
信号量struct semaphore在处理驱动时出场率不高。它本质上是“可计数的互斥锁”,适合生产者-消费者模型中资源数量固定的场景。内核开发中的普遍建议是:只要不要求允许多个持有者,就优先用 mutex,因为语义更明确,锁调试更容易。
3.4 读写锁、顺序锁与 RCU:按读写比例细分
同样的临界区,如果读操作远多于写操作,让所有读者互相排挤就太浪费了。这时可以考虑:
读写自旋锁
rwlock_t:允许多个读者同时进入,写者必须独占。驱动场景里如果读者很频繁且临界区很短,可以尝试,但它有一个问题:读者会饿死写者。在我实际驱动开发里用它不多,统计类场景偶尔用。顺序锁
seqlock_t:这是为“写者优先、读者允许读到旧值也能忍受重试”设计的。读者在读的时候不做互斥,只检查序号;写者获取锁时序号加一。最经典的例子就是内核里的jiffies_64时间读取。
seqlock_t lock; unsigned long seq; do { seq = read_seqbegin(&lock); /* 读取需要一致的数据 */ } while (read_seqretry(&lock, seq));读者理论上可能在读到一半时被写者打断,所以要用read_seqretry判断这一轮是否有效,无效就重读。这个机制要求读者必须能容忍偶尔重试,否则不适合。
- RCU(Read-Copy Update):它把“读-改-写”优化成了“读-复制-改-发布”,读者侧可以完全不使用原子指令和锁,直接访问受保护数据。写者侧则要创建新版本,然后通过
rcu_assign_pointer发布,再等待所有读者离开旧版之后才能释放旧内存。
后面的第 5 节我会专门展开 RCU 的使用边界,这里只要记住一句话:RCU 是“读多写少、读者不能睡眠受限少”场景下性能最漂亮的方案,但正确使用它需要理解宽限期(grace period)概念,新手上手成本并不低。
3.5 一个小型决策表
| 场景 | 推荐方案 | 能否睡眠 | 典型上下文 |
|---|---|---|---|
| 整数计数 / 标志位 | 原子操作 | 否 | 任意 |
| 短临界区,多执行路径 | 自旋锁(加 irqsave) | 否 | 中断、软中断 |
| 长临界区,可能阻塞 | 互斥锁 | 是 | 进程上下文 |
| 资源数量固定,多个持有者 | 信号量 | 是 | 进程上下文 |
| 读多写少,短临界区 | 读写锁 | 否 | 进程/中断 |
| 写者优先,读者可重试 | 顺序锁 | 否 | 进程上下文 |
| 读多写少,链表/哈希遍历 | RCU | 读侧否 | 进程/中断 |
4. 实战改造:给一个字符设备驱动加上并发保护
4.1 第一版代码的问题在哪
假设我们写了一个虚拟传感器驱动,设备结构体如下:
struct sensor_dev { int enabled; int last_value; wait_queue_head_t wq; }; static struct sensor_dev *sdev; static ssize_t sensor_read(struct file *filp, char __user *buf, size_t count, loff_t *pos) { int val = sdev->last_value; /* 这里读的是共享数据 */ if (copy_to_user(buf, &val, sizeof(val))) return -EFAULT; return sizeof(val); }看起来完全没问题。可是中断服务程序里每隔几毫秒就会更新last_value,同时还可能翻转enabled:
static irqreturn_t sensor_irq(int irq, void *data) { sdev->last_value = read_adc(); /* 假设是个寄存器读 */ if (sdev->last_value > THRESHOLD) sdev->enabled = 0; return IRQ_HANDLED; }问题就来了:read()里读last_value时,对 32 位整数来说大部分架构能保证单次访问原子性,但如果平台是 64 位、寄存器读取被优化成两条指令,或者sensor_irq里对结构体多个字段的改动交错进来,数据就会出现中间状态。更要命的是,如果用户空间通过 ioctl 想同时修改enabled和last_value,两个字段必须一起变才合理,没有锁的话改动中间总会被其他路径看到。
这类 bug 的恐怖之处在于:它绝大多数时间是正常的,只在特定中断时序下出错,可能一周才复现一次。
4.2 用自旋锁保护中断相关的共享数据
第一步,先解决中断路径和数据读路径之间的竞争。因为 ISR 里不能睡眠,所以这里只能用自旋锁,而且必须用irqsave版本:
struct sensor_dev { int enabled; int last_value; wait_queue_head_t wq; spinlock_t lock; /* 新增 */ }; static ssize_t sensor_read(struct file *filp, char __user *buf, size_t count, loff_t *pos) { unsigned long flags; int val; spin_lock_irqsave(&sdev->lock, flags); val = sdev->last_value; spin_unlock_irqrestore(&sdev->lock, flags); if (copy_to_user(buf, &val, sizeof(val))) return -EFAULT; return sizeof(val); } static irqreturn_t sensor_irq(int irq, void *data) { unsigned long flags; spin_lock_irqsave(&sdev->lock, flags); sdev->last_value = read_adc(); if (sdev->last_value > THRESHOLD) sdev->enabled = 0; spin_unlock_irqrestore(&sdev->lock, flags); return IRQ_HANDLED; }注意read()中我把copy_to_user移到了锁外面。这是非常关键的一步:copy_to_user可能睡眠,一旦睡眠,持有着自旋锁的进程会被调度走,而中断又要抢同一把锁,整个系统直接转圈锁死。所以自旋锁的临界区里只保留最简单的数据读取,与用户空间的数据交换放到解锁之后。
4.3 为什么 read 路径最终选择 mutex 更合适
上面的方案能保证数据一致,但有一个性能问题:只要 ISR 频繁触发,用户态每读一次last_value都要跟中断去抢锁,中断优先级更高,读进程可能自旋等待很久。对一个传感器驱动来说,这种短锁竞争可以接受。可如果我把 ioctl 的用户配置操作也加进来,事情就变了。比如用户要一次性设置采样率、阈值、使能三个字段:
static long sensor_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct sensor_config cfg; if (copy_from_user(&cfg, (void __user *)arg, sizeof(cfg))) return -EFAULT; /* 多字段联动的配置操作,临界区很长 */ sdev->sample_rate = cfg.rate; sdev->threshold = cfg.threshold; sdev->enabled = cfg.enable; return 0; }如果还用自旋锁保护这段代码,copy_from_user会睡眠,等于直接踩雷。正确做法是把配置用互斥锁保护,中断仍然用自旋锁。驱动初始化里分别初始化两把锁:
static int sensor_probe(struct platform_device *pdev) { sdev = kzalloc(sizeof(*sdev), GFP_KERNEL); spin_lock_init(&sdev->lock); mutex_init(&sdev->cfg_lock); init_waitqueue_head(&sdev->wq); return 0; }ioctl 里这样写:
static long sensor_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct sensor_config cfg; if (copy_from_user(&cfg, (void __user *)arg, sizeof(cfg))) return -EFAULT; mutex_lock(&sdev->cfg_lock); /* 多字段联动赋值 */ mutex_unlock(&sdev->cfg_lock); return 0; }那last_value和cfg_lock保护的配置字段之间有没有关联?如果 ISR 要同时读取这些配置字段又没有拿cfg_lock,就会出现新的竞争。这需要你分析具体逻辑:如果 ISR 只访问last_value和enabled,而enabled又可能在 ioctl 里被配置修改,就存在 ISR 与 ioctl 之间的竞争。此时最简单的做法是不引入第二把锁,而是让配置路径也使用自旋锁,并且把copy_from_user放到拿锁之前。
这个案例给了一个启发:并发保护不是机械地在每个结构体里加锁,而是要把每一条访问共享数据的路径全部列出来,再决定锁的粒度和类型。漏一条路径,等于前面的锁全白装。
4.4 用 lockdep 检验锁的合法性
把锁加完之后,不要急着交付。内核自带一个强大的锁校验器CONFIG_PROVE_LOCKING,也就是常说的 lockdep。它在运行时记录每次加锁的上下文和顺序,一旦发现潜在死锁就会打印详细警告。
尽量在开发版内核里开启以下选项:
CONFIG_PROVE_LOCKING=y CONFIG_LOCKDEP=y CONFIG_DEBUG_ATOMIC_SLEEP=y CONFIG_DEBUG_SPINLOCK=y拿前面 4.3 的例子测试,如果我在 spinlock 临界区里误写了copy_from_user,内核会立刻告诉我:
BUG: sleeping function called from invalid context at mm/slub.c:... in_atomic(): 1, irqs_disabled(): 1, non_block: 0, pid: ...这行日志的意思是:你在原子上下文(拿着自旋锁、关着中断)里调用了一个会睡眠的函数。看到这类输出,第一步要做的不是搜 Google,而是看自己当前持有哪些锁,然后把睡眠调用移到锁外面。
我个人的流程是这样的:每次驱动改完,先跑一遍正常的 open/read/write/close 测试,再写一个多线程压力脚本并发访问同一个设备节点,开着 lockdep 跑一晚上。只要 lockdep 没报新警告,才敢把模块交出去。
5. 死锁与错误锁序:内核日志里那些醒目的警告
5.1 锁顺序反转:最经典的死锁原因
当你持有锁 A 想拿锁 B,而另一个执行路径持有锁 B 想拿锁 A,两个执行路径就互相等成了死锁。在驱动里,这种问题几乎都来自“全局锁顺序”没定死。
举一个我实际遇到过的情况。驱动里有两个自旋锁:一个保护描述符链表list_lock,一个保护硬件寄存器映射reg_lock。某条路径先拿list_lock再拿reg_lock;另一条路径在寄存器配置成功后,回调里又去遍历链表,先拿reg_lock再拿list_lock。当两个 CPU 恰好同时跑到这两个函数时,就像两个人过独木桥,谁也不让谁。
内核为此专门引入了“锁分类”的概念。如果你在整个驱动里约定:任何代码必须先获取list_lock,再获取reg_lock,禁止反序,那这个死锁就不可能发生。这种约定要写进代码注释,让后来的维护者也能看到。
5.2 中断上下文自旋锁死锁的典型特征
不加irqsave的自旋锁很容易在中高负载下暴露问题。比如进程上下文持有锁,中断到来后 ISR 也去抢这把锁。因为 ISR 优先级高,它一旦执行,就等待已经被打断的进程释放锁,而被中断的进程要等 ISR 返回才能继续,两边都卡死。这时系统表现通常是:某个 CPU 的软锁死,日志里反复出现:
watchdog: BUG: soft lockup - CPU#0 stuck for 22s!解决方式就是我在 4.2 里演示的spin_lock_irqsave。这个 API 在加锁同时关掉当前 CPU 的中断,从根本上杜绝了“同一 CPU 中断抢锁”的可能。
5.3 lockdep 警告日志怎么读
lockdep 最典型的输出是 “possible circular locking dependency detected”,后面会跟一段加锁历史。它的核心信息在于两个栈回溯:第一个栈显示当前执行路径拿锁的顺序,第二个栈显示之前某条路径拿锁的顺序。两边一比较,你就能看出环路在哪里。
我读这类日志的方法很简单:
- 看警告第一行是哪个锁(
lock: ...); - 看两处栈回溯里持有锁的函数名;
- 在源码里找到这两个函数,画出它们的锁获取顺序;
- 按“全驱动统一锁顺序”的原则修改其中一处。
lockdep 还有个好处,它不仅能报潜在死锁,还能报“重复加锁”“在带锁的情况下睡眠”等问题。很多看似“偶发”的驱动崩溃,开着 lockdep 跑压力测试后都能主动暴露。
6. 进阶并发机制:per-CPU、RCU、seqlock 的正确边界
6.1 per-CPU 变量:与其保护共享,不如消除共享
并发问题的根源是多个执行路径同时访问同一份数据。那如果每个 CPU 都有一份独立的数据副本,不就彻底没有竞争了吗?这就是 per-CPU 变量的思路。
DEFINE_PER_CPU(unsigned long, stats_count); unsigned long get_stats(void) { return per_cpu(stats_count, smp_processor_id()); } void inc_stats(void) { unsigned long *p = this_cpu_ptr(&stats_count); (*p)++; }在驱动的统计路径里这招很管用:每个 CPU 只更新自己的计数,最后汇总时再遍历所有 CPU。好处不仅是消除锁竞争,还能避免多核之间的缓存行互相失效——专业术语叫 false sharing。代价是读到的数据可能是“某个 CPU 的瞬时值”,最终聚合时需要注意一致性,对于计数类场景通常可以接受。
6.2 RCU 的宽限期:读随便读,写要等
RCU 最让我上头的点在于:读者侧完全不用锁,甚至可以不带任何原子操作去遍历链表。写者需要把旧节点拷贝一份,修改完用rcu_assign_pointer切换指针,然后调用synchronize_rcu()等待所有读者退出临界区,之后才敢 kfree 旧节点。
一个简化的链表替换示例:
struct foo { int val; struct rcu_head rcu; }; static struct foo __rcu *g_foo; static int read_foo(void) { struct foo *p; rcu_read_lock(); p = rcu_dereference(g_foo); rcu_read_unlock(); return p ? p->val : -1; } static void update_foo(int new_val) { struct foo *old, *new; new = kmalloc(sizeof(*new), GFP_KERNEL); new->val = new_val; old = rcu_dereference_protected(g_foo, lockdep_is_held(&mutex)); rcu_assign_pointer(g_foo, new); synchronize_rcu(); kfree(old); }这里有个很需要警惕的细节:synchronize_rcu()是在进程上下文阻塞等待,不是中断上下文能用的。而且如果写者路径很短、又频繁,RCU 反而因为synchronize_rcu()的等待开销变得比互斥锁还慢。所以 RCU 适合“读者极多且频繁、写者极少”的场景,典型如网络路由表、文件系统缓存。驱动里如果你的设备列表只在加载/卸载时变化,运行期只有读,那 RCU 是很漂亮的选择。
6.3 seqlock 与读写锁:数据一致性优先时怎么选
seqlock 适合写者频繁而且读者能接受偶尔重读的场景。内核时间子系统就是典型代表。驱动里如果硬件提供一个 64 位时间戳寄存器,读操作需要先读高 32 位和低 32 位,两次读取之间硬件可能进位,就必须用 seqlock 保证一致性。读者拿到序号后读取,之后检查序号没变就说明读取过程中没有写者介入。
读写锁则相反,它保证读者在读期间写者不会介入,但读者之间互补相斥。要注意的是读写锁在读者很多时会饿死写者,所以内核社区对rwlock_t的态度是“能用 rcu 或 seqlock 就别用 rwlock”。驱动代码里真正适合 rwlock 的场景很少,我自己写驱动这么多年,也就见过硬件状态表这类读极多、写极少且临界区极短的场景用过。
6.4 别把并发保护做成“过度设计”
最后讲一个反面案例。有次看同事维护的驱动,结构体里同时放了spinlock_t、mutex、seqlock_t,还有一个 RCU 指针,每个字段都有不同锁保护。功能确实正确,但每次改需求都要反复确认“这个字段该持哪把锁”,代码 review 成了推理大赛。后来我们统一评估,把大部分字段拆成两把锁:中断路径数据用自旋锁,配置和管理路径全走互斥锁,性能几乎没差,可维护性明显提升。
驱动并发保护的终极目标只有一个:在保证正确性的前提下,做到让开发者能一眼看出锁的归属。复杂的锁设计如果没人能维护,迟早会变成下一个线上事故。
7. 写在最后的一点实际经验
做了几年驱动开发,我对并发与竞争最大的体会是:这类问题不像语法错误那样能被编译器报出来,它藏在代码里,可能几个月不发作,但一旦发作就是“概率性偶发”的疑难杂症。所以我现在写任何驱动,都会先把共享数据列一张表,把每一条访问路径标出来,再决定用哪种锁。这个过程看起来多花两小时,但能省下一周调试时间。
另一个重要的习惯是:拿到一份不熟悉的驱动代码,先看它初始化了几把锁,然后去找 lockdep 开没开。不开 lockdep,你的压力测试再猛,也只能测出已经发生的死锁,测不出潜在的死锁。开了 lockdep,哪怕只跑一遍常规流程,很多锁顺序问题都会自动暴露出来。
最后分享一个小技巧:在临界区入口加lockdep_assert_held(&lock)。这个宏在开启 lockdep 时会校验当前是否真的持有这把锁,没持有就直接报警。它相当于给代码加了一道运行时断言,很多时候比写注释管用得多。把这些习惯坚持下来,并发与竞争就不会再是驱动开发里最痛苦的部分,反而会成为你代码质量的试金石。