4、多核同步机制:自旋锁、读写锁、RCU锁在驱动中的应用、死锁检测与调试

多核环境下,驱动代码最怕什么?
我个人觉得,最怕的就是数据打架。两个CPU核同时操作同一个链表,一个在插入,一个在删除,结果就是系统崩溃。嗯,这可不是闹着玩的。

同步机制,说白了就是给共享数据加把锁。但锁用不好,性能反而会下降。我见过不少驱动工程师,一上来就上大锁,结果多核性能还不如单核。今天我们就聊聊自旋锁、读写锁和RCU锁,以及怎么在Renode里调试死锁。

4.1 自旋锁:最简单的同步原语

自旋锁的特点是:拿不到锁就原地打转。它不会让出CPU,而是一直循环检测锁状态。适合锁持有时间极短的场景。

核心原则:自旋锁的临界区代码,执行时间必须小于一次上下文切换的开销。否则,用信号量更合适。

4.1.1 自旋锁的API与使用

Linux内核里,自旋锁的接口很简洁:

spinlock_t my_lock;
spin_lock_init(&my_lock);

spin_lock(&my_lock);
// 临界区代码,必须短小精悍
spin_unlock(&my_lock);

但要注意,自旋锁持有期间不能睡眠。我在项目中遇到过一位同事,在自旋锁里调用了kmalloc(GFP_KERNEL),结果系统直接死锁。因为kmalloc可能触发内存回收,导致进程睡眠,而睡眠在自旋锁里是致命的。

4.1.2 中断上下文中的自旋锁

如果你的驱动在中断处理函数里也要用锁,那就得用spin_lock_irqsave:

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

为什么要关中断?
因为如果中断处理函数和进程上下文同时争用同一把锁,进程拿着锁时来了中断,中断里又去拿同一把锁——这就死锁了。关中断能避免这种嵌套。

我曾经踩过的坑:在SMP系统上,只关中断不关CPU迁移,结果锁被其他CPU释放了,但当前CPU的中断还关着,导致中断响应延迟飙升。记住:spin_lock_irqsave是关本地中断+关抢占,缺一不可。

4.2 读写锁:区分读多写少的场景

自旋锁不分读写,所有操作互斥。但很多场景是:读操作远多于写操作。比如路由表查找、设备状态查询。这时候用读写锁,能大幅提升并发度。

4.2.1 读写锁的API

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.2.2 读写锁的适用边界

读写锁不是万能的。我做过一个网络驱动的性能测试,发现读写锁在某些场景下比自旋锁还慢。为什么?
因为读写锁的实现需要维护读者计数,这个计数本身的原子操作就有开销。如果临界区非常短(比如只读一个32位寄存器),自旋锁反而更快。

场景 推荐锁类型 原因
读多写少,临界区较长 读写锁 读者并行,提升吞吐
读写比例接近1:1 自旋锁 读写锁的原子计数开销更大
临界区极短(< 100ns) 自旋锁 读写锁的公平性机制反而拖慢
写操作频繁 自旋锁或RCU 写者频繁时,读者几乎无法并行

4.3 RCU锁:无锁读的终极方案

RCU(Read-Copy-Update)是Linux内核里最复杂的同步机制,也是性能最高的。它的核心思想是:读者不需要加锁。

4.3.1 RCU的工作原理

RCU把更新操作拆成三步:

  1. 复制:把要修改的数据复制一份
  2. 更新:在副本上做修改
  3. 替换:用原子操作把指针指向新数据

读者在读的时候,直接通过指针访问数据,完全不需要锁。即使写者在更新,读者读到的要么是旧数据,要么是新数据,不会读到中间状态。

我个人习惯:RCU最适合管理频繁读取、偶尔更新的数据结构,比如设备链表、网络路由表、文件系统dentry缓存。更新频率超过每秒1000次时,RCU的宽限期回收开销会变大,需要评估是否改用其他方案。

4.3.2 RCU的代码示例

// 定义RCU保护的指针
struct my_data __rcu *gptr;

// 读路径
rcu_read_lock();
struct my_data *p = rcu_dereference(gptr);
// 使用p指向的数据,此时不能睡眠
rcu_read_unlock();

// 写路径
struct my_data *new_data = kmalloc(sizeof(*new_data), GFP_KERNEL);
new_data->value = 42;
// 复制旧数据并更新
struct my_data *old = rcu_replace_pointer(gptr, new_data, lockdep_is_held(&my_mutex));
// 等待所有读者退出宽限期
synchronize_rcu();
// 安全释放旧数据
kfree(old);

