驱动中的并发与竞态控制
并发与竞态,说白了就是多个执行路径同时访问共享资源时产生的冲突。我刚开始写驱动时,总觉得加锁是件麻烦事,能省就省。直到有一次,一个中断处理程序和任务共享的全局变量没保护,结果设备跑着跑着就挂了,查了两天才找到原因。嗯,从那以后我再也不敢轻视并发问题了。
并发与竞态的概念
并发,就是多个执行单元同时运行。在Linux内核里,并发场景特别多:
- SMP多核:两个CPU核同时执行驱动代码
- 中断与进程:中断处理程序和普通进程共享数据
- 抢占内核:进程A执行到一半,被更高优先级的进程B抢占
- 软中断/tasklet:这些机制本身就可能并发执行
竞态,就是并发访问导致的结果不确定性。举个例子:
// 两个CPU同时执行这段代码
static int counter = 0;
void increment(void) {
counter++; // 这条指令不是原子的!
}
你想想看,counter++ 在汇编层面可能是三条指令:读、加、写。两个CPU同时执行,结果可能只加了1次,而不是2次。这就是典型的竞态。
核心原则:只要共享数据可能被并发访问,就必须用某种同步机制保护起来。没有例外。
原子操作
原子操作是最轻量的同步手段。它保证操作要么全部执行完,要么完全不执行,中间不会被中断。
内核提供了两类原子操作:
| 类型 | API示例 | 适用场景 |
|---|---|---|
| 原子整数 | atomic_t, atomic_inc(), atomic_dec_and_test() | 计数器、标志位 |
| 原子位操作 | set_bit(), clear_bit(), test_and_set_bit() | 位图、状态标志 |
我个人习惯,能用原子操作解决的问题,绝不用锁。比如驱动里的引用计数、设备状态标志,用 atomic_t 就够了,既简单又高效。
// 原子操作示例
static atomic_t usage_count = ATOMIC_INIT(0);
int open_device(struct inode *inode, struct file *filp) {
if (atomic_inc_and_test(&usage_count)) {
// 第一次打开,初始化硬件
return 0;
}
// 已经打开过了,直接返回
return 0;
}
int release_device(struct inode *inode, struct file *filp) {
if (atomic_dec_and_test(&usage_count)) {
// 最后一次关闭,关闭硬件
}
return 0;
}
小技巧:atomic_t 在ARM上使用LDREX/STREX指令实现,在x86上使用LOCK前缀。它比自旋锁轻量得多,但只能保护简单的整数操作。
自旋锁
自旋锁的特点是:如果锁被占用,申请锁的线程会原地打转(自旋),直到锁被释放。它不会让出CPU,所以适合短时间的临界区。
我在项目中遇到过一个问题:中断处理函数里用了互斥锁,结果直接死锁了。为什么?因为互斥锁可能导致睡眠,而中断上下文是不能睡眠的。这时候只能用自旋锁。
// 自旋锁基本用法
spinlock_t my_lock;
spin_lock_init(&my_lock);
void my_function(void) {
unsigned long flags;
spin_lock_irqsave(&my_lock, flags);
// 临界区代码
// 这里不能睡眠,不能调用可能睡眠的函数
spin_unlock_irqrestore(&my_lock, flags);
}
自旋锁有几个变种,你得根据场景选:
| 变种 | 说明 | 使用场景 |
|---|---|---|
| spin_lock() / spin_unlock() | 普通自旋锁 | 进程上下文,没有中断竞争 |
| spin_lock_irq() / spin_unlock_irq() | 禁止本地中断 | 进程上下文,中断也会访问共享数据 |
| spin_lock_irqsave() / spin_unlock_irqrestore() | 保存中断状态并禁止中断 | 不确定当前中断状态时使用 |
| spin_lock_bh() / spin_unlock_bh() | 禁止软中断 | 进程上下文与软中断共享数据 |
注意:自旋锁持有时间一定要短!我见过有人把耗时的硬件操作放在自旋锁里,结果其他CPU核都在空转,系统性能直接崩了。自旋锁的临界区,最好控制在几十条指令以内。
信号量与互斥锁
信号量和互斥锁的共同点是:如果锁被占用,申请者会睡眠,让出CPU。它们适合临界区较长、或者可能睡眠的场景。
互斥锁(struct mutex)是信号量的特例,但更高效、更安全。我建议你优先用互斥锁,而不是信号量。
// 互斥锁示例
struct mutex my_mutex;
mutex_init(&my_mutex);
int ioctl_handler(struct file *filp, unsigned int cmd, unsigned long arg) {
mutex_lock(&my_mutex);
// 这里可以睡眠,可以调用copy_from_user等函数
// 做一些耗时的操作
mutex_unlock(&my_mutex);
return 0;
}
信号量(struct semaphore)现在用得少了,但有一个场景它仍然不可替代:资源计数。比如一个硬件只有3个DMA通道,可以用信号量来管理。
| 特性 | 互斥锁 | 信号量 |
|---|---|---|
| 所有者 | 只能由持有者释放 | 任何进程都可以释放 |
| 递归 | 不支持(会死锁) | 不支持 |
| 计数 | 只有0和1 | 可以大于1 |
| 调试 | 有内置调试支持 | 没有 |
| 推荐度 | 优先使用 | 只在计数场景使用 |
经验之谈:互斥锁和自旋锁的选择很简单——临界区短且不会睡眠,用自旋锁;临界区长或可能睡眠,用互斥锁。别搞反了,搞反了就是bug。
读写锁与RCU机制
读写锁(rwlock_t)和RCU(Read-Copy-Update)都是针对读多写少场景优化的同步机制。
读写锁允许多个读者同时进入临界区,但写者必须独占。我做过一个网络驱动,路由表查询是典型的读多写少场景,用读写锁后性能提升明显。
// 读写锁示例
rwlock_t my_rwlock;
rwlock_init(&my_rwlock);
// 读路径
void read_data(void) {
read_lock(&my_rwlock);
// 多个读者可以同时进入
read_unlock(&my_rwlock);
}
// 写路径
void write_data(void) {
write_lock(&my_rwlock);
// 写者独占,等待所有读者退出
write_unlock(&my_rwlock);
}
RCU就更高级了。它允许读者完全不加锁,写者通过替换指针的方式更新数据。读者几乎零开销,但写者需要等待所有读者退出后才能释放旧数据。
// RCU示例(简化版)
struct my_data {
int value;
struct rcu_head rcu;
};
struct my_data __rcu *gptr;
// 读路径
void read_rcu(void) {
rcu_read_lock();
struct my_data *p = rcu_dereference(gptr);
// 使用p指向的数据
rcu_read_unlock();
}
// 写路径
void update_rcu(void) {
struct my_data *new = kmalloc(sizeof(*new), GFP_KERNEL);
struct my_data *old;
new->value = 42;
old = rcu_replace_pointer(gptr, new, lockdep_is_held(&some_lock));
synchronize_rcu(); // 等待所有读者退出
kfree(old);
}
RCU的坑:我曾经在RCU读端临界区里调用了可能睡眠的函数,结果系统直接报错。记住,rcu_read_lock() 到 rcu_read_unlock() 之间不能睡眠,不能调用可能引起调度的函数。
选择合适锁机制的实战经验
说了这么多,到底怎么选?我总结了一个决策流程:
我再补充几条实战经验:
- 能用原子操作就别用锁:原子操作零开销,锁再轻量也有成本
- 中断上下文只能用自旋锁:千万别用互斥锁,会死锁
- 读多写少优先考虑RCU:但RCU实现复杂,别为了炫技而用
- 锁的粒度要适中:太粗了性能差,太细了容易死锁
- 加锁顺序要一致:多个锁时,所有路径的加锁顺序必须相同,否则死锁
我的建议:新手先从互斥锁和自旋锁开始,把这两个用熟了,再研究RCU。别一上来就想用最复杂的方案,简单可靠的代码才是好代码。
嗯,并发控制这块内容就这些。记住一个原则:共享数据必须保护,但保护方式要选对。选错了,要么性能差,要么出bug。多想想你的场景,多看看内核源码里别人怎么用的,慢慢就有感觉了。