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上的读端临界区都退出。这个等待时间可能很长,所以写操作不能频繁执行。

RCU核心思想: 读不阻塞写,写不阻塞读。写操作先发布新数据,等所有读操作都结束后,再释放旧数据。

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 知识体系总览

下面这张图,把咱们这一章的核心逻辑串起来了。你保存下来,写驱动时对照着看。

Linux驱动并发控制机制总览 并发访问共享资源 自旋锁系列 睡眠锁系列 无锁机制 spin_lock / spin_lock_irqsave 读写自旋锁 (rwlock) 互斥体 (mutex) 信号量 (semaphore) 原子操作 (atomic_t) RCU (Read-Copy-Update) 适用场景:自旋锁→短临界区/中断上下文 | 睡眠锁→长临界区/进程上下文 | 无锁→读多写少/简单计数

这张图把咱们讲的四种机制分成了三大类。自旋锁系列适合中断上下文和短临界区;睡眠锁系列适合进程上下文和长临界区;无锁机制(原子操作和RCU)适合特定场景,用好了性能极高。

最后说一句:并发控制没有万能钥匙。我每次写驱动前,都会先想清楚——这个共享资源会被谁访问?中断?进程?多个CPU?想清楚了再选锁。别上来就抄代码,那是最容易出事的。