Lazy loaded image
🥳嵌入式Linux开发
Linux 内核并发机制:RCU、Per-CPU 与无锁数据结构
Words 3699Read Time 10 min
2026-8-31
2026-9-2
🧭
这篇文章回答一个核心问题:在多核 Linux 内核中,如何让高频访问路径尽量少加锁,同时保证数据安全?
答案不是“所有地方都用无锁”,而是根据数据访问模式选择合适的工具:
  • 读多写少:优先考虑 RCU。
  • 每个 CPU 可以独立处理:优先考虑 Per-CPU 变量。
  • 需要多个执行流直接竞争共享状态:才考虑无锁数据结构或传统锁。

一、先把问题说清楚:为什么要减少全局共享?

多核系统中,多个 CPU 同时访问同一个全局变量,问题通常不在“变量是不是全局的”,而在于多个 CPU 是否会同时修改同一份内存。
例如,多个 CPU 对一个全局计数器执行自增:
它并不是一个不可分割的动作,而是类似于:
两个 CPU 可能同时读取旧值,最后只成功保留一次加法。这是数据竞争。
即使使用原子操作,也不一定解决所有问题:
  • 原子操作能保证单个变量更新不可分割;
  • 但多个字段之间的一致性,仍然可能需要锁或更复杂的协议;
  • 多个 CPU 反复修改同一个缓存行,还会引发 Cache-line bouncing,降低性能。
因此,内核优化并发访问时通常有三条思路:
  1. 让读者尽量不等待写者——RCU;
  1. 把共享状态拆成每 CPU 一份——Per-CPU;
  1. 用原子指令和内存序直接组织并发访问——无锁数据结构。

二、RCU:用延迟回收换取快速读取

2.1 RCU 是什么?

RCU 的全称是 Read-Copy-Update,即“读取、复制、更新”。它适合读多写少的场景。
它的基本思想是:
  • 读者直接读取当前数据,通常不需要获取互斥锁;
  • 写者不直接修改正在被读取的旧对象,而是复制一份副本;
  • 写者修改副本后,把全局指针切换到新副本;
  • 等所有可能读取旧副本的读者退出后,再释放旧副本。
可以把它理解成在线编辑文档:读者继续阅读旧版本,作者在新版本上修改;新版本发布后,旧版本不能马上销毁,要等仍在阅读旧版本的人离开。

2.2 RCU 的执行过程

假设全局指针最初指向对象 A:
写者更新时:
对应的时间线是:
这里最关键的不是“复制”,而是旧对象的生命周期管理。如果新指针发布后立即释放 A,仍在使用 A 的读者就会访问已经释放的内存,形成 Use-after-free。

2.3 Linux 中的典型接口

读侧通常类似:
写侧通常类似:
常见接口的职责如下:
接口
作用
rcu_read_lock()
进入 RCU 读侧临界区
rcu_read_unlock()
退出 RCU 读侧临界区
rcu_dereference()
安全读取 RCU 保护的指针
rcu_assign_pointer()
安全发布新指针
call_rcu()
宽限期结束后异步回收旧对象
synchronize_rcu()
同步等待当前读者经过安全点

2.4 什么是宽限期?

**宽限期(Grace Period)**是指:从新版本发布开始,到所有可能仍持有旧版本引用的读者都退出读侧临界区之间的阶段。
宽限期结束后,写者才能确定旧对象不会再被这些读者访问,于是可以释放旧对象。
因此,RCU 不是简单地“睡眠几毫秒再释放”,而是要依据 RCU 机制确认相关读者已经经过安全点。

2.5 RCU 的边界

RCU 很适合下面的场景:
  • 路由表、设备配置、文件系统元数据等读多写少的数据;
  • 读路径要求低延迟,不能频繁等待写者;
  • 写操作可以接受复制和延迟回收。
但它不适合所有场景:
  • 写操作非常频繁,复制成本过高;
  • 读者需要修改共享对象本身;
  • 需要对多个对象执行复杂事务更新;
  • 对象生命周期关系很复杂,无法清晰定义何时安全回收。
