1. 并发与竞态:为什么需要并发控制?
大家好,我是你们的老朋友。今天咱们来聊聊内核开发中最容易踩坑的话题——并发控制。
说实话,我刚开始接触内核时,觉得并发控制就是个麻烦事。不就是多几个线程同时跑吗?能出什么问题?直到有一次,我写了个简单的驱动,在双核机器上测试,结果数据莫名其妙就乱了。查了三天,最后发现是并发访问共享变量导致的。嗯,从那以后,我再也不敢小看并发问题了。
1.1 并发执行的内核场景
先说说内核里哪些地方会并发执行。你想想看,一个单核CPU上跑Linux,是不是就安全了?其实不是。内核里的并发场景比你想的要多得多。
SMP(对称多处理)
说白了,就是多个CPU核心同时执行内核代码。每个核心都有自己的寄存器、缓存,但共享内存。我在项目中遇到过,两个核同时修改一个全局变量,结果变量值被覆盖了。这就是典型的并发问题。
中断处理
中断一来,当前执行的进程就被打断了。中断处理程序可能和被打断的进程访问同一个数据结构。我记得有一次调试网卡驱动,中断处理函数和主线程都在操作一个环形缓冲区,结果数据包顺序全乱了。
内核抢占
Linux内核从2.6版本开始支持抢占。什么意思?就是一个内核线程正在执行,另一个更高优先级的线程可以抢进来。这听起来很美好,但问题来了——如果两个线程都在修改同一个链表呢?
1.2 临界区与竞态条件
什么是临界区?就是访问共享资源的代码段。竞态条件呢?就是多个执行流同时进入临界区,导致结果依赖于执行顺序。
我给大家画个图,看看竞态条件是怎么发生的:
你看,两个线程都读到count=0,各自加1后写回,结果count变成了1。本来应该是2的。这就是竞态条件。
1.3 内核并发控制的层次
内核的并发控制不是一锤子买卖。它分好几个层次,每个层次解决不同的问题。
| 层次 | 机制 | 适用场景 | 开销 |
|---|---|---|---|
| 原子操作 | atomic_t, atomic_xxx() | 简单的计数器、标志位 | 极低 |
| 自旋锁 | spin_lock, spin_unlock | 短临界区、中断上下文 | 低 |
| 信号量 | semaphore, down/up | 长临界区、可能睡眠 | 中 |
| 互斥锁 | mutex, mutex_lock/unlock | 进程上下文、可睡眠 | 中 |
| 读写锁 | rwlock, rw_semaphore | 读多写少的场景 | 中 |
| RCU | rcu_read_lock/unlock | 读多写极少、链表遍历 | 读极低 |
我个人习惯是:能用原子操作就不用锁,能用自旋锁就不用互斥锁。为什么?因为锁的开销不一样。原子操作就一条指令,自旋锁可能忙等几个时钟周期,互斥锁可能引起进程切换,那开销就大了去了。
1.4 并发控制的核心原则
做内核并发控制,有几个原则必须记住。我总结了四条:
- 最小化临界区——只保护真正需要保护的数据。别把整个函数都锁住,那样性能就毁了。
- 避免锁嵌套——一个锁里再拿另一个锁,很容易死锁。我见过太多死锁的bug了。
- 注意锁的顺序——如果必须用多个锁,所有代码路径拿锁的顺序要一致。
- 考虑中断上下文——在中断处理函数里,只能用自旋锁,不能用会睡眠的锁。
举个例子,看看实际代码中怎么用自旋锁:
// 一个简单的共享计数器
static atomic_t my_counter = ATOMIC_INIT(0);
// 原子操作,不需要锁
void increment_counter(void)
{
atomic_inc(&my_counter);
}
// 复杂操作,需要自旋锁
static DEFINE_SPINLOCK(my_lock);
static struct list_head my_list;
void add_to_list(void *data)
{
unsigned long flags;
// 在中断上下文也能安全使用
spin_lock_irqsave(&my_lock, flags);
list_add(data, &my_list);
spin_unlock_irqrestore(&my_lock, flags);
}
你看,简单的计数器用原子操作就够了。但链表操作涉及多个步骤,必须用锁保护。这里用了spin_lock_irqsave,因为它能同时禁止本地中断,防止中断处理程序和当前代码产生竞争。
说实话,并发控制是内核编程里最考验功力的地方。我见过很多新手,要么不加锁导致数据混乱,要么加锁太多导致性能惨不忍睹。平衡之道,就在这临界区的划分和锁的选择上。
好了,这一章的内容就到这里。记住一句话:并发控制不是银弹,但不用并发控制,你的内核代码就是定时炸弹。