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

并发,是嵌入式Linux驱动绕不开的坎儿。说白了,就是多个执行路径同时访问共享资源,谁先谁后的问题。我早年做项目时,就吃过竞态的亏——一个中断处理程序和tasklet同时操作一个全局变量,结果数据错乱,设备直接挂死。从那以后,我对并发控制就格外上心。

这一章,咱们就把Linux内核里常用的并发控制手段捋一遍。自旋锁、互斥锁、读写锁、原子操作、RCU,每个都有它的脾气和适用场景。你想想看,选错了锁,轻则性能下降,重则死锁崩溃。所以,理解它们的本质,比背API更重要。

核心原则: 保护共享数据,而不是保护代码。锁的粒度越细,并发度越高,但复杂度也越高。
并发控制机制知识体系 并发与竞态控制 自旋锁 互斥锁 读写锁 原子操作 RCU 机制 忙等待,不睡眠 适合短临界区 可睡眠,切换上下文 适合长临界区 读读不互斥 读写/写写互斥 硬件级指令 无锁操作 读多写少场景 无锁读取,延迟回收

4.1 自旋锁:忙等待的代价

自旋锁,顾名思义,拿不到锁就原地打转。它不会让出CPU,而是不断循环检查锁状态。这种机制的好处是开销小,没有上下文切换。坏处也明显——浪费CPU时间片。

适用场景:临界区执行时间极短,比如更新一个寄存器、修改一个标志位。我一般建议,临界区不要超过几十条指令。否则,自旋锁就是灾难。

注意: 在自旋锁保护的临界区里,绝对不能调用可能导致睡眠的函数,比如 copy_from_user()、kmalloc(GFP_KERNEL)、mutex_lock()。否则,系统可能死锁。
// 自旋锁基本用法
spinlock_t my_lock;
spin_lock_init(&my_lock);

spin_lock(&my_lock);
/* 临界区:快速操作共享数据 */
my_shared_data++;
spin_unlock(&my_lock);

// 中断上下文必须使用 irq 版本
unsigned long flags;
spin_lock_irqsave(&my_lock, flags);
/* 临界区 */
spin_unlock_irqrestore(&my_lock, flags);

我个人习惯,在中断处理函数里只用 spin_lock_irqsave。为什么?因为它会保存当前中断状态,防止在临界区里被中断打断,造成死锁。我曾经见过一个同事,在中断里用了 spin_lock,结果中断嵌套,锁被自己占着,直接死机。

4.2 互斥锁:让出CPU的艺术

互斥锁和自旋锁最大的区别,就是拿不到锁时会睡眠。它会触发调度器,把CPU让给其他进程。等锁释放了,再唤醒等待的进程。

适用场景:临界区执行时间长,或者可能发生阻塞。比如,操作硬件需要等待完成中断、从用户空间拷贝数据。

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

mutex_lock(&my_mutex);
/* 临界区:可能包含阻塞操作 */
copy_from_user(buf, user_buf, size);
mutex_unlock(&my_mutex);
经验之谈: 互斥锁的持有者必须是同一个进程。也就是说,你不能在进程A里加锁,在进程B里解锁。这是互斥锁的语义约束,内核会做严格检查。

你想想看,如果临界区只有几微秒,用互斥锁反而得不偿失。因为睡眠和唤醒的开销,可能比自旋锁忙等的时间还长。所以,选锁要看临界区的“长度”。

特性 自旋锁 互斥锁
等待方式 忙等待(循环检查) 睡眠等待(上下文切换)
临界区时长 极短(微秒级) 可长(毫秒级或更长)
中断上下文 可用(需 irq 版本) 不可用
CPU 占用 高(空转) 低(让出 CPU)
死锁风险 高(中断嵌套) 中(依赖进程调度)

4.3 读写锁:区分读写的智慧

读写锁,是对自旋锁或互斥锁的扩展。它把访问者分为两类:读者和写者。多个读者可以同时持有锁,但写者必须独占。这种机制,在“读多写少”的场景下,能大幅提升并发性能。

