Linux 内核基础知识:READ_ONCE、内存屏障与指令重排

前言

在编写 Linux 内核并发代码时,READ_ONCEWRITE_ONCEsmp_rmbsmp_wmb 等是最常见的基础设施。但很多开发者只是"照着抄",并不真正理解它们的原理。本文从最底层出发,彻底讲清楚 编译器指令重排CPU 指令重排 这两个核心概念。


一、编译器指令重排

1.1 什么是编译器重排

编译器的工作是把 C 源码翻译成汇编指令。但编译器 不保证 生成的汇编顺序和你写的 C 代码顺序一致。

编译器的目标是:在不改变单线程语义的前提下,生成尽可能快的代码。

1.2 具体例子

你写的 C 代码:

c 复制代码
int a = 1;     // 第1行
int b = 2;     // 第2行
int c = a + b; // 第3行

编译器看到这段代码后,发现第1行和第2行之间没有依赖关系,可能会重排成:

asm 复制代码
mov [b], 2    ; 先写 b
mov [a], 1    ; 再写 a
mov r1, [a]
mov r2, [b]
add [c], r1, r2

单线程下,c 的结果还是 3,没有任何问题。编译器认为只要单线程最终结果对就行。

1.3 编译器的其他优化

合并冗余读写:

c 复制代码
*p = 0;
*p = 0;
// 编译器优化后:直接删掉第一次写,只保留一次
*p = 0;

拆分读写(tearing):

c 复制代码
// 你写的:写 4 字节
*(int *)p = 1234;

// 编译器可能拆成两次 2 字节写
*(short *)p = 1234;
*(short *)(p+2) = 0;

在并发场景下,另一个 CPU 可能看到半写入的值。

1.4 READ_ONCE / WRITE_ONCE 的原理

c 复制代码
#define READ_ONCE(x)  (*(volatile typeof(x) *)&(x))
#define WRITE_ONCE(x, val) \
    (*(volatile typeof(x) *)&(x) = (val))

本质就是 volatile 访问。volatile 告诉编译器:

  • 每次都必须真的发一条 load/store 指令去内存,不许用寄存器缓存值
  • 不许合并、不许拆分、不许删除多余的读写
  • 不许推测执行

注意: volatile 只约束编译器,不约束 CPU。它生成的还是一条普通的 CPU load/store 指令,和不加 volatile 生成的指令码一模一样(在 x86 上)。区别仅在于编译器不会对它做优化。


二、CPU 指令重排

2.1 编译器重排完了,CPU 还会再重排

假设编译器没有重排,生成了正确的汇编顺序:

asm 复制代码
; 线程 A --- 编译器没重排
mov [data], 42    ; 指令1:写 data
mov [flag], 1     ; 指令2:写 flag

你以为指令1一定在指令2之前执行完毕。但在现代 CPU 上不是这样的。

2.2 为什么 CPU 要重排

现代 CPU 有很多加速机制,导致指令的实际执行/可见顺序和程序顺序不一致。

原因一:Store Buffer(写缓冲区)
复制代码
CPU 核心                          主内存
  │                                │
  ├── 指令1: 写 data=42 ──────→ [Store Buffer] ──→ (慢慢刷到内存)
  ├── 指令2: 写 flag=1  ──────→ [Store Buffer] ──→ (慢慢刷到内存)

CPU 把写操作先放进 Store Buffer,立刻就去执行下一条指令了(不用等数据真正写入内存)。Store Buffer 再以自己的节奏把数据刷到缓存/内存。

这意味着:CPU 0 执行了指令1和指令2,但对 CPU 1 来说,可能先看到 flag=1,后看到 data=42。

原因二:Out-of-Order Execution(乱序执行)

CPU 的执行引擎可以乱序执行指令。只要对当前线程的最终结果没影响,CPU 可以先执行后面的指令。

原因三:Invalidation Queue(失效队列)

当其他 CPU 修改了某个缓存行,本 CPU 会收到 invalidate 消息,但不会立刻处理,而是放进队列里延迟处理。这导致本 CPU 可能还在读旧值。

2.3 具体例子

复制代码
时间线:

CPU 0 执行线程 A:          CPU 1 执行线程 B:
─────────────────          ─────────────────
写 data=42
                           读 flag → 看到 0(缓存还是旧的)
