跳到主要内容

第 13 章 内核同步(下半部分)

这不仅仅是一章关于“如何加锁”的教程。我们已经熬过了上一章的基础洗礼,现在要面对的是内核同步中那些真正棘手、真正考验你对系统底层理解的问题。

如果说上一章是在教你怎么“守规矩”,那么这一章就是在告诉你:规矩是用来打破的——或者说,是为了在更高性能的维度上重新定义规矩。

我们即将进入的是一片充满雷区的领域:整数溢出的陷阱、看起来锁死了其实不然的“无锁”编程、能让多核性能暴跌的缓存伪共享,以及被誉为“社会工程学”奇迹的 RCU 机制。

这里没有那么多“理所当然”。每一个 API 背后,都是对硬件行为的精确控制。


13.1 当整数变成战场:原子操作与引用计数

我们从一个最简单的场景切入。还记得你最开始写那个简单的 misc 字符设备驱动时吗?(在 Linux Kernel Programming – Part 2 这本书的配套代码里)。在那个驱动的 open 方法里,你可能写过类似这样的代码:

static int ga, gb = 1;
/* ... */
ga++;
gb--;

这看起来人畜无害。但如果你还记得上一章我们反复强调的“临界区”概念,你应该已经开始冒冷汗了:gagb 是全局变量,也就是共享可写状态。如果多个进程试图同时打开这个设备,这段代码就是典型的数据race(竞争条件)现场。

在上一章,我们用互斥锁修复了这个问题,然后用自旋锁提升了性能。代码变成了这样:

spin_lock(&lock1);
ga++; gb--;
spin_unlock(&lock1);

这没问题,但这就是终点了吗?并不是。

这太重了。

仅仅为了给两个整数加减 1,就要引入一把锁,还要处理可能导致睡眠或阻塞的各种边界情况。内核开发者意识到,对整数的操作是如此频繁(引用计数、资源统计、状态标记),以至于值得为它们设计一套专用的、硬件层面的原子指令集。

这就是 atomic_t 和后来更安全的 refcount_t 登场的原因。


13.1.1 atomic_trefcount_t:旧的信任与新的安全

这里有一个历史遗留问题。在 4.10 内核(含)之前,我们只有 atomic_t 接口。它很好用,但有个致命缺陷:它是整数,它可能会溢出或下溢

如果你把一个引用计数从 0 变成 -1,或者从 INT_MAX 变成 0,内核可能根本不会报警,数据可能悄无声息地损坏,导致最可怕的Use-After-Free (UAF) 漏洞。

从 4.11 内核开始,Linux 引入了一套新的接口:refcount_t

你可以把它看作是 atomic_t 的“安全强化版”。它的设计哲学极其激进:宁可错杀,不可放过


引入类比:计数的“防死锁器”

想象一个传统的机械计数器(atomic_t)。你用力拨动它,它可以一直转,从 9999 变回 0000,甚至变成负数(如果是带符号的话)。这叫回绕。在程序世界里,这通常意味着灾难。

refcount_t 就像是一个带有防死锁器的现代电子计数器。如果你试图把它拨到 0 以下,或者超过最大值,它会卡死在一个特定的错误值上(比如 REFCOUNT_SATURATED),然后大声报警(WARN)。它绝不会悄悄地回绕。


refcount_t 这个“防死锁器”的比喻有一个地方需要注意:它不仅仅是防呆,它还涉及内存序

在传统的 atomic_t 中,我们主要关心操作本身不可分割。但在 refcount_t 中,内核为了保证多核环境下的绝对安全,强制规定了内存操作的顺序。这不是简单的“加减法”,这是在协调多个 CPU 核心眼中的现实世界。

回到那个防死锁计数器:它不仅卡死了,还会在卡死的那一刻,强制刷新所有的“计数缓存”,确保其他人都知道它已经坏了,而不是看着一个旧的、错误的数值继续操作。


新老接口的对比:一张表看懂差异

在深入细节之前,我们先看一张表,这能帮你快速建立直觉。

atomic_trefcount_t 的用法非常相似,但语义和范围截然不同。

