跳到主要内容

第 6 章 独占的代价:临界区与原子性

本章的叙事核心

并发不是设计出来的,而是随着多核 CPU 和中断的到来,强行塞进系统里的麻烦。

我们将不得不面对「临界区」这个幽灵,并学会用互斥锁和自旋锁这两种脾气完全不同的工具来驯服它。在这个过程中,你会发现「原子性」这个词的分量,以及为什么内核开发者总是把「加锁」挂在嘴边。


6.0 章节引子:当秩序开始崩塌

有一类 bug,初看时完全随机,毫无规律——有时候程序跑得好好的,有时候却突然崩得莫名其妙。重启一下似乎好了,过会儿又坏了。这时候,经验丰富的工程师通常会叹口气,说:「看来是并发问题。」

并发问题,或者说同步问题,是操作系统内核里最让人头疼,也是最致命的陷阱。

想象一下,你和另一个人同时用一个记事本记账。如果没有约定,你们两个同时拿起笔,在同一行格子里写下不同的数字。结果是什么?那一行可能会变成一笔乱糟糟的涂鸦,或者后写的人覆盖了前一个人的数据。不管哪一种,账本错了。

在单核 CPU 的年代,我们还可以假装这种问题不存在——只要关掉中断,或者在代码里小心翼翼地避让,似乎就能安宁度日。但现在不一样了。现在的芯片是 SMP(对称多处理)的,哪怕是树莓派这种单板机,也是多核的。这意味着同一时刻,真的有多个代码流在不同的 CPU 核上物理并行地跑着。更别提还有随时会插一脚的中断处理程序。

旧的「天真」方案已经行不通了。你不能指望运气。你需要一套严格的规则来划定界限:在这段代码里,只有我能进,其他人(包括其他 CPU 核、中断)都在外面等着。这就是「临界区」和「互斥」诞生的原因。

如果处理不好并发,后果不仅仅是数据错乱,更是系统的彻底死锁或崩溃

本章的任务,就是搞清楚两件事:

  1. 到底谁在跟谁抢?(临界区与数据竞争)
  2. 用什么武器把他们隔开?(互斥锁 vs 自旋锁)

6.1 临界区、独占与原子性

并发问题在 Linux 内核中的隐忧

让我们先退一步,把问题想清楚。

并不是所有的代码都需要保护。只有那些访问共享可写数据的代码路径才是危险的。我们给这种危险的代码区域起了一个名字:临界区

数据竞争 就发生在临界区被不受保护地并发执行时。想象一下,两个 CPU 核同时执行 balance = balance + 1。这行代码在汇编层面不止一步:它需要从内存读 balance,加 1,再写回。如果不幸,两个 CPU 都读了旧的值(比如 100),分别加 1,然后写回 101,结果就是——本来应该变成 102 的账户,只有 101。那一块钱蒸发掉了。

为了解决这个问题,我们需要引入两个核心概念:

  1. 互斥:我要进厕所锁门,其他人必须在外面等。这就是 Mutual Exclusion
  2. 原子性:操作不可分割。要么做完,要么没做,不存在「做了一半被别人打断」这种情况。这就是 Atomicity

在内核里,我们要实现这两个概念,主要靠两种锁:互斥锁自旋锁


Mutex 还是 Spinlock?选择恐惧症指南

这是内核新手最容易晕的地方:到底该用哪个?

简单来说,这两个家伙的行为模式截然不同:

  • 互斥锁

    • 行为:如果锁被别人拿走了,你就去睡觉(休眠),把 CPU 让出来。等锁的主人释放了锁,内核再把 you 唤醒。
    • 适用场景:临界区比较长,或者你在做会阻塞的操作(比如等待 I/O)。
    • 代价:上下文切换的开销(睡眠和唤醒很费时间)。
    • 死敌:不能在中断上下文里用,因为中断处理程序不能睡觉。
  • 自旋锁

    • 行为:如果锁被拿走了,你就原地打转(死循环),一直在那儿问「好了没?好了没?」,直到拿到锁。
    • 适用场景:临界区非常短(比如改个指针,加个计数器)。而且,你必须在不能睡觉的上下文(比如中断处理、原子上下文)里用它。
    • 代价:如果在锁里耗时太久,其他 CPU 就在空转,浪费 CPU cycles,甚至导致系统 Thrashing(颠簸)——都在忙着等锁,没人干正事。

一句话总结

  • 可以睡?用 Mutex
  • 不能睡(或者时间极短)?用 Spinlock

使用互斥锁

互斥锁是内核中最常用的锁,毕竟大部分情况下我们都可以在进程上下文里舒舒服服地跑。

struct mutex 与初始化