还要特别注意:RCU 读侧临界区能否睡眠,取决于具体 RCU 类型和上下文。普通原子上下文中的读侧不能随意睡眠,不能把“读者无锁”误解为“读者可以做任何事情”。

三、Per-CPU 变量:把一个共享变量拆成多份

3.1 Per-CPU 是什么?

Per-CPU 变量为每个 CPU 分配一份独立副本:
CPU 0 只更新自己的副本,CPU 1 只更新自己的副本。热路径上不需要所有 CPU 争抢同一个全局计数器。
例如统计网络包数量时,与其让所有 CPU 修改一个全局变量:
不如改成:
读取总数时,再把各 CPU 的局部值汇总起来。

3.2 Per-CPU 为什么快?

它主要解决了两个问题:
  1. 减少并发竞争:不同 CPU 通常访问不同副本,不需要围绕同一个变量加锁。
  1. 改善缓存局部性:CPU 主要访问本地数据,减少缓存行在多个 CPU 之间来回转移。
这是一种典型的空间换时间:内存占用会随 CPU 数量增加,但更新操作更快、扩展性更好。

3.3 Linux 中的常见用法

定义 Per-CPU 变量:
访问当前 CPU 的副本:
如果需要获取当前 CPU 的指针,可以使用相应的 Per-CPU 访问接口。访问过程中必须注意任务迁移:如果代码先获取当前 CPU 的副本,随后任务被迁移到另一个 CPU,就可能访问错误的副本。
因此,某些访问方式需要关闭抢占,或者使用已经封装好并具备合适同步语义的 this_cpu_* 接口。

3.4 Per-CPU 不是绝对无锁

Per-CPU 变量只保证“不同 CPU 之间减少竞争”,并不自动解决所有并发问题:
  • 当前 CPU 上可能有中断处理程序同时访问它;
  • 任务可能发生 CPU 迁移;
  • 其他 CPU 可能需要读取或修改该 CPU 的副本;
  • 汇总多个副本时,结果可能不是同一时刻的精确快照。
例如,进程上下文和中断上下文都访问同一个 Per-CPU 变量时,可能仍然需要关闭本地中断、使用合适的原子操作,或者采用专门的同步方案。

3.5 典型应用

  • 每 CPU 统计计数器;
  • 调度器运行队列;
  • Slab 分配器的本地缓存;
  • 网络收发路径中的临时状态和缓冲区;
  • 减少全局锁竞争的工作队列或对象缓存。

四、无锁数据结构:不是“不需要同步”,而是换一种同步

4.1 无锁到底是什么意思?

“无锁”通常不是指完全没有同步,而是指不依赖传统的 mutex 或 spinlock 来保护整个数据结构。
它通常依赖:
  • 原子读写;
  • CAS(Compare-And-Swap,比较并交换);
  • 合适的内存序和内存屏障;
  • 对象生命周期管理;
  • 对 ABA 问题的处理。
例如,CAS 的逻辑可以概括为:

4.2 为什么原子操作还不够?

假设一个无锁链表的头指针为:
线程 1 读取到 head == A,准备把新节点插入到头部。在线程 1 暂停期间,线程 2 可能执行:
线程 1 恢复后发现头指针仍然是 A,于是 CAS 成功,但这个 A 已经不是原来的 A 了。这就是 ABA 问题
解决思路包括:
  • 指针增加版本号或标记位;
  • 使用 RCU 管理节点生命周期;
  • 使用引用计数或其他安全回收机制;
  • 采用不会立即复用内存的回收策略。
因此,无锁算法真正难的地方往往不是 CAS 本身,而是:
如何保证多个操作的内存顺序,以及如何保证节点在整个访问期间不会被释放或复用。

4.3 内存序为什么重要?

现代编译器和 CPU 可能对指令进行重排。假设生产者先写数据,再发布“数据可用”标志:
消费者如果没有合适的内存序,理论上可能先看到 ready == 1,却还没有看到最新的 buffer 内容。
所以无锁结构必须明确:
  • 哪些操作需要 acquire;
  • 哪些操作需要 release;
  • 哪些地方需要 full memory barrier;
  • 哪些数据可以使用 relaxed 访问。
“代码没有崩溃”不等于内存序正确。弱内存序架构上的问题,往往很难复现,也最容易在压力测试或换平台后暴露。

