并发与竞态:内核开发的第一个拦路虎
说实话,我刚开始接触内核开发时,第一个让我头疼的概念就是并发。你想想看,一个单核CPU,怎么就并发起来了?后来我才明白,并发这玩意儿,说白了就是「多个执行流同时访问同一份数据」。今天咱们就来聊聊这个基础中的基础。
并发的基本概念
并发,英文叫concurrency。它指的是多个执行单元同时运行。注意,这里的「同时」可能是真的同时(多核),也可能是交替执行(单核)。
我习惯把并发想象成这样一个场景:你正在厨房做饭,同时还要接电话、看孩子。虽然你只有一双手,但你在几个任务之间来回切换——这就是单核并发。如果家里来了个帮手,两个人各干各的——这就是多核并发。
关键点:并发不一定要物理上的同时执行,逻辑上的「看起来同时」也算并发。
竞态:并发带来的麻烦
竞态(race condition)是什么?就是多个执行流同时访问共享数据,导致结果不可预测。我在项目中遇到过这样一个bug:两个线程同时更新一个计数器,结果本该是2,却变成了1。原因就是两个线程同时读取了旧值,各自加1,然后写回。
为什么会这样?因为操作不是原子的。你想想看,count++ 这行C代码,在汇编层面其实是三步:
load count, reg // 读
add reg, 1 // 加
store reg, count // 写
如果两个线程同时执行这三步,结果就乱套了。
注意:竞态不是每次都会发生,它需要特定的时序触发。这也是为什么有些bug在测试时死活复现不了,上线后却频繁出现。
并发产生的原因
内核中的并发,来源比用户态复杂得多。我总结了一下,主要有这么几个:
- 多核CPU:这是最直接的并发来源。每个核上跑一个进程,它们真的在同时执行。
- 中断处理:中断可以在任何时刻发生。你正在执行一个函数,突然来了个中断,中断处理程序就开始跑起来了。
- 软中断和tasklet:这些是下半部机制,它们也会抢占当前进程的执行。
- 内核抢占:高优先级的进程可以抢占正在内核态运行的低优先级进程。
- 睡眠与唤醒:一个进程在等待资源时睡眠,另一个进程唤醒它,这中间就有并发问题。
嗯,这里要注意:用户态程序通常只担心多线程并发,但内核开发者要同时面对上面所有场景。这也是内核编程比应用编程难的一个原因。
内核中的并发场景
我画了一张图,帮你理清内核中的并发场景:
从这张图可以看出,内核中的并发场景是层层嵌套的。每个场景下都有具体的竞态问题。我当年刚接触这些时,感觉头都大了。但后来发现,只要掌握了保护临界区的原则,就能以不变应万变。
保护临界区的原则
临界区(critical section)就是访问共享数据的代码段。保护临界区,说白了就是确保同一时间只有一个执行流能进去。
我总结了四条原则,你可以记一下:
- 识别共享数据:先搞清楚哪些数据会被多个执行流访问。全局变量、静态变量、硬件寄存器,这些都是高危对象。
- 缩小临界区:只保护真正需要保护的代码。不要把整个函数都锁住,那样性能会很难看。
- 选择合适的同步机制:不同的场景用不同的锁。中断上下文不能用互斥锁(会睡眠),只能用自旋锁。
- 避免死锁:这是最坑的。我曾经在一个项目中,两个锁的获取顺序不一致,结果系统直接卡死。从那以后,我养成了「锁顺序一致」的习惯。
我的经验:写代码前先画个并发图,标出哪些数据是共享的,哪些路径可能并发。花10分钟画图,能省下2小时调试时间。
一个简单的例子
来看一个实际的内核代码片段。假设我们要保护一个全局计数器:
// 错误示例:没有保护
int counter = 0;
void increment_counter(void) {
counter++; // 竞态!多个CPU可能同时执行
}
// 正确示例:使用自旋锁
DEFINE_SPINLOCK(counter_lock);
int counter = 0;
void increment_counter(void) {
unsigned long flags;
spin_lock_irqsave(&counter_lock, flags);
counter++;
spin_unlock_irqrestore(&counter_lock, flags);
}
注意这里用了 spin_lock_irqsave 而不是 spin_lock。为什么?因为中断也可能访问这个计数器。如果只用 spin_lock,中断来了照样能抢进去,锁就白上了。
避坑指南:我曾经在中断处理程序里用了 spin_lock,结果死锁了。后来才发现,中断上下文必须用 spin_lock_irqsave 来禁用本地中断。这个坑我踩过,希望你不用再踩。
总结一下
并发与竞态是内核开发的入门必修课。说白了,就是多个执行流同时访问共享数据,导致结果不可预测。内核中的并发来源比用户态多得多,包括多核、中断、抢占等。保护临界区的核心原则就是:识别共享数据、缩小临界区、选对锁、避免死锁。
嗯,这一章的内容就到这里。记住,写内核代码时,时刻问自己一句:「这段代码会不会被并发执行?」如果答案是「会」,那就得加锁。