4. 驱动中的并发控制:自旋锁、信号量、互斥锁、读写锁、原子操作

并发控制,说白了就是让多个执行流别打架。

我在做嵌入式驱动开发的头两年,吃过不少并发的亏。有一次调试一个 SPI 设备驱动,数据莫名其妙地错乱,查了三天才发现是中断上下文和进程上下文同时操作了一个全局缓冲区。嗯,从那以后,我对并发控制就格外上心。

今天咱们就把 Linux 内核里最常见的几种并发控制手段捋一遍。你想想看,驱动里要面对的场景其实就几种:多核 CPU 同时跑、中断来了要抢资源、进程睡眠了怎么办。不同的场景,用的工具不一样。

4.1 自旋锁(Spinlock)

自旋锁,名字就挺形象——拿不到锁就原地打转。它不会让出 CPU,而是忙等待,直到锁被释放。

我个人的习惯是:临界区代码极短(比如几十条指令),并且确定不会睡眠,才用自旋锁。为什么?因为自旋锁持有期间,当前 CPU 核心一直在空转,浪费电不说,还影响系统响应。

核心要点:

  • 自旋锁只能在 不会睡眠的上下文 中使用(进程上下文、软中断、硬中断都可以,但要区分变体)
  • 持有自旋锁期间,不能调用可能睡眠的函数(如 copy_from_userkmalloc(GFP_KERNEL)
  • 多核系统下,自旋锁能防止其他核同时访问;单核非抢占内核下,自旋锁退化为空操作

代码示例:

#include <linux/spinlock.h>

spinlock_t my_lock;
spin_lock_init(&my_lock);

/* 进程上下文使用 */
spin_lock(&my_lock);
/* 临界区:操作共享数据 */
spin_unlock(&my_lock);

/* 中断上下文使用(需要保存中断状态) */
unsigned long flags;
spin_lock_irqsave(&my_lock, flags);
/* 临界区 */
spin_unlock_irqrestore(&my_lock, flags);

我曾经踩过的坑:在自旋锁保护的临界区里调用了 printk,结果 printk 内部可能触发控制台驱动的自旋锁,直接死锁。所以,自旋锁里尽量别干复杂的事。

4.2 信号量(Semaphore)

信号量是个老古董了,从 Unix 时代就存在。它允许指定数量的线程同时进入临界区。比如计数信号量初始值为 3,那最多允许 3 个线程同时访问。

不过说实话,我现在很少在内核驱动里直接用信号量。为什么?因为信号量允许睡眠,但语义不够明确。你看到 down()up(),很难一眼看出这是互斥还是计数。而且信号量没有所有者概念,谁都可以释放,容易出错。

#include <linux/semaphore.h>

struct semaphore sem;
sema_init(&sem, 1);  /* 初始值为1,相当于互斥 */

if (down_interruptible(&sem)) {
    /* 被信号中断,返回非0 */
    return -ERESTARTSYS;
}
/* 临界区 */
up(&sem);

我的建议:除非你需要一个计数信号量(比如控制有限数量的硬件资源),否则优先考虑互斥锁。信号量在驱动中最大的用途其实是 生产者-消费者模型,但这种情况用 wait_queue 更常见。

4.3 互斥锁(Mutex)

互斥锁是信号量的“进化版”,专门为互斥场景设计。它比信号量多了几个限制:

  • 谁加锁,谁解锁,不能跨上下文
  • 递归加锁会死锁(不像信号量可以递归)
  • 进程持有互斥锁时,可以睡眠(但睡眠期间锁不会释放)

我个人最喜欢用互斥锁。它语义清晰,调试方便。内核里还内置了 锁调试(lockdep) 功能,能帮你检测潜在的死锁。

#include <linux/mutex.h>

struct mutex my_mutex;
mutex_init(&my_mutex);

mutex_lock(&my_mutex);
/* 临界区:可以睡眠,可以调用 copy_from_user */
mutex_unlock(&my_mutex);

什么时候用互斥锁?

  • 临界区代码较长,或者可能睡眠
  • 在进程上下文中保护共享数据
  • 需要递归加锁?不行,互斥锁不支持递归,请用 mutex_lock_nested 处理嵌套场景

你想想看,如果临界区里要拷贝用户态数据(copy_from_user),那必须用互斥锁。因为 copy_from_user 可能触发缺页异常,导致进程睡眠。自旋锁下睡眠?那系统就挂了。

4.4 读写锁(RW Lock)

读写锁是个好东西。它把锁分成了两种角色:读者写者。多个读者可以同时持有读锁,但写者必须独占。

我在项目中遇到过这样一个场景:一个设备状态表,大部分时间是读取,偶尔才更新。如果用普通互斥锁,读操作之间也会互相阻塞,性能白白浪费。换成读写锁,读并发度一下就上来了。

#include <linux/rwlock.h>

rwlock_t my_rwlock;
rwlock_init(&my_rwlock);

/* 读路径 */
read_lock(&my_rwlock);
/* 读取共享数据 */
read_unlock(&my_rwlock);

/* 写路径 */
write_lock(&my_rwlock);
/* 修改共享数据 */
write_unlock(&my_rwlock);

注意:读写锁也有自旋锁的“不能睡眠”限制。如果你需要睡眠,请使用 RCU(Read-Copy Update) 或者 down_read/down_write(信号量风格的读写锁)。

读写锁的变体 rwlock_trwsem 区别很大:

类型 能否睡眠 适用场景
rwlock_t 不能 中断上下文、短临界区
struct rw_semaphore 进程上下文、长临界区

4.5 原子操作(Atomic Operations)

原子操作是并发控制的“终极轻量级武器”。它不需要锁,直接由 CPU 指令保证操作的不可分割性。

说白了,就是对一个整型变量做 读-改-写 时,中间不会被其他执行流打断。

#include <linux/atomic.h>

atomic_t counter = ATOMIC_INIT(0);

/* 原子加1 */
atomic_inc(&counter);

/* 原子减1,并判断结果是否为0 */
if (atomic_dec_and_test(&counter)) {
    /* 计数器减到0,执行清理 */
}

/* 原子读 */
int val = atomic_read(&counter);

原子操作在驱动里最常见的用途:

  • 引用计数:比如设备打开次数、缓冲区使用计数
  • 标志位操作:用 set_bitclear_bittest_and_set_bit 等位原子操作
  • 无锁统计:比如统计中断次数、丢包计数

我的经验:能用原子操作解决的问题,就别上锁。原子操作没有上下文切换,没有缓存一致性开销(相对较小),性能最好。但原子操作只能处理简单的整型或位操作,复杂的数据结构还是得靠锁。

4.6 如何选择?一张表说清楚

我经常被问到:“到底该用哪个?” 嗯,这里给个快速决策表:

场景 推荐工具 原因
临界区极短(< 10条指令),不会睡眠 自旋锁 开销最小,无上下文切换
临界区较长,可能睡眠 互斥锁 允许睡眠,语义清晰,有 lockdep 调试
读多写少,临界区短 读写锁(rwlock_t) 提高读并发度
读多写少,临界区长 读写信号量(rwsem) 允许睡眠,适合进程上下文
简单计数或标志位 原子操作 无锁,性能最高
中断上下文与进程上下文共享 自旋锁(irqsave 变体) 必须关中断,防止死锁

4.7 避坑指南

最后,分享几个我亲身经历的教训:

  • 我曾经在自旋锁里调用了 msleep——嗯,系统直接死锁。自旋锁持有期间绝对不能睡眠。
  • 我曾经忘记初始化锁——内核崩溃,查了半天才发现 spin_lock_init 没调用。静态定义的锁一定要初始化。
  • 我曾经在中断处理函数里用 mutex_lock——编译没报错,运行就死锁。中断上下文只能用自旋锁。
  • 我曾经用原子操作保护一个结构体——原子操作只能处理整型,结构体赋值不是原子的,必须用锁。

并发控制是驱动开发的核心基本功。你想想看,一个驱动如果并发没处理好,轻则数据错乱,重则系统崩溃。希望今天的内容能帮你理清思路,少走弯路。