4.4 Linux 中的例子

  • kfifo:在满足单生产者、单消费者等约束时,可以实现高效的环形缓冲区;
  • RCU 保护的链表和哈希表:读侧可以无锁遍历,写侧负责更新和回收;
  • refcount_t:管理对象生命周期,但引用计数本身不能替代对象内部状态同步;
  • 原子变量和 cmpxchg:构建简单状态机、引用计数和无锁队列的基础。
需要强调的是:一个数据结构“使用了原子变量”,不代表它整体就是无锁安全的。必须同时证明数据结构的不变量、内存序和生命周期都成立。

五、三种机制如何选择?

机制
主要思路
适合场景
主要代价
RCU
读者快速读取,写者复制并延迟回收
读多写少、读延迟敏感
写路径复杂、旧对象不能立即释放
Per-CPU
每个 CPU 保存独立副本
统计、本地缓存、CPU 局部状态
内存增加、汇总复杂、跨 CPU 访问仍需同步
无锁数据结构
原子操作加内存序组织并发访问
特定高性能队列、状态机、并发容器
实现和验证困难,容易出现 ABA 或生命周期错误
自旋锁
忙等待保护短临界区
不能睡眠、临界区很短
持锁时间过长会占用 CPU
互斥锁
竞争失败时睡眠
临界区可能较长或允许睡眠
上下文切换和调度开销
可以用下面的判断顺序快速选择:
  1. 数据是否主要由当前 CPU 使用?是的话,先考虑 Per-CPU。
  1. 是否读很多、写很少,并且读者只需要观察对象?可以考虑 RCU。
  1. 是否是明确的生产者—消费者队列?先检查能否使用受约束的环形缓冲区。
  1. 是否需要复杂地修改多个字段并保持整体一致?传统锁往往更安全、更容易验证。
  1. 是否处于中断或原子上下文?不能使用会睡眠的 mutex,也不能调用可能睡眠的函数。

六、与实时系统的关系

这些机制都可以降低锁竞争,但它们不会自动让系统变成实时系统。
例如:
  • Per-CPU 可以减少跨 CPU 竞争,但汇总数据时仍可能产生延迟;
  • RCU 读侧很快,但回收线程和回调仍会消耗 CPU;
  • 无锁队列避免了锁阻塞,但 CAS 失败重试可能造成不可预测的执行时间;
  • 自旋锁不睡眠,但持锁时间过长会阻塞同一 CPU 或其他 CPU 上的执行流。
在实时系统中,还需要进一步控制:
  • 中断和 softirq 的执行时间;
  • 内核线程和 RCU 回调的 CPU 分布;
  • 内存分配是否可能触发回收或睡眠;
  • 锁的最长持有时间;
  • 调度延迟和最坏情况执行时间。
例如,rcu_nocbs=2 的作用是把 CPU 2 产生的 RCU 回调尽量迁移到其他 CPU,减少 RCU 回调对实时 CPU 的干扰。但它不等于关闭 RCU,也不会自动迁移所有中断和 workqueue。

七、最后的工程判断

并发优化不是“锁越少越高级”,而是要先识别访问模式:
  • 如果共享状态可以按 CPU 拆开,优先拆分,而不是让所有 CPU 争抢一个全局变量;
  • 如果读操作远多于写操作,优先考虑 RCU;
  • 如果数据结构必须由多个执行流共同修改,先判断传统锁是否已经足够;
  • 只有在锁竞争确实成为瓶颈,并且团队能够验证内存序和生命周期时,才引入无锁结构。
真正可靠的并发设计,最终要回答三个问题:
  1. 谁可以同时访问这份数据?
  1. 数据在什么时候对其他 CPU 可见?
  1. 谁负责保证对象在最后一次访问之前不会被释放?
如果这三个问题回答不清楚,代码里即使充满了 atomiccmpxchg 和内存屏障,也可能只是把一个容易发现的锁问题,换成一个更难复现的并发问题。
上一篇
DMA溢出破坏场景的物理内存视图
下一篇
Linux 设备权限实战:从串口到 GPIO 的免-sudo 之道(i.MX8)

Comments
Loading...