操作旧的 32-bit atomic_t新的 refcount_t (32/64-bit)
有效范围整个 int 范围严格受限: [1 .. INT_MAX-1]
初始化static atomic_t v = ATOMIC_INIT(1);static refcount_t v = REFCOUNT_INIT(1);
读取int val = atomic_read(&v);unsigned int val = refcount_read(&v);
设置atomic_set(&v, i);refcount_set(&v, i);
自增atomic_inc(&v);refcount_inc(&v);
自减atomic_dec(&v);refcount_dec(&v);
加法atomic_add(i, &v);refcount_add(i, &v);
减法atomic_sub(i, &v);refcount_sub(i, &v);

⚠️ 踩坑预警 千万别把 refcount_t 当成通用的原子整数用! 它的设计契约决定了它只能在 [1, INT_MAX-1] 之间。如果你把它设为 0,或者减到了 0,它会触发 WARN_ONCE(),并饱和在一个特殊的负值(0xc0000000,即 REFCOUNT_SATURATED)上。 如果你需要通用的原子计数,请老实使用 atomic_t

思考题:为什么 refcount_t 不允许 0?

答案:因为对象的生命周期由引用计数管理。当计数为 0 时,对象就该被释放了。如果你还能读取到值为 0 的 refcount,说明你已经有一个 Use-After-Free 的逻辑漏洞了。所以,最后一次看到 1 的人负责释放对象,而不是让它变成 0 还赖在内存里。


动手:让引用计数溢出(并看到内核尖叫)

光说不练假把式。我们来故意制造一次溢出,看看 refcount_t 的“防死锁器”是怎么工作的。

这里有一段刻意写坏的代码:

static refcount_t ga = REFCOUNT_INIT(42); /* 初始化为 42 */

static int open_miscdrv_rdwr(struct inode *inode, struct file *filp)
{
/* 正常情况:自增 */
refcount_inc(&ga);

/* ... 坏情况(通过宏控制):故意溢出 ... */
#if 0
pr_debug("*** Bad case! About to overflow refcount var! ***\n");
/* 加上一个巨大的 INT_MAX,必定溢出 */
refcount_add(INT_MAX, &ga);
#endif
// ...
}

如果你把 #if 0 改成 #if 1,编译、加载并运行这个模块,你会看到系统日志里弹出一个刺眼的 WARNING

更重要的是,当你检查 ga 的值时,它不再是那个回绕后的小数字,而是变成了一个诡异的常量:0xc0000000

这就是 REFCOUNT_SATURATED

这意味着什么? 内核在说:“我知道出错了,但我把它钉死在这个错误状态,防止它变成一个看似合法的数值(比如 0)从而导致后续代码误判并释放内存。”这是一种牺牲可用性换取安全性的策略。


13.1.2 64位世界:atomic64_t

我们上面讨论的都是 32 位的 atomic_t。现在的世界是 64 位的,显然我们也需要 64 位的原子操作。

其实这没啥好讲的,就是换个名字的事。

  • 类型:atomic64_t (其实就是 atomic_long_t)
  • API:把所有的 atomic_ 前缀换成 atomic64_

比如:

  • ATOMIC_INIT(1) -> ATOMIC64_INIT(1)
  • atomic_read() -> atomic64_read()

13.1.3 RMW:不仅仅是读写,而是“读-改-写”

到目前为止,我们处理的都是简单的加减。但在内核里,尤其是写驱动时,还有一种更微妙的需求:位操作

想象你在操作一个硬件寄存器(MMIO)。你想把第 7 位(MSB)置 1,以开启某个功能。

你可能会写出这样的代码:

u8 tmp;
tmp = ioread8(CTRL_REG); /* 1. 读 */
tmp |= 0x80; /* 2. 改 */
iowrite8(tmp, CTRL_REG); /* 3. 写 */

这就是经典的 RMW (Read-Modify-Write) 序列。

但这是有危险的。

如果在步骤 1 和步骤 3 之间,发生了上下文切换,或者另一个核心也同时修改了这个寄存器,你的操作可能被覆盖,或者你覆盖了别人的操作。这就是数据竞争

方案一:加锁 你可以用自旋锁把这仨步骤包起来。但这又是“为了几行指令引入一把锁”的老故事,开销大。

方案二:RMW 原子指令 现代 CPU(x86 的 lock 前缀,ARM 的 LDXR/STXR)提供了一条龙式的 RMW 原子指令。Linux 内核把它们封装成了极其好用的 API。

