4. 并发与竞态控制:自旋锁、互斥锁、读写锁、原子操作、RCU机制、并发场景下的死锁避免

并发,是驱动开发里绕不开的坎儿。你想想看,一个多核系统里,CPU0 在跑你的中断处理函数,CPU1 在跑你的系统调用,两个线程同时操作同一个全局变量——这不出乱子才怪。

我刚开始写驱动那会儿,就吃过这个亏。一个简单的计数器,没加任何保护,结果跑着跑着数值就飞了。从那以后,我对并发控制就特别上心。今天咱们就把这些锁机制掰开揉碎了讲清楚。

核心原则:共享资源必须串行化访问。谁先拿到锁,谁先用;没拿到的,等着。

4.1 自旋锁:轻量级的守护者

自旋锁,说白了就是「忙等待」。线程拿不到锁,就在原地打转,不停地检查锁是否释放了。

spinlock_t my_lock;
spin_lock_init(&my_lock);

spin_lock(&my_lock);
// 临界区代码
spin_unlock(&my_lock);

什么时候用自旋锁?临界区执行时间极短的时候。比如就改一个寄存器、更新一个标志位。我建议,临界区别超过几十条指令。为什么呢?因为自旋锁不睡眠,CPU 就在那空转,时间长了纯属浪费。

我的习惯:在中断上下文里,我几乎只用自旋锁。因为中断处理函数不能睡眠,互斥锁用不了。

注意,自旋锁有个变种——spin_lock_irqsave()。它会在拿锁的同时关闭本地中断。为什么要这么做?

举个例子。你的驱动在普通进程上下文拿了自旋锁,然后来了个中断,中断处理函数也想拿同一个锁——完蛋,死锁了。中断处理函数等进程释放锁,进程被中断打断不执行了。这就是典型的「自己等自己」。

unsigned long flags;
spin_lock_irqsave(&my_lock, flags);
// 临界区,此时本地中断被关闭
spin_unlock_irqrestore(&my_lock, flags);

我曾经踩过的坑:在自旋锁保护的临界区里调用了 kmalloc(GFP_KERNEL)。kmalloc 可能会睡眠,而自旋锁持有期间不允许睡眠。结果系统直接报「scheduling while atomic」错误。切记:自旋锁临界区里,别调用任何可能睡眠的函数。

4.2 互斥锁:让线程睡个好觉

互斥锁和自旋锁最大的区别是什么?拿不到锁时,线程会睡眠。CPU 不会空转,而是去干别的事。等锁释放了,内核再把睡眠的线程唤醒。

struct mutex my_mutex;
mutex_init(&my_mutex);

mutex_lock(&my_mutex);
// 临界区代码
mutex_unlock(&my_mutex);

互斥锁适合临界区比较长的场景。比如你要拷贝大量数据、要等待硬件操作完成。这时候让线程睡眠,比让它空转划算得多。

但互斥锁有个限制:不能在中断上下文使用。因为中断处理函数不能睡眠。你想想看,中断来了,你拿互斥锁,拿不到就睡眠——谁唤醒你?中断上下文没有调度器管你。

特性 自旋锁 互斥锁
等待方式 忙等待(空转) 睡眠等待
临界区时长 极短(几十条指令) 可长可短
中断上下文 可用 不可用
CPU 占用 高(空转) 低(睡眠)

4.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);

我遇到过这样一个场景:一个设备的状态表,大部分时间都是被读取,只有偶尔才会更新。用普通的互斥锁,所有读操作都得排队,性能很差。换成读写锁,读操作可以并行,性能提升很明显。

注意:读写锁不是万能的。如果写操作很频繁,读写锁反而可能比普通锁更慢。因为写者会被读者饿死——读者太多,写者一直拿不到锁。

4.4 原子操作:最轻量的保护

有些操作,比如对一个整数加 1,看起来简单,但在多核系统里不是原子的。CPU 要「读-改-写」三步走。两步之间可能被其他核打断。

原子操作就是硬件级别的解决方案。一条指令完成读、改、写,中间不可打断。

atomic_t counter = ATOMIC_INIT(0);

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

我个人的习惯是:能用原子操作,就不用锁。比如引用计数、简单的统计计数器,原子操作就够了。它没有锁的开销,也不会引起上下文切换。

