跳到主要内容

第 12 章 并发的代价 —— 内核同步机制 Part 1

前两章我们聊了调度,看起来系统运转良好,井井有条。但那是因为我们运气好,一直只盯着「一个进程」看。

现实是残酷的:在现代多核系统上,几十个硬件中断、内核线程和用户进程同时在 CPU 之间乱窜。如果它们恰好看上了同一块内存……好吧,这就是本章的主题——内核同步

这不是一个「锦上添花」的高级话题。这是区分「玩具代码」和「生产级驱动」的分水岭。一旦你的代码跑在多核上,或者你的设备产生了中断,所有的 bets 都 off 了。除非你真的知道自己在干什么,否则数据结构会在你眼皮底下腐烂,而你却一无所知,直到系统在凌晨三点突然 Panic。

所以,坐稳了。接下来的内容会稍微有点烧脑,但这是成为一名严肃的系统程序员的必经之路。我们会把并发这个猛兽关进笼子里,或者至少……学会如何不被它咬死。


12.1 临界区:当「同时」发生时

在深入代码之前,我们必须先对齐一个核心认知。

并发本身并不是问题。 代码是只读的,指令也是只读的。让一万个 CPU 同时执行同一个函数,不仅安全,甚至是我们梦寐以求的(这就是多核存在的意义)。问题出在「写」上——更准确地说,是多条执行路径同时修改同一块可写数据

如果你觉得这听起来很简单,那可能是因为你还没见过真正的混乱。让我们从最基础的概念开始拆解。

什么是临界区?

教科书上的定义很枯燥,但在内核里,这是一个关乎生死的概念。我们把它拆解成两个必须同时满足的条件:

  1. 可能并发:这段代码路径有机会被两个以上的执行流同时运行。
  2. 操作共享可写状态:代码里动了全局变量、静态变量,或者某块大家都能看见的内存。

只要同时满足这两点,恭喜你,你找到了一个临界区

临界区必须被强制串行化。 这意味着,在任何给定的时间点,只能有一个线程在里面跳舞。如果两个线程同时在里面,这就是一个 Bug,通常被称为竞态条件数据竞争

等等,为什么「读」也不行?

这是一个经典的直觉误区。「我只是读一下数据,又不改它,难道也有问题?」

在单核、单线程的世界里,读确实是安全的。但在多核并发环境里,读操作可能会遇到脏读撕裂读。想象一下,你在读取一个 64 位的变量,而在 32 位架构上,这需要两条指令才能完成。如果在读完高 32 位的一瞬间,另一个线程把低 32 位改了……那你读到的就是一个「缝合怪」,既不是旧值,也不是新值,而是一个毫无意义的垃圾数据。

原子性:不可分割的艺术

我们在讨论并发时,经常会听到「原子操作」。

原子性意味着这个操作要么全做完,要么完全没发生,不存在「做了一半被叫停」这种状态。在内核术语里,如果一个临界区是原子的,那就意味着它必须一口气跑到终点,中间不能被打断(甚至不能被中断打断)。

在用户态编程(比如 Pthreads)里,我们通常只关心「互斥访问」——只要我不让你进来,大家相安无事。但在内核里,事情更棘手:

  • 进程上下文:大多数情况下,你跑在进程上下文里(比如处理系统调用)。如果临界区可能会阻塞(比如等待 I/O),那你不能用强制的原子性,只能用互斥锁。
  • 原子上下文:如果你跑在中断处理里,或者正拿着自旋锁,那你绝对不能睡觉。这里的临界区不仅要互斥,还必须是原子的——任何 schedule() 都会引发灾难。

这里有个简单的心理判断法:

  • 如果代码可能睡眠 -> Mutex
  • 如果代码绝不能睡眠(比如在中断里) -> Spinlock

那个经典的 i++ 陷阱

几乎所有讲并发的故事都会从这个例子开始。但我还是得讲一遍,因为它是现代编译器和硬件联手坑人的最佳案例。

static int i = 5;

void foo(void) {
i++; /* 这行代码安全吗? */
}

你的直觉告诉你:「这不就是加 1 吗?一条指令的事吧?」

大错特错。

让我们打开黑匣子。除非编译器开启了极端优化(并且目标指令集刚好支持原子递增,比如 x86 的 LOCK INC 指令),否则 i++ 在底层通常会被拆解成三个步骤:

  1. Load:把 i 从内存读到寄存器。
  2. Add:在寄存器里加 1。
  3. Store:把新值写回内存。