/* 以前 */
spin_lock(&lock);
tmp = ioread8(CTRL_REG);
tmp |= 0x80;
iowrite8(tmp, CTRL_REG);
spin_unlock(&lock);

/* 现在 */
set_bit(7, CTRL_REG); /* 搞定 */

揭示距离:原子位操作的“谎言”

这里我要揭示一个关于 set_bit() 的“谎言”。

虽然我们叫它“原子位操作”,而且它确实用了一条汇编指令搞定,但在多核环境下,它并不保证多核之间的原子性

Wait, what?

这听起来很反直觉,但请仔细看set_bit() 保证了在当前 CPU 核心上,操作是原子的。但是,如果 CPU 0 在操作某个内存地址,而 CPU 1 同时也在操作同一个地址(不管是用 set_bit 还是别的),这就依然构成了竞争。

所以,规则是

  • 如果操作的是普通内存(RAM),且涉及多核并发访问,你依然需要一把锁来包裹 set_bit(),或者使用 atomic_t 这种自带锁语义的变量。
  • 如果操作的是设备寄存器(MMIO),且硬件本身保证了某种级别的原子性(或者你只在意不需要锁的简单场景),set_bit() 是神器。

为什么这很重要? 因为很多初学者会误以为 set_bit() 是万能的,从而在驱动里省去了本该存在的锁。不要陷入这个陷阱。


回到那个设备寄存器:为什么 set_bit 更好?

回到刚才那个设备的例子。如果我们只是想控制一个位,用 set_bit(7, CTRL_REG) 不仅仅是为了省去写锁的代码量。

它是真的快。

对比测试显示,在 x86_64 上:

  • 手动加锁 RMW:约 125 纳秒。
  • set_bit() RMW:约 29 纳秒。

差距高达 4 倍以上。

这背后的原因很简单:自旋锁在获取和释放时,即便没有竞争,也有内存屏障和锁总线的开销。而 lock 指令(set_bit 的底层实现)虽然也锁总线,但它只锁那一次操作,且由硬件直接优化,开销极低。


13.1.4 读写锁的自白:我也许会饿死写者

让我们把视角从“位操作”拉回到“大块数据”上。

假设你有一个巨大的双向链表,几千个节点。你要经常去遍历它(读),偶尔去插入或删除节点(写)。

如果你用普通的 spinlock,即使十个线程只是想“看看”数据,它们也得排队,一个接一个地进。这太浪费了。

rwlock_t (读写自旋锁) 应运而生。

它允许多个读者同时进入,大家互不干扰。只有当写者来了,它才需要独占访问。

听起来很完美?


转折点:写者的噩梦

但这里有一个微妙的转折……而且是性能上的“死刑”。

想象这样一个场景:

  1. 读者 A 拿到了读锁。
  2. 读者 B 拿到了读锁。
  3. 读者 C 拿到了读锁。
  4. 此时,一个写者来了,它想写。它必须等待 A、B、C 全部释放。
  5. 问题来了:在 A、B、C 还没完事的时候,读者 D、E、F 又来了!因为只要没有写者,读锁就可以无限发放。
  6. 结果就是:写者被饿死了

只要有源源不断的读请求,写者可能永远拿不到锁。这在那些虽然“读多写少”但“写必须及时响应”的场景下是致命的。

而且,还没完

还有一个更隐蔽的杀手:缓存伪共享。

即使没有写者,当多个核心的读者同时访问这个链表时,它们虽然不竞争锁,但它们在竞争缓存行。链表节点里的“前驱指针”和“后继指针”都在同一个缓存行里。Core 0 读了一个节点,Core 1 读了另一个。但为了维护内存一致性,缓存行在这些核心之间来回 bouncing(乒乓),导致性能急剧下降。

这还没完,真的没完

现代 Linux 内核社区正在努力移除 rwlock_t

为什么?因为有一种更强大的机制,几乎完美地解决了上述所有痛点。

它就是 RCU (Read-Copy-Update)


13.2 缓存效应与伪共享:多核性能的隐形杀手

在正式介绍 RCU 之前,我们必须先理解为什么多核编程这么难。

不是因为代码难写,而是因为硬件在跟你耍花招