内核用 struct mutex 这个数据结构来表示互斥锁。定义它通常有两种方式:

1. 静态定义(编译时):

static DEFINE_MUTEX(my_mutex);

这行代码定义并初始化了一个叫 my_mutex 的锁。

2. 动态初始化(运行时):

如果你是在运行时分配的锁(比如在 probe 函数里),你需要显式初始化:

struct mutex my_mutex;
mutex_init(&my_mutex);

加锁与解锁 API

一旦有了锁,使用就非常直观了。

/* 获取锁(进入临界区) */
/* 如果锁不可用,当前进程会进入不可中断的睡眠 */
mutex_lock(&my_mutex);

/* --- 临界区开始 --- */
/* 访问共享数据,做你想做的修改 */
/* --- 临界区结束 --- */

/* 释放锁(离开临界区) */
/* 必须由同一个任务调用 */
mutex_unlock(&my_mutex);

正确使用 mutex 的潜规则

这里有几个坑,踩进去就是死锁,请务必小心:

死锁陷阱一:递归调用 绝对不能在已经持有锁的情况下再次加锁。如果你试图在临界区里再次 mutex_lock(&my_mutex),你会死锁。内核的 mutex 不支持递归锁。它会以为自己拿了锁,然后在那儿等到地老天荒。这一步真的会炸。

死锁陷阱二:乱序加锁 如果你需要同时持有两个锁(比如锁 A 和锁 B),必须全系统统一顺序。大家都先拿 A,再拿 B。如果一个线程拿了 A 等待 B,另一个拿了 B 等待 A,这就是经典的 AB-BA 死锁

锁泄露:别忘了回家 拿了锁一定要放。而且不能在临界区里 return,或者跳到别的地方去,否则锁就泄露了,别人会永远等下去。

性能陷阱:别赖着不走 虽然 Mutex 允许休眠,但长时间持有锁会降低系统并发性能。锁的粒度要尽可能小,拿了赶紧干,干完赶紧放。


使用自旋锁

现在我们换个频道,聊聊那个「暴躁」的家伙——自旋锁。

自旋锁通常用于 SMP 系统,或者开启了内核抢占的单核系统。它的核心数据结构是 spinlock_t

基础用法

和 Mutex 类似,也有静态和动态两种初始化方式:

/* 静态 */
static DEFINE_SPINLOCK(my_spinlock);

/* 动态 */
spinlock_t my_spinlock;
spin_lock_init(&my_spinlock);

最基础的加锁/解锁 API 是 spin_lockspin_unlock

spin_lock(&my_spinlock);
/* --- 临界区 --- */
/* 注意:这里绝对不能调用任何会引发睡眠的函数! */
/* --- 临界区结束 --- */
spin_unlock(&my_spinlock);

⚠️ 注意 千万别在持有 spinlock 的时候睡觉(比如调用 kmalloc(GFP_KERNEL), msleep 等)。 如果你这么干,你会触发内核最著名的报错之一:"Scheduling while atomic"。 这意味着你在原子上下文里试图调度,系统会直接 panic 或者挂起。这真的会让你的血压拉满。

处理中断的棘手情况

这可能是自旋锁最让人头晕的地方。

假设你在一个进程上下文里拿着自旋锁,访问着共享数据。突然,一个中断来了,中断处理程序在同一个 CPU 上跑了。它也试图访问这同一个数据,于是它也去拿这个锁。

结果:死锁。中断处理程序会一直自旋等待锁释放,但锁的持有者(进程)被中断打断了,根本没机会运行,也就没机会释放锁。死循环达成。

为了解决这个问题,内核提供了一套带 irq 后缀的 API。它们的含义不仅仅是加锁,还包括开启/关闭本地 CPU 的中断

方案 1:spin_lock_irq / spin_unlock_irq

spin_lock_irq(&my_spinlock); /* 获取锁并禁用硬件中断 */
/* --- 临界区 --- */
spin_unlock_irq(&my_spinlock); /* 释放锁并恢复中断 */

这个组合简单粗暴。它适用于你不确定之前中断状态是什么,或者你不在乎的时候。

方案 2:spin_lock_irqsave / spin_unlock_irqrestore —— 最安全的方式

大多数情况下,我们应该用这个。它会保存当前的中断状态到标志变量里,解锁时恢复原状。

unsigned long flags;

spin_lock_irqsave(&my_spinlock, flags);
/* 获取锁,禁用中断,并将之前的中断状态保存到 flags 中 */

/* --- 临界区 --- */

spin_unlock_irqrestore(&my_spinlock, flags);
/* 恢复之前保存的中断状态,然后释放锁 */

