跳到主要内容

第 7 章 无锁的艺术与陷阱

有一类问题,表面上看是工程实现问题,实际上是认知边界问题。

我们在这一章要处理的,正是这样一个问题:当两个执行流同时访问同一块内存时,除了粗暴地让其中一个停下来等待(加锁),我们还能做什么?

如果你的直觉答案是「没办法,必须加锁」,那么你的直觉在 20 年前是主流,但在今天已经是错的。现代内核性能的瓶颈,往往不在算法复杂度,而在「等待」。每一次自旋锁的循环空转,每一次互斥锁的上下文切换,都是对 CPU 周期的直接浪费。

这一章的任务,是建立一种「无锁」的直觉。我们将从最底层的原子整数开始,一路讲到内核内部的锁调试工具。你会发现,很多你以为必须用锁的地方,其实可以用更轻量级的手段解决;而很多你以为「没问题」的加锁代码,其实正悄悄埋下死锁的种子。

先别急着看那些炫酷的无锁算法——让我们先回到最基础的问题。


7.1 使用 atomic_t 和 refcount_t 接口

旧时代的遗留:为什么不是 volatile?

如果你刚接触内核并发,可能会想:既然不想用锁,那能不能用 C 语言的 volatile 关键字修饰变量?

答案是不能。

volatile 这个关键字在内核驱动里随处可见,但它主要是用来告诉编译器「别优化这个变量,因为它可能会被硬件或其他线程莫名其妙地改变」。它确实能防止编译器把变量缓存到寄存器里,但它不保证原子性,也不保证内存顺序

想象一个场景:两个 CPU 同时执行 counter++。在 x86 上,这通常被编译成三条指令:

  1. 从内存读 counter 到寄存器。
  2. 在寄存器里加 1。
  3. 把寄存器写回内存。

如果两个 CPU 同时读到了旧值(比如都是 5),分别加 1 变成 6,然后写回。结果是 6,而不是预期的 7。

volatile 解决不了这个问题。我们需要的是一种硬件级的「读-改-写」(Read-Modify-Write, RMW)指令,保证这三步像一条指令一样不可打断。

这就是 atomic_t 存在的理由。

atomic_t:不只是一个整数

你可以把 atomic_t 理解为内核中的「线程安全计数器」——但这个类比有一个地方是错的:真正的计数器只是加减,而 atomic_t 内部为了保证跨平台兼容性,通常被定义为一个结构体(在 32 位系统上包含一个 int counter),并且它会强制进行内存对齐,以避免跨缓存行的问题。

让我们来看看它的真面目(通常定义在 <asm/atomic.h><linux/types.h>):

typedef struct {
int counter;
} atomic_t;

以及它的 64 位兄弟(适用于 64 位系统):

typedef struct {
s64 counter;
} atomic64_t;

定义与初始化

定义和初始化它们需要用专门的宏,这不仅是风格问题,更是为了确保内部结构正确初始化(比如在某些架构上可能需要调试位的清零):

// 静态定义并初始化为 0
static atomic_t my_ref_cnt = ATOMIC_INIT(0);

// 动态设置
atomic_t v;
atomic_set(&v, 4); // 将 v.counter 设为 4

基础的 RMW 操作

最常用的操作是原子加减。注意,这些函数返回的往往是新的值(或者在某些架构上是旧值,具体看宏实现,但语义是统一的):

atomic_add(1, &v); // v.counter += 1
atomic_sub(1, &v); // v.counter -= 1

// 带返回值的操作(返回的是运算后的新值)
atomic_inc(&v); // v.counter++
atomic_dec(&v); // v.counter--

如果你想获取当前值,不要直接访问 .counter 字段(那是违规的),而是用:

int val = atomic_read(&v);
atomic_set(&v, 10); // 直接设置

条件操作:避免竞态条件

这是 atomic_t 最强大的地方。下面的操作不仅是原子的,而且是条件判断与修改合一的:

// 如果 v.counter 减 1 后为 0,则返回 true,否则返回 false
// 整个过程是原子的,没人能在你减 1 和判断之间插一脚
if (atomic_dec_and_test(&v)) {
// 我们是最后一个引用者,可以安全释放资源了
kfree(obj);
}

// 反之亦然
if (atomic_inc_and_test(&v)) {
// 加 1 后正好是 0(说明原来是 -1,通常表示下溢)
}

// 带减法并测试负值
if (atomic_sub_and_test(2, &v)) {
// 减去 2 后是否为 0
}

// 加上 delta 后是否为负值(常用于信号量实现)
if (atomic_add_negative(1, &v)) {
// 结果 < 0
}

⚠️ 踩坑预警

千万别把 atomic_t 当作万能锁。

  1. 它只保护这一个变量。如果你有 struct { atomic_t a; int b; }atomic_t 只能保证 a 的操作是原子的。如果你需要 ab 同时修改且保持一致,你还得用自旋锁或互斥锁。
  2. 它不能代替序列化。如果你需要先读 A,再根据 A 改 B,这两个操作合起来不是原子的,除非 A 和 B 在同一个原子变量里(或者打包成更大的结构体,但那就需要其他机制了)。

refcount_t:比 atomic_t 更安全的计数器

回到那个「计数器」的类比。atomic_t 虽然解决了并发冲突,但它有一个致命的缺陷:它对整数溢出非常宽容。

如果在多核环境下疯狂引用一个对象,引用计数可能会回绕(从 INT_MAX 变成 INT_MIN),导致对象被提前释放,而使用者依然持有引用——这就是经典的 Use-After-Free (UAF) 漏洞。

为了解决这个问题,内核引入了 refcount_t(定义在 <linux/refcount.h>)。它是 atomic_t 的加强版,专门用于引用计数。

定义与初始化

#include <linux/refcount.h>

static refcount_t my_refcnt = REFCOUNT_INIT(1);

操作接口

接口和 atomic_t 类似,但名字更明确:

refcount_set(&my_refcnt, 1);

// 增加引用
if (refcount_inc_not_zero(&my_refcnt)) {
// 成功增加,且原值不为 0(意味着对象还没死)
// 拿到了对象的引用
} else {
// 对象正在销毁过程中,不能再用
}

// 减少引用
if (refcount_dec_and_test(&my_refcnt)) {
// 减到 0 了,可以释放了
kfree(obj);
}

⚠️ 关键区别:饱和与溢出保护

refcount_t 的核心在于它对溢出的处理。当计数达到 REFCOUNT_SATURATED(通常是接近 UINT_MAX 的一个值)时,它就不再增加,或者直接触发内核警告(取决于 CONFIG_REFCOUNT_FULL 配置)。

这看起来很完美,但有一个代价:

性能。 refcount_t 的操作比 atomic_t 稍微慢一点,因为它加入了一系列的检查逻辑(尤其是开启了 CONFIG_REFCOUNT_FULL 时)。所以,如果不是做引用计数,仅仅是做简单的统计标志位,用 atomic_t 就够了;只有涉及到对象生命周期管理时,才必须用 refcount_t

踩坑实录:atomic_dec_and_test 的陷阱

如果你想用 atomic_t 实现引用计数,很容易写成这样:

if (atomic_dec_and_test(&obj->refcnt)) {
kfree(obj);
}

这看起来没问题。但如果在多核疯狂并发下,refcnt 可能会被减到负数(比如重复释放)。一旦变负,它就再也回不到 0 了,对象永远泄露。

这就是为什么内核现在大力推荐 refcount_t。它会检测到这种非自然的下溢,并直接让内核 panic(或者 saturate),让你立刻发现 bug,而不是等到几个月后因为内存泄露被 OOM Killer 处决。