13.2.1 CPU 缓存的工作原理

现代 CPU 不直接读写 RAM。它们读写 L1、L2、L3 缓存。

这里有一个关键概念:缓存行

CPU 从 RAM 读取数据时,不是只读那一个字节,而是一口气读 64 字节(典型值)。这就是一个缓存行。

这本来是好事:利用“空间局部性”原理,你访问了 myarr[0],紧接着访问 myarr[1]myarr[63] 时,全都在 L1 缓存里,快得飞起。


13.2.2 缓存一致性:多核的“世界观”战争

但在多核系统里,这变成了麻烦。

想象一下

  • Core 0 读取了全局变量 N(值为 55)到它的缓存行里。
  • Core 0 把 N 改成了 41。
  • 此时,Core 1 的缓存里还留着旧值 55。
  • Core 1 要对 N 加 1。

如果不加干预,Core 1 会把 55 + 1 = 56 写回,覆盖掉 Core 0 的修改。

为了防止这种精神分裂,硬件必须保证缓存一致性。当 Core 0 修改了 N,它必须发出信号,让 Core 1 缓存里的 N 失效。Core 1 必须重新从 RAM(或者从 Core 0 的缓存)读取新值。

这个“失效-重新读取”的过程,就是缓存同步

代价是什么? 极其昂贵。它涉及到总线流量、停顿、等待。


13.2.3 伪共享:住同一个房间的冤家

如果只有真正的共享变量(比如那个 N)还好办。最怕的是伪共享

看这两个变量:

u16 ax = 1;
u16 bx = 2;

它们紧挨着。编译器大概率会把它们放在同一个 64 字节缓存行里。

然后,悲剧发生了

  • Thread 0 在 Core 0 上疯狂修改 ax
  • Thread 1 在 Core 1 上疯狂修改 bx

它们明明在改不同的变量,完全不需要同步!

但是!因为它们在同一个缓存行里,当 Core 0 修改了 ax,整个缓存行被标记为“脏”。Core 1 的缓存行失效。当 Core 1 修改 bx 时,它必须先获取这个缓存行的所有权,导致 Core 0 失效。

这两个线程就像在一个很小的房间里(缓存行)打架,即使它们互相并不关心对方在干什么。

结果: 缓存行在两个核心之间疯狂跳动。这叫 Cache Ping-Pong。性能呈指数级下降。


修复伪共享

修复方法非常粗暴且有效:人为制造距离

u16 ax = 1;
char padding[64]; /* 强制换行 */
u16 bx = 2;

或者,使用 GCC 的 __attribute__((aligned(64)))

一定要小心:这会增加内存占用。你把每个变量都填充到了 64 字节。如果这只是一个简单的计数器,那开销微不足道;如果你有一个一百万个元素的数组,那你的内存占用会爆炸。


13.3 无锁编程:Per-CPU 与 RCU

既然锁这么麻烦(死锁、优先级反转、写饥饿、缓存失效),那能不能不用锁

可以,但这需要更高的智慧。这里我们介绍两种内核中最重要的无锁技术:Per-CPU 变量RCU


13.3.1 Per-CPU 变量:分而治之

Per-CPU 变量的思想极其简单:既然共享会导致冲突,那我们就别共享了。

对于每一个 CPU 核心,我们都给它分配一个独立的变量副本。

  • CPU 0 操作 pcpu_var[0]
  • CPU 1 操作 pcpu_var[1]
  • ...

大家井水不犯河水,没有任何竞争,不需要任何锁。连缓存伪共享都没有(前提是你没把两个 Per-CPU 变量搞错行)。

这有什么代价? 内存。如果有 64 个核心,你就得有 64 份副本。这对于小的数据结构(比如计数器、指针)完全没问题。但对于巨大的结构,就要权衡了。


如何使用 Per-CPU 变量

千万不要像数组那样直接访问 pcpu_var[cpu_id]。内核提供了一套宏。

静态分配

#include <linux/percpu.h>

DEFINE_PER_CPU(int, my_counter); /* 定义一个 Per-CPU 整数,自动初始化为 0 */

动态分配

struct my_data *data = alloc_percpu(struct my_data);
/* ... 用完记得 free ... */
free_percpu(data);