为什么这个最好?因为如果你的临界区所在的代码环境本身就要求中断是关闭的,用 _irq 版本可能会错误地把中断打开。_irqsave 确保了「环境复原」。

方案 3:spin_lock_bh / spin_unlock_bh

如果你不担心硬件中断,而是担心底半部——软中断或 tasklet 的并发,可以用这个。它会禁用底半部的执行,但允许硬件中断打断。


自旋锁使用总结 —— 速查表

让我们把上面那堆 API 梳理一下,根据场景对号入座:

场景推荐使用的 API特点
简单情况
仅在进程上下文,不与中断/底半部共享数据
spin_lock()
spin_unlock()
开销最小。纯粹的自旋锁。
中等复杂度
进程上下文与中断共享数据,且不关心中断状态保存
spin_lock_irq()
spin_unlock_irq()
获取锁并禁用本地中断。简单但略显粗暴。
最安全 / 复杂
进程上下文与中断共享数据,需要严谨的状态恢复
spin_lock_irqsave()
spin_unlock_irqrestore()
这是最推荐的通用写法。保存并恢复中断状态,无副作用。
对抗底半部
与 Softirq / Tasklet 有竞争
spin_lock_bh()
spin_unlock_bh()
禁用底半部执行,允许硬件中断。

现在我们回过头看引子里的问题:谁在抢?是多核和中断。用什么隔开?如果允许睡就用 Mutex 让出 CPU,如果不允许睡或者时间极短,就得硬着头皮 Spinlock 歹守。


也就是,单核系统上自旋锁在干嘛?

你可能会问:在单核(UP)系统上,自旋锁还有意义吗?

这真是一个好问题。

既然只有一个 CPU,锁的持有者在运行,等待者就没法运行。等待者怎么自旋?它根本跑不起来!

答案是:在 UP 系统上,自旋锁的「自旋」逻辑被优化掉了spin_lock 在编译时会被替换为空操作(或者仅仅是抢占计数器的增加)。

但是! 注意那个 irq 相关的 API。 即使在 UP 系统上,spin_lock_irqsave 依然是有意义的。因为它会禁用中断。这正是我们需要的——在单核上防止死锁的唯一办法就是确保你被打断时,中断不会去争抢同一把锁。

所以,这里有一个极其重要的工程原则:

作为驱动开发者,不要去管是 UP 还是 SMP。 统统按照 SMP 的逻辑写,统统用标准 API。内核内部会帮你处理细节。在单核上,那些无用的自旋循环会自动消失,留下来的只有必要的禁用中断逻辑。


补充:关于 5.8 内核的 "Local Locks"

这还不算完。到了 5.8 内核,实时 Linux(PREEMPT_RT)项目又引入了一个新叫法——「Local Locks」。这个对新手来说可能太远了,知道有这么回事就行:它的主要用途是为硬实时内核提供优化,但在非实时内核上,它对锁的调试(尤其是配合 lockdep 这种工具)非常有帮助。如果你正在写对实时性要求极高的代码,可以去了解一下 LWN 上关于这个特性的文章。


练习题

练习 1:understanding

题目:假设你的 Linux 驱动程序中有一个全局静态变量 static int safety_count;,在驱动的 read 方法中有如下代码:safety_count++;。如果该驱动在启用了 CONFIG_SMP 的多核系统上运行,且没有使用任何锁机制,请解释为什么这可能导致数据不一致(脏读或更新丢失)?

答案与解析

答案:因为 i++ 操作在大多数处理器架构上不是原子指令,通常包含“读取-修改-写入”三个步骤。在没有锁保护的情况下,两个线程可能同时读取到相同的旧值,分别加一后写回,导致一次增加丢失,或者读取到只更新了一半的数据。

解析:考察对 Critical section(临界区)和 Data race(数据竞争)的理解。虽然单条 C 代码看起来简单,但编译器生成的机器码通常涉及 load、inc、store 多条指令。在多核环境下,Thread A 和 Thread B 可能同时执行 load 指令,得到相同的初始值,随后各自增量并写回,最终结果只增加了 1 而不是预期的 2。这就是典型的临界区未被保护导致的数据竞争。

练习 2:application

题目:你正在编写一个块设备驱动,需要在进程上下文的 write 方法中分配内存并复制大量用户数据,同时需要保护一个访问频繁的全局链表。你应该选择 Mutex(互斥锁)还是 Spinlock(自旋锁)?为什么?

答案与解析

答案:应该选择 Mutex(互斥锁)。