但原子操作只能处理简单的整数操作。复杂的临界区,还是得上锁。

4.5 RCU 机制:读操作无锁

RCU(Read-Copy-Update)是我觉得最巧妙的一种同步机制。它的核心思想是:读操作完全不加锁,写操作通过「替换」来实现更新。

怎么做到的?

  1. 读者进入临界区前调用 rcu_read_lock(),退出时调用 rcu_read_unlock()。注意,这俩函数几乎没开销,只是标记一下。
  2. 写者要更新数据时,先创建一个新副本,修改好,然后用 rcu_assign_pointer() 把指针指向新副本。
  3. 写者调用 synchronize_rcu() 等待所有正在读的读者完成。
  4. 之后,旧副本才能被安全释放。
struct foo {
    int data;
};

DEFINE_SPINLOCK(foo_mutex);
struct foo __rcu *g_foo;

// 读路径
rcu_read_lock();
struct foo *p = rcu_dereference(g_foo);
int val = p->data;
rcu_read_unlock();

// 写路径
struct foo *new_foo = kmalloc(sizeof(*new_foo), GFP_KERNEL);
new_foo->data = 42;
spin_lock(&foo_mutex);
struct foo *old = rcu_replace_pointer(g_foo, new_foo, lockdep_is_held(&foo_mutex));
spin_unlock(&foo_mutex);
synchronize_rcu();
kfree(old);

RCU 的适用场景:读操作极其频繁,写操作极少。比如路由表、文件系统超级块。我见过一个网络驱动,用 RCU 替换了读写锁,吞吐量提升了 30%。

但 RCU 也有代价。写者要等待所有读者完成,这个等待时间可能很长。而且 RCU 的实现依赖编译器和 CPU 的内存屏障,理解起来有点绕。

4.6 死锁的避免:血的教训

死锁,说白了就是「你等我,我等你,谁都不放手」。我见过最典型的死锁场景是这样的:

  • 线程 A 拿了锁 1,想拿锁 2
  • 线程 B 拿了锁 2,想拿锁 1
  • 两个线程都卡住了

怎么避免?我总结了几个原则:

  1. 锁的顺序要一致。如果多个锁必须同时持有,所有代码路径都按同样的顺序拿锁。比如先拿锁 A,再拿锁 B。别有的路径先 A 后 B,有的路径先 B 后 A。
  2. 尽量用 trylock。mutex_trylock() 拿不到锁就返回,不会阻塞。你可以释放已持有的锁,重试。
  3. 锁的粒度要合适。锁太大,并发度低;锁太小,容易死锁。我一般从粗粒度开始,性能不够再细化。
  4. 使用内核的死锁检测工具。CONFIG_PROVE_LOCKING 和 lockdep 能帮你自动检测潜在的死锁。

我曾经犯过的错:在持有互斥锁的情况下调用了某个回调函数,回调函数里又试图拿同一个锁。这就是递归死锁。后来我改用 mutex_lock_nested() 来区分锁的层级,才解决这个问题。

4.7 知识体系总览

下面这张图,是我梳理的并发控制机制的选择逻辑。你写驱动时,可以照着这个思路来选:

并发控制机制选择流程图 进入临界区 中断上下文? 是 临界区极短? 是 自旋锁 否 互斥锁 否 读多写少? 是 读可容忍延迟? 是 RCU 否 读写锁 否 操作简单? 是 原子操作 否 互斥锁 核心原则:能用原子操作不用锁,能用自旋锁不用互斥锁 读多写少用 RCU 或读写锁,中断上下文只能用自旋锁

嗯,并发控制这块内容不少。但说白了,核心就一句话:选对工具,用对场景。原子操作最轻量,自旋锁适合短临界区,互斥锁适合长临界区,读写锁和 RCU 适合读多写少。死锁的避免,靠的是良好的设计和严格的代码审查。

我在项目中见过太多因为并发问题导致的诡异 bug。有的是数据错乱,有的是系统卡死,有的是性能骤降。每次排查到最后,都是锁用错了。所以,写驱动时多花点心思在并发控制上,绝对值得。

我的建议:写完代码后,打开内核的 lockdep 功能跑一遍。它能帮你发现很多你意识不到的死锁风险。我每次提交代码前,都会跑一遍 lockdep 测试,这已经成了我的习惯。

想要更多资料,联系可可