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);
为什么要关中断?
因为如果中断处理函数和进程上下文同时争用同一把锁,进程拿着锁时来了中断,中断里又去拿同一把锁——这就死锁了。关中断能避免这种嵌套。
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把更新操作拆成三步:
- 复制:把要修改的数据复制一份
- 更新:在副本上做修改
- 替换:用原子操作把指针指向新数据
读者在读的时候,直接通过指针访问数据,完全不需要锁。即使写者在更新,读者读到的要么是旧数据,要么是新数据,不会读到中间状态。
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的行为不同,移植时要注意
4.4 死锁检测与调试
死锁是驱动开发里最头疼的问题。系统不响应了,但代码看起来没问题。我早期做驱动时,经常被死锁折磨得通宵加班。
4.4.1 死锁的四个必要条件
死锁发生,必须同时满足四个条件:
- 互斥:资源一次只能被一个线程持有
- 持有并等待:线程拿着一个资源,等待另一个资源
- 不可剥夺:资源不能被强制拿走
- 循环等待:多个线程形成等待环路
打破任意一个条件,就能预防死锁。我个人习惯在代码里强制规定锁的获取顺序,比如总是先拿锁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救了我一命。
4.5 本章小结
多核同步机制的选择,本质上是在并发度和开销之间做权衡:
- 自旋锁:简单粗暴,适合极短临界区
- 读写锁:读多写少时提升并发,但写频繁时不如自旋锁
- RCU锁:读者无锁,性能极致,但写者开销大,使用限制多
死锁调试没有捷径。熟悉工具、保留现场、分析锁依赖关系,是每个驱动工程师必须掌握的技能。Renode模拟器让我们可以在不烧录硬件的情况下,反复复现和分析死锁问题,这是纯硬件调试做不到的。