4. 并发与竞态控制:自旋锁、互斥体、原子操作、RCU机制

并发,是驱动开发里绕不开的坎儿。说白了,就是多个执行路径同时访问同一份数据。我刚开始做驱动时,总觉得只要代码逻辑对就行,结果有一次在 SMP 板子上跑压力测试,系统随机崩溃,查了三天才发现是全局变量没加锁。嗯,从那以后,我对并发就再也不敢马虎了。

嵌入式 Linux 里的并发源很多:中断上下文、软中断、多核 CPU、进程抢占……任何一个都可能把你的数据撕成碎片。今天我们就来把这四种武器讲透:自旋锁、互斥体、原子操作、RCU。

核心原则: 保护共享数据,而不是保护代码。锁的粒度越细,性能越好。
并发与竞态控制机制全景图 并发控制机制 自旋锁 互斥体 原子操作 RCU 机制 自旋锁 忙等待,不睡眠 适合短临界区 互斥体 可睡眠,上下文切换 适合长临界区 原子操作 硬件级不可分割 适合计数器/标志位 RCU 读多写少,无锁读

4.1 自旋锁:轻量级的忙等待

自旋锁,顾名思义——拿不到锁就原地打转。它不会让出 CPU,而是不断循环检查锁状态。你想想看,如果临界区只有几条指令,上下文切换的开销可能比忙等待还大。这时候自旋锁就派上用场了。

我在项目中遇到过一个问题:一个 GPIO 中断服务程序里要更新一个状态寄存器,用了 mutex,结果中断上下文不能睡眠,直接 panic。后来换成自旋锁,问题解决。记住,中断上下文只能用自旋锁。

// 自旋锁基本用法
spinlock_t my_lock;
spin_lock_init(&my_lock);

spin_lock(&my_lock);
// 临界区:极短的操作,比如更新一个标志位
shared_flag = 1;
spin_unlock(&my_lock);

// 中断上下文版本
unsigned long flags;
spin_lock_irqsave(&my_lock, flags);
// 临界区
spin_unlock_irqrestore(&my_lock, flags);
我曾经踩过的坑: 自旋锁持有期间不能睡眠、不能调用可能睡眠的函数(如 copy_from_user、kmalloc(GFP_KERNEL))。否则系统直接死锁,连 panic 都来不及打。

4.2 互斥体:让出 CPU 的绅士

互斥体(mutex)和自旋锁最大的区别:拿不到锁时,它会主动睡眠,让出 CPU。等锁释放了,内核再把等待的进程唤醒。说白了,这是一种「绅士风度」——我不跟你抢 CPU,我先歇着。

什么时候用互斥体?临界区比较长的时候。比如你要操作一个硬件,需要等待几十微秒甚至几毫秒,用自旋锁就是浪费 CPU。我习惯这样选:临界区超过 100 条指令,或者可能调用阻塞函数,就用 mutex。

// 互斥体基本用法
struct mutex my_mutex;
mutex_init(&my_mutex);

mutex_lock(&my_mutex);
// 临界区:可以睡眠,可以调用阻塞函数
// 比如:等待硬件操作完成
hardware_operation();
mutex_unlock(&my_mutex);
特性 自旋锁 互斥体
等待方式 忙等待(循环检查) 睡眠(上下文切换)
临界区长度 极短(< 100 条指令) 可长可短
中断上下文 可用 不可用
持有期间睡眠 禁止 允许
性能开销 低(无调度) 较高(有调度)
我的选择原则: 中断上下文用自旋锁;进程上下文且临界区短用自旋锁;进程上下文且临界区长用互斥体。简单吧?

4.3 原子操作:硬件级的终极保障

原子操作,是 CPU 指令级别的「不可分割」。比如对一个 32 位整数做加 1,普通代码会被编译成多条指令:读、加、写。多核同时执行时,数据就乱了。原子操作保证这三步一气呵成,中间不会被任何中断或别的核打断。

我经常用原子操作来维护驱动里的引用计数、状态标志。它比锁更轻量,没有上下文切换,没有忙等待。你想想看,如果只是对一个计数器加 1,用自旋锁是不是有点杀鸡用牛刀?

// 原子操作示例
atomic_t ref_count = ATOMIC_INIT(0);

// 原子加 1
atomic_inc(&ref_count);

// 原子减 1 并检查是否为 0
if (atomic_dec_and_test(&ref_count)) {
    // 引用计数归零,执行清理
    cleanup_resource();
}

// 原子位操作
set_bit(3, &flags);
clear_bit(3, &flags);
test_bit(3, &flags);
性能对比: 原子操作通常只需 1-2 个 CPU 周期;自旋锁需要 10-50 个周期(加上缓存一致性开销);互斥体需要几百到上千个周期(包含调度开销)。

4.4 RCU 机制:读多写少场景的利器

RCU(Read-Copy-Update),读-复制-更新。这个名字已经说明了它的核心思想:读操作不加锁,写操作先复制一份,修改完再原子替换指针,最后延迟释放旧数据。

为什么读操作可以不加锁?因为 RCU 保证:读操作看到的指针要么是旧的完整数据,要么是新的完整数据,永远不会看到中间状态。这依赖内存屏障和宽限期(grace period)机制。

我在项目中用 RCU 优化过一个路由表查询驱动。原来的实现用读写锁,每秒 10 万次查询,CPU 占用率 30%。换成 RCU 后,查询路径完全无锁,CPU 占用降到 5%。写操作虽然复杂一点,但路由表更新频率很低,完全能接受。

// RCU 读端(无锁!)
rcu_read_lock();
struct route_entry *entry = rcu_dereference(route_table);
// 使用 entry,此时不会阻塞任何写操作
if (entry)
    process_route(entry);
rcu_read_unlock();

// RCU 写端
struct route_entry *new_entry = kmalloc(sizeof(*new_entry), GFP_KERNEL);
// 复制旧数据并修改
*new_entry = *old_entry;
new_entry->nexthop = new_nexthop;
// 原子替换指针
rcu_assign_pointer(route_table, new_entry);
// 等待所有读端完成,然后释放旧数据
synchronize_rcu();
kfree(old_entry);
注意: RCU 读端不能睡眠,不能持有自旋锁,不能调用可能阻塞的函数。因为宽限期需要等待所有读端完成,如果读端睡眠了,宽限期就永远结束不了。

4.5 如何选择?一张决策流程图

实际开发中,我一般按这个思路选型:

  1. 先看上下文: 中断上下文?只能用自旋锁或原子操作。
  2. 再看临界区长度: 只有几条指令?原子操作优先;几十条指令?自旋锁;几百条以上?互斥体。
  3. 最后看读写比例: 读多写少(99:1)?RCU 是首选。
我的经验: 不要过早优化。先用最简单的锁(比如自旋锁或互斥体)把功能跑通,然后用 perf 或 ftrace 看锁竞争情况。如果锁争用严重,再考虑升级到 RCU 或更细粒度的锁方案。

并发控制没有银弹。每种机制都有它的适用场景,也有它的代价。理解它们的原理和取舍,比记住 API 更重要。嗯,这四种武器用好了,你的驱动就能在多核、多中断的复杂环境下稳稳运行。