注意:rcu_read_lock不是真正的锁,它只是禁止抢占。所以读者路径的开销极低,几乎可以忽略不计。

4.3.3 RCU的注意事项

  • 读者不能睡眠:在rcu_read_lock/unlock之间,不能调用可能睡眠的函数
  • 写者开销大:synchronize_rcu()会阻塞,直到所有CPU上的读者都退出临界区
  • 内存屏障:RCU依赖CPU的内存屏障指令,ARM和x86的行为不同,移植时要注意
我曾经踩过的坑:在ARM64平台上,RCU的读者路径里调用了printk。printk内部有自旋锁,而自旋锁会触发抢占调度,导致RCU读者被意外打断。结果synchronize_rcu永远等不到所有读者退出,系统hang死。记住:RCU读者路径里,任何可能触发调度的操作都是禁区。

4.4 死锁检测与调试

死锁是驱动开发里最头疼的问题。系统不响应了,但代码看起来没问题。我早期做驱动时,经常被死锁折磨得通宵加班。

4.4.1 死锁的四个必要条件

死锁发生,必须同时满足四个条件:

  1. 互斥:资源一次只能被一个线程持有
  2. 持有并等待:线程拿着一个资源,等待另一个资源
  3. 不可剥夺:资源不能被强制拿走
  4. 循环等待:多个线程形成等待环路

打破任意一个条件,就能预防死锁。我个人习惯在代码里强制规定锁的获取顺序,比如总是先拿锁A再拿锁B,这样就不会形成循环等待。

4.4.2 在Renode中调试死锁

Renode模拟器提供了强大的调试能力。当系统死锁时,我们可以:

  • 暂停所有CPU:用machines set命令暂停模拟
  • 查看CPU状态:用cpu PC查看每个CPU的当前指令地址
  • 分析锁状态:通过内存查看命令,检查锁变量的值

举个例子,假设系统死锁了,我怀疑是自旋锁问题:

# 在Renode monitor中
(machine-0) cpu 0
(machine-0) sysbus.cpu PC
# 输出:0x80001234
# 查看这个地址附近的代码,发现是在spin_lock里循环

(machine-0) cpu 1
(machine-0) sysbus.cpu PC
# 输出:0x80005678
# 发现也是在spin_lock里循环

# 查看锁变量的内存地址
(machine-0) sysbus ReadDoubleWord 0x90000000
# 输出:0x00000001  # 锁被持有
(machine-0) sysbus ReadDoubleWord 0x90000004
# 输出:0x00000002  # 另一个锁也被持有

通过对比两个CPU的PC值和锁变量,就能推断出死锁的环路。

4.4.3 使用Lockdep进行静态检测

Linux内核的Lockdep工具可以在运行时检测死锁。开启方法:

# 内核配置
CONFIG_LOCKDEP=y
CONFIG_DEBUG_LOCK_ALLOC=y
CONFIG_PROVE_LOCKING=y

Lockdep会记录每个锁的获取顺序,如果发现违反顺序的情况,就会打印警告。我在项目中遇到过Lockdep报出的潜在死锁,当时觉得"不可能",但仔细分析代码后,发现确实有一个罕见的路径会导致循环等待。Lockdep救了我一命。

调试死锁的黄金法则:先看Lockdep输出,再看CPU栈回溯,最后看锁变量的内存值。不要一上来就重启系统,保留现场是最重要的。

4.5 本章小结

多核同步机制的选择,本质上是在并发度和开销之间做权衡:

  • 自旋锁:简单粗暴,适合极短临界区
  • 读写锁:读多写少时提升并发,但写频繁时不如自旋锁
  • RCU锁:读者无锁,性能极致,但写者开销大,使用限制多

死锁调试没有捷径。熟悉工具、保留现场、分析锁依赖关系,是每个驱动工程师必须掌握的技能。Renode模拟器让我们可以在不烧录硬件的情况下,反复复现和分析死锁问题,这是纯硬件调试做不到的。

多核同步机制选择决策树 共享数据需要保护 读操作远多于写? 读写比例接近? RCU锁(推荐) 读写锁 自旋锁 选择建议: • 临界区极短(< 100ns)→ 自旋锁,读写锁的原子计数开销反而更大 • 读多写少 + 临界区较长 → RCU,读者无锁,性能最优 • 写操作频繁 → 自旋锁或读写锁,RCU的宽限期回收会成为瓶颈 • 中断上下文 → 必须使用 spin_lock_irqsave 变体