这三个步骤在 CPU 眼里是完全独立的指令。这意味着,如果在第 1 步和第 3 步之间,发生了上下文切换,或者另一个核心也在写 i,你的修改就被覆盖了。这就是数据竞争。

你可以去 Compiler Explorer (godbolt.org) 上自己试试。不开启优化时,i++ 通常会编译成三条汇编指令。开了 -O2 之后,编译器可能会把它优化成一条原子指令(在 x86 上),但在 ARM 或其他 RISC 架构上,这可能还是多条指令。

作为内核开发者,我们的默认假设必须是:i++ 不是原子的。 必须加锁。

临界区的直觉图景

让我们把前面讲的抽象概念具象化一下。假设你的驱动程序里有这样一个 read 方法:

ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos)
{
// t0: 只操作局部变量,安全,并发也没事
char tmp[100];

// t1: 开始操作全局共享数据 -> 危险!
my_global_data->len = count;
my_global_data->buf = buf;

// ... 还在操作共享数据 ...

// t2: 停止操作共享数据 -> 解除危险
copy_to_user(buf, ...); // 这里又回到安全区(假设 buf 是用户传进来的)

// t3: 又是局部变量,安全
return count;
}

如果不加锁,在 t1t2 这段时间里,任何其他线程(或者同一 CPU 上的中断处理函数)都可能冲进来,把 my_global_data 踢得七零八落。这就是临界区保护的意义。

一个小细节:你可能注意到上面的代码里 copy_to_user 并没有包含在临界区里(因为我们假设 buf 是局部变量)。但如果 copy_to_user 要访问的也是受保护的共享数据,那它也必须在锁的保护范围内。

代码能跑不代表没问题

这就是并发最让人头疼的地方。

如果你的测试用例不够强,或者你运气比较好,那个极其微小的「时间窗口」可能永远没被踩中。代码在实验室里跑了三个月稳如泰山,一部署到客户的服务器上(负载更高、核心更多、中断更频繁),每过几天就崩一次。

并发引发的 Bug 是不可复现的、随机的、间歇性的。 这就是为什么我们要在写代码的时候就严格遵循规则,而不是把希望寄托在运气上。


12.2 道具:锁

好了,既然我们已经识别出了敌人(临界区),现在需要武器来对付它。

最直观的武器就是

锁的本质:把并行变串行

你可以把锁想象成一个独木桥。

  • 赢家:第一个抢到锁的线程。它获得进入临界区的资格,可以继续执行,其他的线程只能在旁边看着。
  • 输家:那些没抢到锁的线程。它们必须等待。

这里的「等待」有两种完全不同的策略,也是 Linux 内核里两种最主要的锁的区别所在:

  • Mutex (互斥锁):输家去睡觉。内核会把它们从 CPU 上踢下去,调度器会去运行别的进程。直到赢家把锁释放了,输家才会被叫醒,重新加入战斗。
  • Spinlock (自旋锁):输家在那儿空转。它死死盯着那把锁,在一个循环里不断检查「锁释放了吗?锁释放了吗?」。它拒绝离开 CPU,就在那儿原地等着。

这听起来 Spinlock 似乎很蠢?浪费 CPU 电量?别急,我们在后面会讲到为什么有时候「睡觉」是更糟糕的选择。

类比回收时间

还记得那个「独木桥」的比喻吗?

  1. 建立映射:Mutex 就像是桥头上有个收费站。如果前面有车(锁被占用),你就把车熄火,在路边休息区睡觉(Sleep),直到收费员通知你醒过来。
  2. 揭示距离:但 Spinlock 不一样。Spinlock 就像是你在桥上堵车了,但你不下车,你就在驾驶座上死死盯着前车的尾灯,哪怕要等上一小时,你也保持脚踩离合、手挂一挡的姿势。这就是「自旋」。
  3. 回收验证:回到我们的代码场景。如果你持有锁的时间极短(比如只是给一个变量赋值),那么让别人去「自旋」等待你完事,比让他睡觉再叫醒(上下文切换的开销)要划算得多。这时候 Spinlock 就赢了。但如果你要干的活儿很久(比如读写磁盘),那 Spinlock 就是纯粹的 CPU 浪费。

死锁:当所有人都在等待

锁这东西,用不好就会反噬。最可怕的就是死锁

最经典的场景是 AB-BA 死锁

  • CPU 0 上的线程 A:拿到了锁 A,试图拿锁 B。
  • CPU 1 上的线程 B:拿到了锁 B,试图拿锁 A。