访问

/* 获取当前 CPU 的副本指针 */
int *val = get_cpu_var(my_counter);
(*val)++;
put_cpu_var(my_counter); /* 必须配对!这涉及抢占计数 */

/* 或者更简单的读写(不推荐在指针上用,推荐用 get_cpu_ptr) */
per_cpu(my_counter, cpu_id); /* 访问指定 CPU 的副本 */

⚠️ 踩坑预警:Per-CPU 上下文的“原子性”

get_cpu_var() 宏展开后,会调用 preempt_disable()

这意味着在 get_cpu_var()put_cpu_var() 之间,你处于原子上下文

千万、千万不要在这里睡眠! 不要调用 kmalloc(GFP_KERNEL),不要调用 mutex_lock(),不要调用任何可能睡眠的函数。

如果你在 get_cpu_var() 期间调用了 vmalloc(),内核会直接报错:

BUG: sleeping function called from invalid context
in_atomic(): 1

为什么? 因为你已经关闭了抢占,内核调度器没法把你切走。如果你此时睡眠(等待资源),整个系统就死锁了(因为没人能切走你来恢复你)。


13.3.2 RCU (Read-Copy-Update):读写的“相对论”

如果 Per-CPU 是“分家”,那 RCU 就是“时空穿越”。

RCU 是 Linux 内核中最复杂、也是最强大的同步机制之一。它的核心思想是:读者完全不加锁,写者通过“复制-修改-替换”的套路来更新数据。


RCU 的直观解释(Level 1)

想象一个公告栏(共享数据)。

  • 读者:只要看公告栏就行。哪怕有人正在贴新公告,读者也可以看旧公告。
  • 写者
    1. 把旧公告拍个照(Copy)。
    2. 在照片上修改内容(Update)。
    3. 等所有看旧公告的人看完,把新照片贴上去,覆盖旧公告(Publish)。
    4. 把旧公告扔掉(Reclaim)。

重点:读者永远不需要锁。写者之间的协调用普通的锁(如 spinlock)就行。

但是:写者在扔掉旧公告前,必须等待。等待多久?直到所有可能在看旧公告的人都看完了。

这个等待期,叫 宽限期


RCU 的 API 一瞥

RCU 的 API 设计非常精妙,它利用了“社会契约”。

读者 API

rcu_read_lock();
/* ... 读取共享数据 ... */
rcu_read_unlock();

请注意:在非 CONFIG_PREEMPT_RCU 的内核中,这两个宏可能完全是空的(或者只有注释)。 它怎么工作的?它依赖于程序员遵守契约:在这两个宏之间,你不能睡眠。

如果你睡了,你就是一个“老赖”读者,占着茅坑不拉屎,写者会永远等你,导致系统卡死。

写者 API

/* 1. 复制并修改数据 (普通 C 代码) */
struct new_data *new = kmalloc(...);
*new = *old_ptr;
new->field = new_value;

/* 2. 发布新数据 */
rcu_assign_pointer(old_ptr, new); /* 原子地替换指针 */

/* 3. 等待所有读者退出宽限期 */
synchronize_rcu(); /* 阻塞等待 */

/* 4. 安全释放旧数据 */
kfree(old);

内存屏障的幽灵

你可能好奇,rcu_assign_pointerrcu_dereference 是干嘛的?

它们就是内存屏障的封装。

rcu_assign_pointer 确保新数据的写入完成在指针更新之前。 rcu_dereference 确保指针读取完成在读取数据内容之前。

这在某些弱内存序的架构(如 Alpha、某些 ARM 配置)上至关重要。在 x86 上,它们虽然轻量,但绝不可少,因为它们阻止了编译器的乱序优化。


13.3.3 RCU 与 Reader-Writer Lock 的对决

让我们回到上一节那个遍历大链表的例子。

Reader-Writer Lock

  • 读操作:虽然可以并发,但锁的开销依然存在。
  • 写操作:极度饥饿,且严重伤害缓存一致性。

RCU

  • 读操作:零开销(没有锁指令,没有内存屏障,只是加了两个宏注释)。
  • 写操作:需要复制数据(开销),替换指针(快),等待宽限期(慢,但不阻塞读者)。