解析:考察 Mutex 与 Spinlock 的选择依据。题目场景是“进程上下文”且涉及“分配内存”和“复制大量数据”,这些都是可能引起阻塞的操作。Spinlock 的设计初衷是用于短时间的临界区保护,持有期间线程处于忙等待状态且不能休眠。如果在持有 Spinlock 时调用可能睡眠的函数(如内存分配),会导致内核崩溃或死机。Mutex 允许持有者进入睡眠,适合这种可能耗时且需要阻塞的场景。

练习 3:application

题目:在一个字符设备驱动中,驱动程序的 read 方法(进程上下文)和底半部中断处理函数(软中断/softirq 上下文)都会访问同一个共享队列。如果只能使用锁机制来同步,你应该选用哪种锁 API 来保护 read 方法中的临界区?请写出具体的锁函数调用。

答案与解析

答案:应该使用 spin_lock_irqsave()spin_unlock_irqrestore()(或者在确定中断状态的情况下使用 spin_lock_irq() / spin_unlock_irq())。

解析:考察中断上下文与进程上下文共享数据时的同步策略。软中断(softirq)会抢占进程上下文运行。如果进程上下文仅使用普通的 spin_lock(),它可能在持有锁的时候被本地 CPU 的软中断打断。如果软中断代码也尝试获取同一个锁,就会发生死锁(因为持有锁的进程还没机会释放锁就被打断了)。因此,必须使用 spin_lock_irq* 系列函数,在获取锁的同时禁用本地 CPU 的中断,确保临界区不会被本地中断处理程序打断。

练习 4:thinking

题目:假设一个实时系统中有三个线程:高优先级线程 H、中优先级线程 M 和低优先级线程 L。线程 L 持有互斥锁 Mutex,线程 H 正在等待该锁,而线程 M 正在占用 CPU 运行(未等待锁)。在这种情况下,会发生什么现象?这与使用 RT-mutex 有什么区别?

答案与解析

答案:会发生优先级反转。线程 H 被阻塞等待 L,而 L 因为优先级低无法运行以释放锁,反而被优先级居中且无需等待锁的 M 抢占运行,导致 H 实际上被 M 阻塞,表现得像 M 的优先级一样低。RT-mutex 通过优先级继承机制解决了这个问题:当 H 等待 L 持有的锁时,L 的优先级会临时提升到 H 的级别,从而能够快速执行并释放锁,避免被 M 长时间抢占。

解析:考察对并发编程中经典问题“优先级反转”及其解决方案的理解。使用普通 Mutex 时,调度器仅根据静态优先级调度,导致高优先级任务间接受制于中优先级任务。RT-mutex 引入的优先级继承协议确保了持有锁的低优先级任务在必要时能获得足够的 CPU 时间来完成任务,从而消除无界的优先级反转延迟。


要点提炼

并发问题源于多核物理并行和中断打断,导致共享数据的非原子性操作(如“读-改-写”)出现数据竞争,其核心风险在于临界区内的指令序列被干扰。因此,系统必须引入互斥机制划定界限,确保在访问共享可写状态时的排他性,以防止数据错乱或系统崩溃。

处理并发的主要工具是互斥锁和自旋锁,二者的选择取决于临界区长度和上下文要求。互斥锁适用于允许休眠的进程上下文及较长的操作,通过睡眠避免 CPU 浪费;而自旋锁适用于临界区极短或不可休眠(如原子上下文)的场景,它通过 CPU 忙等待来维持对临界区的保护。

互斥锁的使用需严格遵循规则:不可递归加锁,持有锁期间禁止跳转或 return 导致锁泄露,且持有锁的时间应尽可能短。此外,在涉及多锁时必须遵循全局统一的加锁顺序,以防止经典的 AB-BA 死锁,因为内核互斥锁机制本身并不自动检测或处理这类死锁。

自旋锁的使用比互斥锁更为苛刻,要求临界区内绝对禁止调用任何可能引发睡眠的函数(如内存分配或延时),否则会触发“Scheduling while atomic”错误导致系统崩溃。在单核系统上,自旋的“忙等待”逻辑虽被优化去除,但其配合中断控制机制(如 spin_lock_irqsave)来防止死锁的功能依然保留。

当中断处理程序与进程上下文存在数据竞争时,必须使用带中断控制的 API(推荐 spin_lock_irqsave/spin_unlock_irqsave)。这不仅能获取锁,还能安全地屏蔽本地 CPU 的硬件中断并保存状态,防止中断处理程序在同一 CPU 上试图获取已持有的锁而造成死锁。

还记得开头那个问题吗——到底谁在跟谁抢? 答案是多核和中断用什么武器隔开? 答案是:如果允许睡就用 Mutex 让出 CPU,如果不允许睡或者时间极短,就得硬着头皮 Spinlock 歹守