结果就是:A 等 B 释放 B,B 等 A 释放 A。两人互相瞪着对方,直到地老天荒。系统 Hang 住了。

还有一个更隐蔽的变种:自死锁。这在单锁场景下也会发生。如果某个线程拿着一把锁,然后试图再去拿这把锁(递归加锁),在内核的 Spinlock 实现里,这通常会导致死锁——因为它永远等不到自己释放锁。

怎么避免?

  1. 锁顺序规则:这是铁律。如果大家都遵循「先拿 A,再拿 B」的顺序,死锁就不可能发生。内核里很多地方都有注释明确规定锁的顺序。
  2. 短小精悍:持有锁的时间越短,死锁的概率越低,系统性能越好。这是「锁粒度」的问题。

12.3 实战工具箱:Mutex 还是 Spinlock?

理论说了那么多,动手写代码时该用哪个?这有一张简单的决策表。

决策树:该用谁?

  • 场景 A:我在进程上下文(比如系统调用),而且我的临界区可能会阻塞(比如要等待 I/O,或者要分配内存)。

    • 答案Mutex。因为你可以睡觉,而且睡觉比占着茅坑不拉屎要好。
  • 场景 B:我在中断上下文,或者任何原子上下文。

    • 答案Spinlock。因为你在原子上下文里根本不能睡觉!必须 Spin。
  • 场景 C:我在进程上下文,临界区非常短(比如只是修改一个指针或计数器),而且绝对不阻塞。

    • 答案Spinlock。因为为了避免睡眠带来的上下文切换开销,原地等待反而更高效。

工具 1:Mutex (互斥锁)

Mutex 是「睡眠锁」。它好的一面是允许你在持有锁的时候做一些可能阻塞的操作,坏的一面是它本身有开销(两次上下文切换:一次睡,一次醒)。

初始化

不要想当然地以为未初始化的锁是打开的。锁必须显式初始化。

#include <linux/mutex.h>

// 静态定义
static DEFINE_MUTEX(my_mutex);

// 动态初始化(通常在 module_init 里)
struct mutex my_mutex;
mutex_init(&my_mutex);

最佳实践:把锁放在它所保护的数据结构里面。

struct my_driver_data {
int some_value;
struct mutex lock; /* 保护这个结构体里的其他成员 */
};

这样你就不会搞混「这把锁到底保护谁」了。

使用 API

最基本的用法:

// 上锁(如果锁被占用,当前进程会进入不可中断的睡眠)
mutex_lock(&my_mutex);

/* ... 临界区代码 ... */

// 解锁
mutex_unlock(&my_mutex);

⚠️ 踩坑预警

  • 递归加锁:同一个线程对同一个 Mutex 调用两次 mutex_lock,后果通常是死锁或者错误。内核 Mutex 不支持递归。
  • 谁加锁,谁解锁:不要试图解锁别人持有的锁,也不要试图释放一个未初始化的锁。

中断睡眠变体

有时候你希望你的进程在等待锁的时候是可中断的(比如用户按了 Ctrl+C)。

if (mutex_lock_interruptible(&my_mutex) != 0) {
return -ERESTARTSYS; // 被信号打断了
}
/* ... 临界区 ... */
mutex_unlock(&my_mutex);

还有一种不等待的变体:

if (mutex_trylock(&my_mutex)) {
/* 拿到锁了,干活 */
mutex_unlock(&my_mutex);
} else {
/* 没拿到,做点别的事,稍后重试 */
}

工具 2:Spinlock (自旋锁)

Spinlock 是「忙等待锁」。它是个暴力的家伙。

初始化

和 Mutex 类似:

#include <linux/spinlock.h>

// 静态定义
static DEFINE_SPINLOCK(my_spinlock);

// 动态初始化
spinlock_t my_spinlock;
spin_lock_init(&my_spinlock);

使用 API:基础版

最简单的用法(仅适用于确定不会被中断打断的场景):

spin_lock(&my_spinlock);

/* ... 临界区:绝不睡眠!绝不访问用户空间内存!... */

spin_unlock(&my_spinlock);

⚠️ 踩坑预警:致命的睡眠

如果你在持有 Spinlock 的时候调用了任何会导致睡眠的函数(比如 kmalloc(GFP_KERNEL)copy_to_user,或者 msleep),你会触发一个著名的内核错误:

BUG: scheduling while atomic

这不仅意味着你的系统离崩溃不远了,而且这个错误会被 CONFIG_DEBUG_ATOMIC_SLEEP 这样的调试选项捕获,并在日志里狠狠地羞辱你。

