Skip to content

从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。如果你的驱动被多个进程打开,它们可能同时进入你的readwrite函数。

c
/* 两个进程同时打开同一个设备 */
$ cat /dev/mydevice &  /* 进程A */
$ cat /dev/mydevice &  /* 进程B */

这两个进程可能同时在不同的CPU核心上执行mydevice_read()函数。如果它们操作同一个全局变量,那就完了。

来源二:抢占式并发访问

从2.6版本内核开始,Linux内核是可抢占的。这意味着什么?意味着你的内核代码正在运行时,调度器可能会突然说:「停,你太慢了,换别人跑。」然后把你强行换下CPU。

c
/* 你正在执行这个函数 */
void my_function(void) {
    global_counter++;  /* 刚执行完这一行 */

    /* ← 调度器突然把你切走了! */

    global_counter *= 2;  /* 等你回来的时候,global_counter已经被别人改了 */
}

等你恢复运行时,全局变量已经被别人改了。这叫「上下文切换导致的竞争」。

来源三:中断程序并发访问

中断的优先级很高。无论你的代码执行到哪一步,一旦硬件中断来了,CPU必须立刻响应。

c
void my_function(void) {
    spin_lock(&lock);  /* 获取锁 */

    /* ← 硬件中断发生!CPU跳转到ISR执行 */

    /* 中断服务程序ISR */
    void irq_handler(void) {
        spin_lock(&lock);  /* 试图获取同一把锁...死锁! */
    }

    spin_unlock(&lock);
}

中断说:「你先把锁放开我才能干活。」 线程说:「你把CPU还给我,让我跑完,我就能放开锁。」 互相指着鼻子,谁也动不了。这就是死锁

来源四:SMP(多核)核间并发访问

现在的ARM处理器,双核、四核甚至八核都很常见。如果是多核CPU,情况就更糟了。

c
/* Core 0 上执行 */
void core0_function(void) {
    global_var = 10;
}

/* 同时,Core 1 上执行 */
void core1_function(void) {
    global_var = 20;
}

这两个函数是真正物理意义上的「同时」执行。即使关掉内核抢占,两个核心依然可以同时读写同一块内存。这是最难以调试的并发问题,因为你无法通过单步调试来重现它——单步调试时,时间被拉长了,竞争条件消失了。

我们到底在保护什么?

前面一直在说「防止并发访问」、「防止竞争」,听起来像是在守卫一座城堡。那么问题来了:城堡里到底有什么?

我们需要保护的内容,是数据

一定要记住:我们保护的不是代码,而是数据。

某个线程的局部变量是不需要保护的。为什么?因为局部变量存在栈上,每个线程都有自己的私有栈,互不干扰。你改你的,我改我的,井水不犯河水。

我们要保护的,是那些多个执行流都能看到、摸到的共享数据。这通常包括:

  • 全局变量:最明显的受害者
  • 设备结构体里的成员:驱动程序的核心数据
  • 动态分配的、并且指针被多处引用的内存:多个地方引用同一块内存
  • 硬件寄存器:这是最典型的共享资源

在代码中找到「谁需要保护」,是解决并发问题最难的一步。这需要你对着自己的代码,像审视犯罪现场一样分析:

  • 这个变量会在open函数里被改吗?
  • 会在read函数里被读吗?
  • 如果中断来了,中断处理函数会碰它吗?
  • 如果是多核,另一个核会不会同时访问它?

一般来说,像全局变量、设备结构体这种「大家伙」,肯定是要重点保护的。至于其他的临时变量或局部缓存,就要视具体逻辑而定了。

临界区:独木桥上的通行规则

理解了要保护什么,我们还需要引入一个非常重要的概念:临界区

你可以把临界区理解为「独木桥」——一次只能过一个人。

但这个比喻有一处是不准确的:过独木桥的时候,你看得见对面有没有人;而在代码里,你看不见别的线程在干什么。

实际上,代码是静态的,执行流是动态且不可见的。你无法知道你的代码执行到哪一行时,调度器会不会突然切换到另一个线程,或者硬件会不会突然抛出一个中断。这种不可见的打断,就是「并发」的来源。

临界区就是那些「如果被打断就会出问题」的代码段。

举个简单的例子:

c
/* 假设 global_counter 是一个全局变量 */
global_counter++;  /* 这一行代码就是临界区! */

为什么?因为在C语言层面,这只是一行代码。但在CPU层面,这变成了三件事:

  1. 从内存读取global_counter的值到寄存器
  2. 在寄存器里加1
  3. 把结果写回内存

如果在这三步中间被打断,比如在步骤1和步骤2之间,另一个线程也读取了global_counter的值,那么两个线程都会基于同一个旧值进行加1操作。最终结果只加了1,而不是预期的2。

这就是竞争条件(Race Condition)。多个执行流「竞争」同一个资源,谁先谁后不确定,结果也不确定。

从0开始识别危险区域

现在的问题是:怎么在代码里找到这些危险区域?

这里有一个实用的方法论:

步骤1:找出所有共享数据

c
/* 你的驱动代码 */
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:找出所有访问共享数据的代码路径

c
/* 路径1:my_open() -> 修改 global_counter */
/* 路径2:my_read() -> 读取 global_counter */
/* 路径3:中断处理函数 -> 修改 global_counter */

步骤3:判断这些路径是否会并发执行

  • my_open()my_read()会被多个进程同时调用吗?
  • 中断会在my_open()执行期间发生吗?
  • 如果是多核,这些函数会在不同核心上同时运行吗?

步骤4:标记临界区

一旦你确定了哪些代码会并发执行,就把它们标记为临界区:

c
/* 临界区开始 */
global_counter++;
/* 临界区结束 */

这些临界区,就是我们需要加锁保护的地方。

写在最后:这只是开始

到这里,我们已经建立了一个核心认知:并发是不可避免的,我们必须主动管理它。

但识别并发只是第一步。接下来,我们需要学习如何控制并发。Linux内核提供了丰富的工具:

  • 原子操作:最轻量级,适合简单的计数器
  • 自旋锁:让CPU空转等待,适合极短的临界区
  • 信号量和互斥体:让线程睡眠等待,适合较长的临界区

这些工具各有各的使用场景和代价,选错了锁,比不锁更可怕。在接下来的几节里,我们会逐一拆解这些武器库,让你知道什么时候用什么,以及——更重要的是——什么时候不能用什么。

因为记住,在并发世界里,错误的锁比没有锁更危险


本章要点

  1. Linux内核中的并发来源有四个:多线程、内核抢占、中断、SMP多核。单核思维已不适用。
  2. 我们保护的是数据,不是代码。局部变量不需要保护,共享数据需要。
  3. 临界区是「如果被打断就会出问题」的代码段,需要用锁保护起来。
  4. 识别并发问题的方法:找出共享数据 → 找出访问路径 → 判断是否并发 → 标记临界区。

Built with VitePress