这篇文章回答一个核心问题:在多核 Linux 内核中,如何让高频访问路径尽量少加锁,同时保证数据安全?
答案不是“所有地方都用无锁”,而是根据数据访问模式选择合适的工具:
- 读多写少:优先考虑 RCU。
- 每个 CPU 可以独立处理:优先考虑 Per-CPU 变量。
- 需要多个执行流直接竞争共享状态:才考虑无锁数据结构或传统锁。
一、先把问题说清楚:为什么要减少全局共享?
多核系统中,多个 CPU 同时访问同一个全局变量,问题通常不在“变量是不是全局的”,而在于多个 CPU 是否会同时修改同一份内存。
例如,多个 CPU 对一个全局计数器执行自增:
它并不是一个不可分割的动作,而是类似于:
两个 CPU 可能同时读取旧值,最后只成功保留一次加法。这是数据竞争。
即使使用原子操作,也不一定解决所有问题:
- 原子操作能保证单个变量更新不可分割;
- 但多个字段之间的一致性,仍然可能需要锁或更复杂的协议;
- 多个 CPU 反复修改同一个缓存行,还会引发 Cache-line bouncing,降低性能。
因此,内核优化并发访问时通常有三条思路:
- 让读者尽量不等待写者——RCU;
- 把共享状态拆成每 CPU 一份——Per-CPU;
- 用原子指令和内存序直接组织并发访问——无锁数据结构。
二、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 为什么快?
它主要解决了两个问题:
- 减少并发竞争:不同 CPU 通常访问不同副本,不需要围绕同一个变量加锁。
- 改善缓存局部性: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 |
互斥锁 | 竞争失败时睡眠 | 临界区可能较长或允许睡眠 | 上下文切换和调度开销 |
可以用下面的判断顺序快速选择:
- 数据是否主要由当前 CPU 使用?是的话,先考虑 Per-CPU。
- 是否读很多、写很少,并且读者只需要观察对象?可以考虑 RCU。
- 是否是明确的生产者—消费者队列?先检查能否使用受约束的环形缓冲区。
- 是否需要复杂地修改多个字段并保持整体一致?传统锁往往更安全、更容易验证。
- 是否处于中断或原子上下文?不能使用会睡眠的 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;
- 如果数据结构必须由多个执行流共同修改,先判断传统锁是否已经足够;
- 只有在锁竞争确实成为瓶颈,并且团队能够验证内存序和生命周期时,才引入无锁结构。
真正可靠的并发设计,最终要回答三个问题:
- 谁可以同时访问这份数据?
- 数据在什么时候对其他 CPU 可见?
- 谁负责保证对象在最后一次访问之前不会被释放?
如果这三个问题回答不清楚,代码里即使充满了
atomic、cmpxchg 和内存屏障,也可能只是把一个容易发现的锁问题,换成一个更难复现的并发问题。- Author:felixfixit
- URL:http://www.felixmicrospace.top/article/linux_concurrency_mechanisms
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!