写 flag=1
                           读 flag → 看到 1(invalidate 消息终于处理了)
                           读 data → 看到 ???

CPU 0 虽然先写 data 再写 flag,但由于 Store Buffer 刷出的顺序不确定,CPU 1 可能在看到 flag=1 的时候,data=42 还没传播到 CPU 1 的缓存。

结果就是线程 B 打印出 data=0,而不是期望的 42。


三、内存屏障(Memory Barrier)

3.1 什么是内存屏障

内存屏障是一条 CPU 硬件指令,它约束 CPU 和缓存系统对内存操作的重排序行为。

3.2 屏障的分类

屏障类型 指令 作用
写屏障 smp_wmb() 保证屏障之前 的 store 操作在屏障之后的 store 操作之前,对其他 CPU 可见
读屏障 smp_rmb() 保证屏障之前 的 load 操作在屏障之后的 load 操作之前完成
全屏障 smp_mb() 保证屏障之前的所有 load/store 在屏障之后的所有 load/store 之前完成

3.3 写屏障解决什么问题

c 复制代码
// 线程 A(生产者)
data = 42;
smp_wmb();       // ← 写屏障
flag = 1;

smp_wmb() 告诉 CPU:Store Buffer 里,屏障之前的写(data=42)必须在屏障之后的写(flag=1)之前,刷到其他 CPU 能看到的地方。

效果:CPU 1 一定先看到 data=42,然后才看到 flag=1

3.4 读屏障解决什么问题

c 复制代码
// 线程 B(消费者)
if (READ_ONCE(flag)) {
    smp_rmb();     // ← 读屏障
    v = data;
}

smp_rmb() 告诉 CPU:在屏障之前的读(读 flag)的结果确定之前,不要执行屏障之后的读(读 data)。

效果:CPU 不会因为推测执行等原因,跳过读 flag 直接读 data。


四、两者的关系

复制代码
你写的代码:        data = 42;  smp_wmb();  flag = 1;
                         │                │
                    ┌────┘                └────┐
                    ▼                           ▼
编译器可能重排:   flag = 1;              data = 42;    ← 编译器把顺序改了
                         │                │
               WRITE_ONCE 会阻止这种重排 ──┘
                         │                │
                         ▼                ▼
CPU 可能重排:    store [flag]=1 在前    store [data]=42 在后  ← Store Buffer 乱序
                         │                │
               smp_wmb() 会阻止这种重排 ──┘

总结对比

维度 READ_ONCE / WRITE_ONCE 内存屏障 (smp_rmb/smp_wmb/smp_mb)
作用层面 编译器层面 CPU 硬件层面
解决的问题 防止编译器优化:合并、拆分、缓存、删除读写 防止 CPU 硬件对内存操作做重排序
是否影响指令顺序 不影响。生成的汇编指令和不加 volatile 一样 影响。在 CPU 流水线中插入屏障
x86 上的表现 编译成普通 mov 指令 smp_rmb/smp_wmb 编译成空操作(x86 硬件保证了),smp_mb 编译成 mfence 或 lock 前缀
ARM 上的表现 编译成普通 ldr/str smp_rmb → dmb ishld,smp_wmb → dmb ishst,smp_mb → dmb ish
性能开销 几乎为零 有实际开销(flush store buffer、stall 流水线)

简单记忆: READ_ONCE → 告诉编译器:"你别自作聪明优化这次读";smp_rmb() → 告诉 CPU:"你别自作聪明重排这次读"。两者解决的是不同层面的问题,实际代码中必须配合使用。


五、完整的正确写法

生产者-消费者模型

c 复制代码
// 全局变量
int data = 0;
int flag = 0;

// 线程 A(生产者)
data = 42;
smp_wmb();           // 硬件层面:data 的写一定先于 flag 的写对其他 CPU 可见
WRITE_ONCE(flag, 1); // 编译器层面:保证 flag 确实写入内存

// 线程 B(消费者)
if (READ_ONCE(flag)) {   // 编译器层面:保证从内存读 flag
    smp_rmb();            // 硬件层面:flag 的读一定先于 data 的读
    int v = data;         // 安全,v 一定是 42
}

内核中的实际例子

在 Linux 内核的 QoS 继承代码中,可以看到这种模式的实际运用:

c 复制代码
// qi.c 中的 get_dyn_qos_lvl
if (atomic_read(&task_qi->flag) == 0)   // READ_ONCE 语义
    return 0;
