从0开始理解Linux内核并发:多核时代的生存法则
前言:为什么这个问题在2026年比以往任何时候都重要
老实说,当我第一次在代码里遇到并发bug的时候,我是真的懵了。
不是因为代码有多难理解——那只是几个全局变量和一个简单的计数器——而是因为这个问题完全不可复现。开发环境跑得好好的,到了用户手里就莫名其妙崩溃;调试了一百次都没问题,第一百零一次,因为timing的微小差异,系统崩了。
这类bug最折磨人的地方在于它的「幽灵性」。你调了一整天,什么都查不出来,准备下班的时候,它又出现了。你开始怀疑人生,怀疑CPU,甚至怀疑是不是量子力学在作祟。
但当你真正理解了并发之后,你会发现这一切都有迹可循。而且,在2026年的今天,这个问题比以往任何时候都重要——因为单核时代已经彻底结束了。
我们现在面对的ARM SoC,双核、四核甚至八核都是标配。i.MX6ULL虽然是2016年的芯片,但也是双核Cortex-A7。多核意味着什么?意味着真正的物理并行,意味着你的代码可能在Core 0上跑的同时,另一个线程在Core 1上跑。它们是同时执行的,不是轮流切换的。
这就导致了一个残酷的现实:你在单核思维里显而易见的「顺序」,在多核系统的现实中根本不存在。
环境:我们在这片土地上耕作
在开始之前,先说明一下我们的环境:
| 项目 | 版本/信息 |
|---|---|
| 内核版本 | Linux 7.0-rc4 (主线内核) |
| 架构 | ARMv7-A (Cortex-A7 dual-core) |
| 工具链 | arm-none-linux-gnueabihf-gcc |
| 开发板 | i.MX6ULL (正点原子Alpha板) |
| 特殊约束 | SMP启用,内核抢占启用 |
这个配置很重要,因为并发问题的表现形式会根据配置不同而变化。比如在单核非抢占内核上,某些竞争条件可能永远不会出现;但在我们的双核抢占式内核上,它们就是家常便饭。
并发的四大源头:到底是谁在捣乱
我们要做的第一件事,就是搞清楚「并发」到底从哪里来。很多初学者以为并发就是「多线程」,其实在Linux内核里,并发来源要复杂得多。
来源一:多线程并发访问
这是最基本、最容易理解的。Linux是贪婪的多任务系统,多个进程/线程本来就在竞争CPU。如果你的驱动被多个进程打开,它们可能同时进入你的read或write函数。
/* 两个进程同时打开同一个设备 */
$ cat /dev/mydevice & /* 进程A */
$ cat /dev/mydevice & /* 进程B */这两个进程可能同时在不同的CPU核心上执行mydevice_read()函数。如果它们操作同一个全局变量,那就完了。
来源二:抢占式并发访问
从2.6版本内核开始,Linux内核是可抢占的。这意味着什么?意味着你的内核代码正在运行时,调度器可能会突然说:「停,你太慢了,换别人跑。」然后把你强行换下CPU。
/* 你正在执行这个函数 */
void my_function(void) {
global_counter++; /* 刚执行完这一行 */
/* ← 调度器突然把你切走了! */
global_counter *= 2; /* 等你回来的时候,global_counter已经被别人改了 */
}等你恢复运行时,全局变量已经被别人改了。这叫「上下文切换导致的竞争」。
来源三:中断程序并发访问
中断的优先级很高。无论你的代码执行到哪一步,一旦硬件中断来了,CPU必须立刻响应。
void my_function(void) {
spin_lock(&lock); /* 获取锁 */
/* ← 硬件中断发生!CPU跳转到ISR执行 */
/* 中断服务程序ISR */
void irq_handler(void) {
spin_lock(&lock); /* 试图获取同一把锁...死锁! */
}
spin_unlock(&lock);
}中断说:「你先把锁放开我才能干活。」 线程说:「你把CPU还给我,让我跑完,我就能放开锁。」 互相指着鼻子,谁也动不了。这就是死锁。
来源四:SMP(多核)核间并发访问
现在的ARM处理器,双核、四核甚至八核都很常见。如果是多核CPU,情况就更糟了。
/* Core 0 上执行 */
void core0_function(void) {
global_var = 10;
}
/* 同时,Core 1 上执行 */
void core1_function(void) {
global_var = 20;
}这两个函数是真正物理意义上的「同时」执行。即使关掉内核抢占,两个核心依然可以同时读写同一块内存。这是最难以调试的并发问题,因为你无法通过单步调试来重现它——单步调试时,时间被拉长了,竞争条件消失了。
我们到底在保护什么?
前面一直在说「防止并发访问」、「防止竞争」,听起来像是在守卫一座城堡。那么问题来了:城堡里到底有什么?
我们需要保护的内容,是数据。
一定要记住:我们保护的不是代码,而是数据。
某个线程的局部变量是不需要保护的。为什么?因为局部变量存在栈上,每个线程都有自己的私有栈,互不干扰。你改你的,我改我的,井水不犯河水。
我们要保护的,是那些多个执行流都能看到、摸到的共享数据。这通常包括:
- 全局变量:最明显的受害者
- 设备结构体里的成员:驱动程序的核心数据
- 动态分配的、并且指针被多处引用的内存:多个地方引用同一块内存
- 硬件寄存器:这是最典型的共享资源
在代码中找到「谁需要保护」,是解决并发问题最难的一步。这需要你对着自己的代码,像审视犯罪现场一样分析:
- 这个变量会在
open函数里被改吗? - 会在
read函数里被读吗? - 如果中断来了,中断处理函数会碰它吗?
- 如果是多核,另一个核会不会同时访问它?
一般来说,像全局变量、设备结构体这种「大家伙」,肯定是要重点保护的。至于其他的临时变量或局部缓存,就要视具体逻辑而定了。
临界区:独木桥上的通行规则
理解了要保护什么,我们还需要引入一个非常重要的概念:临界区。
你可以把临界区理解为「独木桥」——一次只能过一个人。
但这个比喻有一处是不准确的:过独木桥的时候,你看得见对面有没有人;而在代码里,你看不见别的线程在干什么。
实际上,代码是静态的,执行流是动态且不可见的。你无法知道你的代码执行到哪一行时,调度器会不会突然切换到另一个线程,或者硬件会不会突然抛出一个中断。这种不可见的打断,就是「并发」的来源。
临界区就是那些「如果被打断就会出问题」的代码段。
举个简单的例子:
/* 假设 global_counter 是一个全局变量 */
global_counter++; /* 这一行代码就是临界区! */为什么?因为在C语言层面,这只是一行代码。但在CPU层面,这变成了三件事:
- 从内存读取
global_counter的值到寄存器 - 在寄存器里加1
- 把结果写回内存
如果在这三步中间被打断,比如在步骤1和步骤2之间,另一个线程也读取了global_counter的值,那么两个线程都会基于同一个旧值进行加1操作。最终结果只加了1,而不是预期的2。
这就是竞争条件(Race Condition)。多个执行流「竞争」同一个资源,谁先谁后不确定,结果也不确定。
从0开始识别危险区域
现在的问题是:怎么在代码里找到这些危险区域?
这里有一个实用的方法论:
步骤1:找出所有共享数据
/* 你的驱动代码 */
static int global_counter = 0; /* ← 共享数据 */
static struct my_device_data *dev_data; /* ← 共享数据 */
static int my_open(struct inode *inode, struct file *file) {
/* ... */
}
static ssize_t my_read(struct file *file, char __user *buf, size_t count, loff_t *ppos) {
/* 这些函数都会访问上面的共享数据 */
}步骤2:找出所有访问共享数据的代码路径
/* 路径1:my_open() -> 修改 global_counter */
/* 路径2:my_read() -> 读取 global_counter */
/* 路径3:中断处理函数 -> 修改 global_counter */步骤3:判断这些路径是否会并发执行
my_open()和my_read()会被多个进程同时调用吗?- 中断会在
my_open()执行期间发生吗? - 如果是多核,这些函数会在不同核心上同时运行吗?
步骤4:标记临界区
一旦你确定了哪些代码会并发执行,就把它们标记为临界区:
/* 临界区开始 */
global_counter++;
/* 临界区结束 */这些临界区,就是我们需要加锁保护的地方。
写在最后:这只是开始
到这里,我们已经建立了一个核心认知:并发是不可避免的,我们必须主动管理它。
但识别并发只是第一步。接下来,我们需要学习如何控制并发。Linux内核提供了丰富的工具:
- 原子操作:最轻量级,适合简单的计数器
- 自旋锁:让CPU空转等待,适合极短的临界区
- 信号量和互斥体:让线程睡眠等待,适合较长的临界区
这些工具各有各的使用场景和代价,选错了锁,比不锁更可怕。在接下来的几节里,我们会逐一拆解这些武器库,让你知道什么时候用什么,以及——更重要的是——什么时候不能用什么。
因为记住,在并发世界里,错误的锁比没有锁更危险。
本章要点
- Linux内核中的并发来源有四个:多线程、内核抢占、中断、SMP多核。单核思维已不适用。
- 我们保护的是数据,不是代码。局部变量不需要保护,共享数据需要。
- 临界区是「如果被打断就会出问题」的代码段,需要用锁保护起来。
- 识别并发问题的方法:找出共享数据 → 找出访问路径 → 判断是否并发 → 标记临界区。