为什么会这样? 因为 Spinlock 的设计初衷就是「我马上就要释放锁了,等我一下」。如果你去睡觉了,那谁也不知道你什么时候醒,其他在这个锁上自旋的 CPU 核心就会等到天荒地老。

使用 API:与中断共舞版

现实情况往往更复杂。如果你的进程上下文代码正在访问共享数据,突然一个硬件中断来了,而中断处理函数也要访问这同一个数据……那就麻烦了。

  • 场景:进程持有 Spinlock A。中断来了,中断处理函数试图获取 Spinlock A。
  • 结果:中断处理函数在等待进程释放锁,但因为中断优先级高,进程永远得不到 CPU 去释放锁。死锁

解决方案是:在获取锁的时候,把本 CPU 上的中断关掉。

unsigned long flags;

spin_lock_irqsave(&my_spinlock, flags); /* 关中断并保存状态 */

/* ... 临界区 ... */

spin_unlock_irqrestore(&my_spinlock, flags); /* 恢复之前的中断状态 */

这个 API 是最安全的。irqsave 后缀的意思是「把当前的中断掩码存到 flags 里,然后关中断」。解锁时,irqrestore 会把之前的掩码还原回去。

⚠️ 注意:如果你确定进临界区之前中断本来就是开着的,你可以偷懒用 spin_lock_irq(),但 irqsave 版本永远是更保险的选择,而且不会有明显的性能损失。

还有一种情况是和「下半部」——软中断/Tasklet —— 的冲突:

spin_lock_bh(&my_spinlock);
/* ... 临界区 ... */
spin_unlock_bh(&my_spinlock);

这会禁用软件中断,防止软中断抢跑。


12.4 总结:一份实战清单

现在让我们把这一章的知识压缩成一份你可以带去现场的清单。

  1. 识别临界区:问自己,这段代码会被并发执行吗?它在改全局数据吗?如果是,这就是临界区。
  2. 选锁
    • 可能睡眠? -> Mutex
    • 在中断上下文,或者临界区极短且不可睡眠? -> Spinlock
  3. 初始化DEFINE_MUTEXDEFINE_SPINLOCK,或者 _init 函数。
  4. 加锁/解锁
    • Mutex: mutex_lock / mutex_unlock
    • Spinlock: spin_lock / spin_unlock(小心中断!)。
    • Spinlock + 中断风险:spin_lock_irqsave / spin_unlock_irqrestore
  5. 避坑
    • 持有锁期间千万别睡眠。
    • 不要递归加锁。
    • 不要在锁内做耗时操作(锁粒度要细)。
    • 一定要在所有路径(包括 error path)上都释放锁!

下一章,我们会深入更黑暗的领域:原子变量、无锁编程,以及如何利用内核工具(如 Lockdep)来抓出那些藏得很深的并发 Bug。在那之前,先确保你手里的这一把锁,握得足够稳。


练习题

练习 1:understanding

题目:在 Linux 内核开发中,判断一段代码是否属于 Critical Section(临界区)必须满足哪两个核心条件?如果一段代码在进程上下文中运行且仅访问共享数据进行读取操作,是否可以不加保护?为什么?

答案与解析

答案:两个核心条件:1) 代码路径可能是并发的(即存在并行执行的可能性);2) 代码作用于共享可写数据(Shared Writable Data)。即使仅读取,通常也不能不加保护,因为可能导致 Dirty Read(脏读)或 Torn Read(撕裂读),即读取到不完整或不一致的数据。

解析:根据知识点定义,临界区必须满足“可能并发”和“访问共享可写数据”这两个条件。虽然读取操作看起来不修改数据,但在并发环境下,如果没有同步机制,读取操作可能会与写入操作同时发生。例如在 32 位系统上读取 64 位整数,由于无法原子性地完成一次读取,可能会读到一半的新值和一半的旧值(撕裂读)。因此,为了保证数据一致性,所有对共享数据的访问(包括读写)通常都需要互斥保护。

练习 2:application

题目:你正在编写一个网络设备驱动程序。已知以下情况:

  1. 有一个函数 netif_rx() 是用于接收数据包的核心函数。
  2. 该函数既会在进程上下文(通过系统调用)中被调用,也会在中断上下文(硬件中断处理程序 Hardirq)中被调用。
  3. 该函数需要访问一个全局共享的链表 pkt_queue

为了保证内核稳定,你应该使用 Mutex(互斥锁)还是 Spinlock(自旋锁)来保护 pkt_queue?请说明理由。