结论: 在“读极其频繁、写极其罕见”的场景下(如路由表查找、进程列表遍历),RCU 是无可争议的王者


13.4 调试并发问题:Lockdep 与死锁检测

写了这么多并发代码,不出错是不可能的。Linux 内核最伟大的贡献之一,就是它把并发调试变成了一门“科学”。

它就是 Lockdep


13.4.1 Lockdep:运行时的数学证明者

Lockdep 不仅仅是一个调试器,它是一个运行时的锁定正确性验证器

它的原理是:在每一次加锁、解锁时,记录“锁的依赖关系”。

  • 如果代码路径 A 先拿锁 X,再拿锁 Y。
  • 如果代码路径 B 先拿锁 Y,再拿锁 X。

当这两种情况都发生时,Lockdep 就会报警

为什么? 因为如果 A 和 B 同时运行,可能会导致经典的 AB-BA 死锁

最可怕的是:Lockdep 不需要真的发生死锁才报警。它只要检测到逻辑上存在死锁的可能性,就会在你加载模块、运行测试时就狠狠地喷你一大堆 WARNING。

这就是“数学证明”。它证明你的代码有 Bug。


13.4.2 遇到 Lockdep 警告怎么办?

不要慌。看日志。

它会告诉你:

  1. 哪个锁被涉及(通常是锁的名称和地址)。
  2. 在哪里被获取(Call Trace)。
  3. 之前在哪里被获取过(导致依赖关系的地方)。

修复策略: 如果是 AB-BA 死锁,统一锁的顺序即可。比如规定:永远先拿锁 A,再拿锁 B。

如果是自死锁(试图递归获取同一个非递归锁),检查你的调用链,看看是不是在同一个函数里调用了某个会再次加锁的辅助函数(比如前面提到的 get_task_commtask_lock 的例子)。


13.5 本章回响

本章真正在做的,是建立一种**“代价感知”**的直觉。

表面上我们在配置各种锁和原子变量,实际上我们是在衡量每一次同步操作的硬件代价

  • 传统的自旋锁,为了安全,牺牲了并行度。
  • 原子整数,为了快速,限制了用途。
  • Per-CPU,为了无锁,牺牲了内存空间。
  • RCU,为了极致的读性能,引入了复杂的写时复制和宽限期等待。

还记得开头那个简单的 ga++ 吗? 现在你应该能回答了:如果它只是一个简单的统计变量,atomic_inc 是最轻量的选择;如果它是对象的生命周期计数,refcount_inc 是保命的护身符;如果它在热点路径上被疯狂读取,Per-CPU 可能是性能的终极答案。

并发编程没有银弹,只有 Trade-off(权衡)。 而内核工程师的职责,就是每一次都做出最划算的那个 Trade-off。


练习题

练习 1:understanding

题目:在 Linux 内核中,为何推荐使用 refcount_t 而不是传统的 atomic_t 来管理对象的引用计数?请从安全性和溢出处理机制的角度进行简述。

答案与解析

答案:因为 refcount_t 专门为引用计数设计,提供了防止溢出和下溢的保护机制(饱和逻辑),能检测并防止 Use-After-Free (UAF) 漏洞,而 atomic_t 只是简单的原子整数,缺乏这些保护。

解析atomic_t 只是一个普通的原子整数,操作后如果溢出会回绕(Wrap-around),可能导致错误的引用计数判断,引发 UAF。而 refcount_t 有严格的合法范围([1 .. INT_MAX-1]),一旦发生溢出或下溢,它不会回绕,而是将值饱和至 REFCOUNT_SATURATED(如 0xc0000000)并通过 WARN_ONCE() 触发内核警告,从而提前暴露潜在的严重安全漏洞。

练习 2:application

题目:假设你在编写一个网卡驱动程序,需要在一个内存映射 I/O (MMIO) 的控制寄存器中原子地将第 3 位(bit 3)置为 1,同时不能影响其他位。如果寄存器地址 ctrl_reg 是 unsigned long 指针,你会选择下面哪种实现方式?为什么?

A. *ctrl_reg |= (1 << 3); B. set_bit(3, ctrl_reg);

答案与解析

答案:选项 B (set_bit(3, ctrl_reg);)