我记得在一个网络驱动项目中,统计信息结构体被频繁读取,但只有偶尔更新。用普通自旋锁,所有读操作都串行化,性能瓶颈明显。换成读写锁后,吞吐量提升了近3倍。

// 读写锁(自旋锁变体)
rwlock_t my_rwlock;
rwlock_init(&my_rwlock);

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

// 写者路径
write_lock(&my_rwlock);
/* 更新共享数据 */
write_unlock(&my_rwlock);
注意: 读写锁存在“写者饥饿”问题。如果读者源源不断,写者可能永远拿不到锁。在实时性要求高的场景,要慎用。Linux 内核后来引入了 rcu 和 seqlock 来缓解这个问题。

4.4 原子操作:最轻量的武器

原子操作,是硬件级别的指令,保证一个操作要么全部执行,要么完全不执行。它不需要锁,没有上下文切换,也没有忙等待。说白了,就是一条指令搞定一切。

原子操作通常用于计数器、标志位、位图等简单场景。比如,设备引用计数、中断屏蔽标志。

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

atomic_inc(&counter);       // 原子加1
atomic_dec(&counter);       // 原子减1
int val = atomic_read(&counter);  // 读取当前值

// 原子位操作
set_bit(0, &flags);         // 设置第0位
clear_bit(0, &flags);       // 清除第0位
test_bit(0, &flags);        // 测试第0位
建议: 能用原子操作解决的问题,就不要用锁。它是最轻量、最安全的并发控制手段。我在做性能调优时,经常把一些简单的自旋锁替换成原子操作,效果立竿见影。

4.5 RCU 机制:无锁读取的魔法

RCU(Read-Copy-Update),是Linux内核里最复杂的并发控制机制之一。它的核心思想是:读者不需要加锁,写者通过“复制-更新-延迟回收”的方式,保证数据一致性。

工作原理:

  1. 读取: 读者直接访问指针,不加锁,不阻塞。
  2. 更新: 写者先复制一份数据,在副本上修改,然后原子地更新指针。
  3. 回收: 等待所有读者都离开临界区后,再释放旧数据。

RCU 最适合“读多写少”且读者路径性能敏感的场景。比如,路由表、文件系统目录项缓存、设备链表。

// RCU 读者端
rcu_read_lock();
struct my_data *p = rcu_dereference(global_ptr);
/* 使用 p 指向的数据 */
rcu_read_unlock();

// RCU 写者端
struct my_data *new_data = kmalloc(sizeof(*new_data), GFP_KERNEL);
*new_data = *old_data;  // 复制
new_data->value = 42;   // 修改
rcu_assign_pointer(global_ptr, new_data);
synchronize_rcu();      // 等待所有读者退出
kfree(old_data);        // 释放旧数据
关键点: synchronize_rcu() 会阻塞,直到所有 CPU 上的 rcu_read_lock() 都退出。这个过程可能很慢(毫秒级),所以 RCU 不适合频繁更新的场景。

我曾经在一个网络驱动里,用 RCU 替换了读写锁。读者路径从 200ns 降到了 50ns,几乎零开销。但写者路径变慢了,因为要等待宽限期。好在我们的场景是“一秒更新一次,一秒读取百万次”,RCU 完美匹配。

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

场景 推荐机制 理由
临界区极短(< 10条指令) 原子操作 零开销,硬件保证
临界区短,中断上下文 自旋锁(irq 版本) 不睡眠,适合中断
临界区长,进程上下文 互斥锁 让出 CPU,避免浪费
读多写少,读者性能敏感 RCU 读者无锁,极致性能
读多写少,但写者不能饿死 读写锁 平衡读写,但注意写者饥饿
简单计数器/标志位 原子操作 最轻量,首选
避坑指南: 我曾经在一个项目中,把自旋锁用在了一个可能睡眠的函数里。结果系统随机死锁,排查了三天才找到原因。记住:自旋锁临界区里,绝对不要调用任何可能睡眠的函数。这是铁律。

嗯,并发控制就讲到这里。每种机制都有它的“脾气”,选对了,性能飞起;选错了,调试到崩溃。我的建议是:先从最简单的原子操作开始,不够用再上锁,最后才考虑 RCU。别一开始就上最复杂的,容易翻车。