/* ensure dyn_qos read ops after flag */
smp_rmb();                                // 读屏障
return atomic_read(&task_qi->dyn_qos) & QOS_LVL_MASK;

含义:如果看到了 flag != 0,那么 smp_rmb() 保证后续读到的 dyn_qos 一定是 flag 被设置之前写入的值。

c 复制代码
// qi_utils.c 中的 do_qos_inherit
list_add(&node->list, &task_qi->inherit[type].list);
/* Add memory barrier to ensure list adding has finished
 * before flag has been modified. */
smp_wmb();
atomic_or((1 << type), &task_qi->flag);

含义:smp_wmb() 保证链表插入操作对其他 CPU 可见之后,才去设置 flag。这样其他 CPU 看到 flag 被设置时,链表一定已经是完整的。


六、常见误区

误区1:"READ_ONCE 就是 volatile"

不完全是。READ_ONCE 确实基于 volatile,但内核的 READ_ONCE 还会处理一些特殊情况(比如 volatilestruct 类型的特殊语义问题)。不要直接用 volatile 替代 READ_ONCE

误区2:"x86 是强内存模型,不需要内存屏障"

x86 保证了 store-store 不重排、load-load 不重排、load 不会和更早的 store 重排。但 x86 不保证 store 不会和更早的 load 重排 (即 Load-Store 重排)。所以 smp_mb() 在 x86 上仍然需要 mfencelock 前缀。而且即使在 x86 上,READ_ONCE / WRITE_ONCE 仍然是必要的(防止编译器重排)。

误区3:"用了 atomic 就不需要内存屏障"

atomic_read 就是 READ_ONCEatomic_set 就是 WRITE_ONCE。它们只是防止编译器优化,不提供内存屏障语义 。如果你需要原子读加屏障,应该用 smp_load_acquire() / smp_store_release()


总结

复制代码
┌──────────────────────────────────────────────┐
│                 你写的 C 代码                  │
└──────────────┬───────────────────────────────┘
               │ 编译器可能重排
               ▼
┌──────────────────────────────────────────────┐
│           READ_ONCE / WRITE_ONCE             │
│      阻止编译器重排(volatile 语义)           │
└──────────────┬───────────────────────────────┘
               │ 生成普通汇编指令
               ▼
┌──────────────────────────────────────────────┐
│               汇编指令序列                     │
└──────────────┬───────────────────────────────┘
               │ CPU 可能重排(Store Buffer 等)
               ▼
┌──────────────────────────────────────────────┐
│             smp_rmb / smp_wmb / smp_mb       │
│         阻止 CPU 硬件重排(屏障指令)          │
└──────────────┬───────────────────────────────┘
               │ 实际内存操作顺序
               ▼
┌──────────────────────────────────────────────┐
│                 主内存                        │
└──────────────────────────────────────────────┘

一句话总结:READ_ONCE 管编译器,内存屏障管 CPU,两者各管一层,缺一不可。

相关推荐
D4c-lovetrain1 小时前
Linux个人心得29(深入理解K8S Pod优先级与驱逐机制:从原理到实战踩坑)
linux·运维·kubernetes
小吴伴学者1 小时前
Linux RX报文处理全流程解析
linux
小侯不躺平.2 小时前
C++ Boost库【2】 --stringalgo字符串算法
linux·c++·算法
夏乌_Wx2 小时前
计算机网络实践项目 | 云相册(文件互传与管理系统)
linux·计算机网络
用户805533698032 小时前
嵌入式Linux驱动开发——设备树语法与编译工具——读懂这张"藏宝图"
linux·嵌入式
原来是猿2 小时前
网络计算器:理解序列化与反序列化(下)
linux·开发语言·网络·网络协议·json·php
木木_王2 小时前
嵌入式学习 | STM32裸板驱动开发(Day01)入门学习笔记(超详细完整版|点灯实验 + 库函数代码 + 原理全解)
linux·驱动开发·笔记·stm32·学习
勤自省3 小时前
ROS2从入门到“重启解决”:21讲8~12章踩坑血泪史与核心总结
linux·开发语言·ubuntu·ssh·ros
原来是猿3 小时前
Linux守护进程(Daemon)完全指南:从原理到实战
linux·运维·服务器·网络·php