解析:选项 A 是普通的 C 语言位操作,编译后通常对应三条汇编指令(读、改、写),在并发环境下不是原子的,可能导致数据竞争。选项 B 使用了内核提供的 set_bit RMW(Read-Modify-Write)原子位操作 API,它保证了“读取-修改-写回”整个过程的原子性,适用于设备寄存器或多核并发场景,确保硬件状态修改的安全性和正确性。

练习 3:thinking

题目:在一个多核系统中,CPU 0 不断修改位于地址 0x1000 的全局变量 x,CPU 1 同时不断修改紧随其后的全局变量 y。已知 CPU 缓存行大小为 64 字节,x 的地址是 64 字节对齐的。此时系统吞吐量可能会显著下降。这种现象叫什么?其根本原因是什么?如何通过代码最小化修改来缓解这个问题?

答案与解析

答案:1. 现象:False Sharing (伪共享)。 2. 原因:xy 处于同一缓存行中,CPU 0 和 CPU 1 的并发修改导致该缓存行在两个核心的缓存之间频繁失效,产生 Cache Bouncing (缓存跳动)。 3. 解决方案:在变量定义时使用缓存行对齐宏(如 ____cacheline_aligned____cacheline_aligned_in_smp)强制 xy 位于不同的缓存行中。

解析:CPU 缓存一致性协议(如 MESI)以缓存行(通常 64 字节)为单位管理数据。尽管 CPU 0 和 CPU 1 操作的是不同的变量,但如果这两个变量在同一个缓存行内,硬件认为它们是同一份数据。当 CPU 0 写入 x 时,CPU 1 缓存中的 y 也会被标记为无效,反之亦然。这导致缓存行在核心间像乒乓球一样来回传输(Cache Bouncing),极大地浪费了总线带宽和 CPU 周期。通过按缓存行对齐变量,确保频繁修改的独立变量独占缓存行,可消除此伪共享问题。


要点提炼

本章首先深入探讨了比互斥锁更轻量级的原子操作机制,重点对比了传统的 atomic_t 和专为防止引用计数溢出而设计的 refcount_t。后者通过“饱和”机制(将溢出值钉死在 REFCOUNT_SATURATED)来杜绝整数回绕导致的 Use-After-Free 漏洞,虽然牺牲了灵活性,但极大地提升了内核的安全性。同时,文中还介绍了底层的原子位操作(RMW),虽然比手动加锁快得多(利用硬件指令),但在多核操作同一内存地址时仍需配合锁使用。

接着,教程揭示了多核编程中一个隐蔽的性能杀手——缓存伪共享。当多个核心频繁修改位于同一缓存行(通常为 64 字节)内的不同变量时,虽然逻辑上没有竞争,但硬件为了保持缓存一致性,会导致缓存行在核心间频繁跳动,造成性能暴跌。解决之道是利用编译器属性或人为填充字节,强制将敏感变量对齐到不同的缓存行,以物理隔离换取并行效率。

随后,文章介绍了两种为了极致性能而摒弃传统锁的高级同步技术。Per-CPU 变量为每个核心分配独立的数据副本,彻底消除了共享竞争,但必须注意其在上下文中禁止抢占(不能睡眠)的特性;而 RCU(Read-Copy-Update)则通过“复制-修改-替换”的套路,实现了读者端的零开销(完全无锁),虽然增加了写者的复制逻辑和等待宽限期的复杂度,但在“读多写少”的场景(如路由表查找)下是无可争议的性能王者。

最后,针对并发代码难以调试的痛点,本章强调了 Linux 内核提供的 Lockdep 机制。它不仅是一个运行时调试器,更像是一个数学证明者,能够在死锁实际发生前,通过检测锁的依赖关系图(如 AB-BA 锁定顺序),提前发现潜在的死锁逻辑。善用 Lockdep 的警告来统一锁的顺序或检查递归调用,是内核开发者保证代码正确性的关键手段。

总结而言,内核同步的本质是在硬件代价、安全原则和并行效率之间做权衡。无论是选择原子 RMW 指令来避免总线锁定,还是引入缓存填充来防止伪共享,亦或是采用 RCU 牺牲写性能以换取读扩展性,都没有通用的银弹。只有深刻理解这些机制背后的 Trade-off,才能在具体的驱动或内核模块开发中做出最划算的决策。