4. 驱动并发控制:自旋锁使用与陷阱、互斥体与信号量、原子操作与RCU机制、中断上下文中的锁
并发控制,说白了就是给共享资源上把锁。我早年做驱动时,觉得这玩意儿就是调个API,后来被坑了几次才明白——锁用不对,系统直接死给你看。
咱们这一章,就把Linux内核里最常见的几种并发机制掰开揉碎。自旋锁、互斥体、信号量、原子操作、RCU,还有中断上下文里那些让人头疼的锁问题。你想想看,一个驱动跑在多核CPU上,中断随时可能来,抢占随时可能发生,不加锁?数据乱成一锅粥。
4.1 自旋锁:轻量级但别乱用
自旋锁,顾名思义,拿不到锁就原地打转。它不会让出CPU,适合短临界区。我习惯在中断处理函数里用它,因为那里不能睡眠。
// 自旋锁基本用法
spinlock_t my_lock;
spin_lock_init(&my_lock);
spin_lock(&my_lock);
// 临界区代码,执行时间要短
spin_unlock(&my_lock);
嗯,这里要注意。自旋锁在单核CPU上,如果不开内核抢占,其实是个空操作。但多核环境下,它才是真正的锁。
4.1.1 自旋锁的陷阱
我曾经在项目里犯过一个低级错误:在持有自旋锁的代码里调用了kmalloc(GFP_KERNEL)。结果呢?内存分配触发了睡眠,锁没释放,其他核等着,系统直接卡死。
copy_from_user()、kmalloc(GFP_KERNEL)、mutex_lock()。
还有一个坑——中断上下文。如果你的锁既在普通进程上下文用,又在中断处理函数里用,必须用spin_lock_irqsave()来保存中断状态。否则中断来了,打断正在持有锁的进程,死锁就来了。
// 中断上下文安全的自旋锁
unsigned long flags;
spin_lock_irqsave(&my_lock, flags);
// 临界区
spin_unlock_irqrestore(&my_lock, flags);
4.2 互斥体与信号量:该睡就睡
互斥体(mutex)和信号量(semaphore)都是可以睡眠的锁。说白了,拿不到锁就让出CPU,适合临界区比较长的场景。
| 特性 | 互斥体 (mutex) | 信号量 (semaphore) |
|---|---|---|
| 允许持有者 | 同一时刻只能一个任务持有 | 允许多个任务(计数信号量) |
| 递归 | 不支持(会死锁) | 不支持 |
| 优先级继承 | 支持(防止优先级反转) | 不支持 |
| 中断上下文 | 禁止使用 | 只能使用down_trylock() |
| 性能 | 优化较好 | 相对较重 |
我个人习惯:能用互斥体就别用信号量。互斥体有优先级继承机制,能有效防止优先级反转问题。我记得有个项目,一个低优先级任务拿着锁,高优先级任务等着,中间还有个中优先级任务抢占了低优先级任务——这就是经典的优先级反转。互斥体能自动处理这个。
// 互斥体用法
struct mutex my_mutex;
mutex_init(&my_mutex);
mutex_lock(&my_mutex);
// 临界区,可以睡眠
mutex_unlock(&my_mutex);
down_trylock()在中断里试一下,但我建议你尽量别这么干。
4.3 原子操作:最简单的并发控制
原子操作,就是不可分割的操作。说白了,一条指令完成读写改,中间不会被中断打断。适合简单的计数器、标志位。
// 原子变量操作
atomic_t counter = ATOMIC_INIT(0);
atomic_inc(&counter); // 原子加1
atomic_dec(&counter); // 原子减1
int val = atomic_read(&counter); // 读取值
你想想看,如果不用原子操作,两个CPU同时对一个变量做counter++,结果可能只加了1。为什么?因为counter++在汇编层面是读-改-写三步,中间可能被另一个核打断。
我在调试一个网络驱动时,遇到过统计计数不准的问题。排查了半天,发现就是没用原子操作。改成atomic_inc()后,数据就对了。
4.4 RCU机制:读多写少的利器
RCU(Read-Copy-Update),读-复制-更新。这是Linux内核里最优雅的并发机制之一。它让读操作完全不用加锁,写操作通过延迟释放来保证安全。
说白了,RCU适合那种读操作远远多于写操作的场景。比如路由表、文件系统目录项缓存。
// RCU读端
rcu_read_lock();
struct my_data *p = rcu_dereference(global_ptr);
if (p) {
// 读取p的数据,安全
}
rcu_read_unlock();
// RCU写端
struct my_data *new = kmalloc(sizeof(*new), GFP_KERNEL);
struct my_data *old = rcu_dereference_protected(global_ptr, lockdep_is_held(&my_mutex));
rcu_assign_pointer(global_ptr, new);
synchronize_rcu(); // 等待所有读端完成
kfree(old);
嗯,这里有个关键点:synchronize_rcu()会阻塞,直到所有CPU上的读端临界区都退出。这个等待时间可能很长,所以写操作不能频繁执行。
4.5 中断上下文中的锁:最考验功底的地方
中断上下文里的锁,是驱动开发里最容易出问题的地方。我见过太多驱动因为中断里用错了锁,导致系统随机死机。
核心规则就几条:
- 中断里只能用自旋锁,而且必须用
_irqsave变体 - 绝对不能睡眠,所以互斥体、信号量(除了
down_trylock)都不能用 - 临界区要极短,中断处理函数里停留时间越长,系统响应越差
// 中断上下文中的正确用法
static irqreturn_t my_irq_handler(int irq, void *dev_id)
{
unsigned long flags;
struct my_device *dev = dev_id;
spin_lock_irqsave(&dev->lock, flags);
// 处理硬件寄存器,更新状态
dev->status |= IRQ_PENDING;
spin_unlock_irqrestore(&dev->lock, flags);
return IRQ_WAKE_THREAD; // 唤醒下半部
}
我建议把复杂逻辑放到下半部(tasklet或workqueue)里处理。中断上半部只做最必要的事,比如读取硬件状态、清除中断标志,然后唤醒下半部去慢慢处理。
printk(),以为只是打印个日志。结果printk()内部可能触发控制台锁,而这个锁在另一个中断里被持有——死锁了。中断里尽量别调任何可能加锁的函数。
4.6 知识体系总览
下面这张图,把咱们这一章的核心逻辑串起来了。你保存下来,写驱动时对照着看。
这张图把咱们讲的四种机制分成了三大类。自旋锁系列适合中断上下文和短临界区;睡眠锁系列适合进程上下文和长临界区;无锁机制(原子操作和RCU)适合特定场景,用好了性能极高。
最后说一句:并发控制没有万能钥匙。我每次写驱动前,都会先想清楚——这个共享资源会被谁访问?中断?进程?多个CPU?想清楚了再选锁。别上来就抄代码,那是最容易出事的。