1. 并发与竞态:为什么需要并发控制?

大家好,我是你们的老朋友。今天咱们来聊聊内核开发中最容易踩坑的话题——并发控制。

说实话,我刚开始接触内核时,觉得并发控制就是个麻烦事。不就是多几个线程同时跑吗?能出什么问题?直到有一次,我写了个简单的驱动,在双核机器上测试,结果数据莫名其妙就乱了。查了三天,最后发现是并发访问共享变量导致的。嗯,从那以后,我再也不敢小看并发问题了。

1.1 并发执行的内核场景

先说说内核里哪些地方会并发执行。你想想看,一个单核CPU上跑Linux,是不是就安全了?其实不是。内核里的并发场景比你想的要多得多。

SMP(对称多处理)

说白了,就是多个CPU核心同时执行内核代码。每个核心都有自己的寄存器、缓存,但共享内存。我在项目中遇到过,两个核同时修改一个全局变量,结果变量值被覆盖了。这就是典型的并发问题。

中断处理

中断一来,当前执行的进程就被打断了。中断处理程序可能和被打断的进程访问同一个数据结构。我记得有一次调试网卡驱动,中断处理函数和主线程都在操作一个环形缓冲区,结果数据包顺序全乱了。

内核抢占

Linux内核从2.6版本开始支持抢占。什么意思?就是一个内核线程正在执行,另一个更高优先级的线程可以抢进来。这听起来很美好,但问题来了——如果两个线程都在修改同一个链表呢?

核心要点: 内核中的并发场景包括:SMP多核并行、中断上下文抢占、内核态抢占。这三种场景都可能引发竞态条件。

1.2 临界区与竞态条件

什么是临界区?就是访问共享资源的代码段。竞态条件呢?就是多个执行流同时进入临界区,导致结果依赖于执行顺序。

我给大家画个图,看看竞态条件是怎么发生的:

竞态条件示意图 共享变量 count = 0 线程A:读取 count 线程B:读取 count 线程A:count = 0 + 1 线程B:count = 0 + 1 count = 1(错误!) 两个线程同时读取count=0,各自加1后写回,结果却是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 并发控制的核心原则

做内核并发控制,有几个原则必须记住。我总结了四条:

  1. 最小化临界区——只保护真正需要保护的数据。别把整个函数都锁住,那样性能就毁了。
  2. 避免锁嵌套——一个锁里再拿另一个锁,很容易死锁。我见过太多死锁的bug了。
  3. 注意锁的顺序——如果必须用多个锁,所有代码路径拿锁的顺序要一致。
  4. 考虑中断上下文——在中断处理函数里,只能用自旋锁,不能用会睡眠的锁。
小技巧: 我写代码时,会在每个共享数据结构旁边加注释,说明它会被哪些执行流访问,用什么锁保护。这样过几个月再看代码,自己也不会搞混。

举个例子,看看实际代码中怎么用自旋锁:

// 一个简单的共享计数器
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,因为它能同时禁止本地中断,防止中断处理程序和当前代码产生竞争。

说实话,并发控制是内核编程里最考验功力的地方。我见过很多新手,要么不加锁导致数据混乱,要么加锁太多导致性能惨不忍睹。平衡之道,就在这临界区的划分和锁的选择上。

好了,这一章的内容就到这里。记住一句话:并发控制不是银弹,但不用并发控制,你的内核代码就是定时炸弹。


专注资料整理