答案与解析

答案:应该使用 Spinlock(自旋锁)。

解析:这是一个应用场景题,考察对原子上下文和锁机制的理解。

  1. Mutex (睡眠锁):当锁被占用时,获取锁的线程会进入睡眠状态。这在进程上下文中是允许的,但在中断上下文 中是严格禁止的,因为中断处理程序不能调度睡眠。
  2. Spinlock (自旋锁):当锁被占用时,等待的线程会在循环中忙等待,不会引起睡眠。

由于题目明确指出 netif_rx() 会在中断上下文中被调用,这意味着代码运行在原子上下文中,不能使用可能导致睡眠的 Mutex。因此,必须使用 Spinlock 来保护共享数据。

练习 3:thinking

题目:假设我们有两个内核线程 Thread A 和 Thread B,以及两个资源锁 Lock1 和 Lock2。初始状态下两个锁均处于释放状态。

执行序列如下:

  1. Thread A 获取了 Lock1。
  2. 发生上下文切换,Thread B 开始执行。
  3. Thread B 获取了 Lock2。
  4. Thread B 尝试获取 Lock1(因为被 A 持有,B 进入阻塞/自旋等待)。
  5. 发生上下文切换,Thread A 恢复执行。
  6. Thread A 尝试获取 Lock2(因为被 B 持有,A 进入阻塞/自旋等待)。

请问此时发生了什么现象?基于知识点中的“Lock Ordering(锁顺序)”原则,如何从代码层面避免此类问题?

答案与解析

答案:这种现象被称为 Deadlock(死锁)。为了避免此类问题,应该遵循 Lock Ordering(锁顺序) 原则:定义一个全局的锁层级顺序,规定在代码中必须始终按照相同的顺序获取多个锁(例如,总是先获取 Lock1,再获取 Lock2,严禁逆序获取)。

解析:这是一个经典的死锁场景(Circular Wait 条件)。Thread A 持有 Lock1 并等待 Lock2,而 Thread B 持有 Lock2 并等待 Lock1,两者互相等待对方释放资源,导致永久阻塞,无法继续执行。

思考与解决方案: 在复杂的内核模块中,当需要获取多个锁时,很容易出现因代码路径不同而导致锁获取顺序不一致的情况。解决这一问题的核心在于打破“循环等待”条件。 内核开发者通常会制定严格的编码规范:所有代码路径在访问 Lock1 和 Lock2 时,必须先获取 Lock1,再获取 Lock2。这样,Thread A 获取 Lock1 后,Thread B 在尝试获取 Lock1 时就会被阻塞,从而无法去获取 Lock2,也就消除了互相等待的可能性。


要点提炼

内核开发中的并发安全始于对临界区的精准识别,即代码同时满足“可能被多执行流运行”和“操作共享可写状态”两个条件。由于像 i++ 这样的高级语言操作在底层往往会被编译器拆解为“读取-修改-写回”多条非原子指令,必须通过同步机制强制将这些区域串行化,否则极易发生数据覆盖或脏读等难以复现的竞态条件。

选择正确的锁机制取决于代码所在的上下文和执行特性。互斥锁适用于进程上下文且临界区可能发生阻塞(如等待 I/O 或分配内存)的场景,其代价是上下文切换的开销;而自旋锁则专用于中断上下文或临界区极短且绝不阻塞的场景,它通过 CPU 忙等待避免了调度开销,但持有期间严禁睡眠。

自旋锁的使用有严苛的规则,其中最大的陷阱是在持有锁期间调用可能引起睡眠的函数。这会导致系统抛出“Scheduling while atomic”的致命错误,破坏内核调度器。此外,若进程持有的自旋锁被本 CPU 上的中断处理程序再次请求,会造成死锁,因此必须使用 spin_lock_irqsave 在加锁时禁用本地中断以确保安全。

锁的设计本身引入了死锁的风险,最典型的是“AB-BA”死锁(两个线程以相反顺序持有两把锁)或递归加锁导致的自死锁。防范的核心铁律是强制规定全系统统一的锁获取顺序(如有多个锁,必须按固定顺序获取),并确保持有锁的时间尽可能短,以减少冲突概率。

在实际工程实践中,应将锁与受保护的数据结构封装在一起,并建立严密的代码审查清单。除了关注正常的执行路径外,必须格外警惕错误处理路径,确保在任何退出分支(包括异常返回)中都调用了对应的解锁操作,避免因逻辑漏洞造成锁泄漏而最终导致系统死锁。