文章

volatile关键字

volatile告诉编译器变量可能被外部修改,禁止优化,确保每次访问真实读取。

volatile关键字

volatile关键字

volatile 是 C++ 中一个关键的类型修饰符,用于提示编译器不要对被修饰的变量进行优化,因为这个变量可能会被编译器看不到的方式修改(比如:硬件、中断服务程序、其他线程等)。

基本语法

1
volatile int x;

意思是 x 的值可能在程序的控制之外被改变,所以每次访问它都需要从内存重新读取。

volatile 的本质作用

  1. 阻止编译器优化读写(比如缓存寄存器、死代码删除、合并写入等)
  2. 强制每次访问都从内存中读取 / 写入

完全不能做的事情

  • ❌ 不保证多线程下的原子性
  • ❌ 不保证内存可见性
  • ❌ 不禁止指令重排
  • ❌ 不保证线程安全!

编译器优化

编译器为了让程序更快,会做很多优化,比如:

  • 变量值缓存(避免频繁访问内存)
  • 指令重排
  • 删除“看起来没必要”的代码

这些优化有时会导致代码行为不符合你写的时候的直觉。特别是当变量的值是被其他线程、硬件、中断修改时,就必须阻止这种优化——这时候就需要 volatile

示例 1:变量值被缓存

1
2
3
4
5
6
7
bool stop = false;

void loop() {
    while (!stop) {
        // do something
    }
}

编译器可能这样优化:

1
2
3
4
5
6
7
8
9
bool stop = false;

void loop() {
    if (!stop) {
        while (true) {
            // do something
        }
    }
}

编译器认为:

  • stop 没有在 loop() 中被修改;
  • 没有看到其他地方改它(比如函数参数或者赋值);
  • 所以它大胆推断:stop 在整个函数里一直是 false,于是优化成了死循环。

如果改为 volatile

1
2
3
4
5
6
7
volatile bool stop = false;

void loop() {
    while (!stop) {
        // 每次都从内存重新读 stop
    }
}

这样编译器就不敢优化,每次都会去内存重新读取 stop 的值,以防被外部修改(例如另一个线程或硬件设备)。

示例 2:死代码被优化掉

1
2
3
4
5
6
bool ready = false;

void waitReady() {
    while (!ready);
    printf("Ready!\n");
}

如果 ready 永远没有在这个函数里被修改,且不是 volatile,那么编译器会直接优化掉这个循环——它认为这段代码永远不可能跳出循环(或者干脆删掉整个循环),结果就是 printf 永远不会执行。

示例 3:寄存器缓存 vs 内存刷新

1
2
3
4
5
6
7
8
int flag = 0;

void waitLoop() {
    int tmp = flag;
    while (tmp == 0) {
        // do something
    }
}

你原本以为是“不断检查 flag”,但其实它只读了一次 flag,存在 tmp 里,然后用 tmp 比较,后续根本不会再读内存中的 flag!

示例 4:指令重排问题的经典例子(双线程同步)

编译器或 CPU 出于性能考虑,可能调整指令的执行顺序,只要单线程看起来执行结果一致,它就会做这样的优化。但在多线程程序中,这种“看起来一样”的优化,可能会导致观察到的执行顺序不一致,从而出现问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
int a = 0;
int b = 0;
int x, y;

void thread1() {
    a = 1;
    x = b;
}

void thread2() {
    b = 1;
    y = a;
}

这两个线程并发运行,理论上我们希望:

  • x == 1(thread1 看到 thread2 设置了 b
  • y == 1(thread2 看到 thread1 设置了 a

但由于指令重排,有可能出现这样一种“意想不到”的执行顺序:

1
2
Thread1 重排后执行顺序:x = b; a = 1;
Thread2 重排后执行顺序:y = a; b = 1;

这会导致最终结果是:

1
x == 0 && y == 0  //❗严重问题,两个线程都没看到对方的写入

为什么?

  • 编译器或者 CPU 认为 a = 1x = b 无依赖,顺序可以互换;
  • 同理 b = 1y = a 也可互换;
  • 但这样就打破了你对多线程顺序的“直觉”!
用 volatile 抑制重排(在某些平台有效)

在 Java 中,volatile 明确禁止读写重排序。

在 C++ 中,volatile 并不能完全禁止指令重排,但是它确实对部分编译器(如 GCC)会:

  • 禁止将访问 volatile 的语句移动到一起
  • 禁止访问顺序乱序执行
1
2
3
4
volatile int a = 0;
volatile int b = 0;
a = 1;
b = 2;

在没有 volatile 的情况下,可能变成:

1
2
b = 2;
a = 1;

但加了 volatile 后,编译器必须按照顺序生成写入指令。

多线程下的解决方案(C++ 正统方法)

C++11 引入了 std::atomic 和内存序(memory_order)模型,来真正解决这个问题。

1
std::atomic<int> a{0}, b{0};

memory_order_seq_cst(默认),可以确保跨线程的执行顺序与代码顺序一致,防止乱序。

volatile 并不等价于线程安全

不能保证原子性!

1
2
volatile int counter = 0;
counter++; // 非原子操作!

要实现线程安全的自增,你仍然需要用 std::atomic 或互斥锁:

1
2
std::atomic<int> counter(0);
counter++;

volatile vs const

可以组合使用:

1
volatile const int x = 5;

表示值不能由程序修改(const),但可能被外部修改(volatile)。

“外部”到底指的是谁

“外部”指的是编译器看不见、不是通过你的当前 C++ 代码修改的地方。常见的“外部”有这些几种情况:

1. 硬件设备

比如在嵌入式程序里读取一个温度传感器的值,它会被硬件定时更新:

1
2
3
4
5
const volatile int* TEMP_SENSOR = (int*)0xFF00;  // 硬件地址

int readTemperature() {
    return *TEMP_SENSOR; // 每次都从硬件读取
}
  • const:代码不能写 *TEMP_SENSOR = 5;,因为不该去写传感器的值。
  • volatile:但这个值会被硬件更新,所以每次都要重新读取,不能优化成常量。
2. 中断服务程序(ISR)

中断可能在代码之外发生,并修改变量。

1
2
3
4
5
6
volatile const int counter;

void ISR() {
    // 中断服务程序里修改 counter
    *(int*)&counter = 42;  // 非常规方式修改
}

虽然代码里标记它为 const 不可改,但中断还是可能通过“技巧”或者底层方式改写它的值。

3. 其他线程(不推荐这么做)

在多线程程序中,一个线程可能在写,另一个线程只读。

1
2
3
4
5
6
volatile const int flag;

void threadA() {
    // 不能写 flag,读它的值
    while (flag == 0) { /* wait */ }
}

另一个线程偷偷通过类型转换写入(不推荐这样写!):

1
2
3
void threadB() {
    *(int*)&flag = 1;
}
  • 虽然在 threadAflagconst,不能写;
  • threadB 通过强转指针绕开了这个限制。

不建议用 volatile 做线程同步,应使用 std::atomic!

什么时候使用 volatile

1. 访问硬件寄存器(嵌入式开发)

硬件寄存器的值可能在程序控制之外被设备更新或清除。

1
2
3
#define REG_STATUS (*(volatile int*)0x40000000)

while ((REG_STATUS & 0x1) == 0);  // 等待硬件就绪
  • 告诉编译器每次都必须重新从地址 0x40000000 读取,而不能缓存上次的值。

2. 中断服务程序 (ISR) 与主程序共享变量

1
2
3
4
5
6
7
8
9
10
11
volatile bool flag = false;

void ISR() {
    flag = true;  // 在中断中修改
}

int main() {
    while (!flag) {
        // 等待中断发生
    }
}
  • 否则编译器可能会把 while (!flag) 优化成无限循环(因为它认为 flag 永远不会变)。

3. 简易轮询标志、状态同步(非线程同步)

用于控制程序逻辑,比如设备状态、线程退出标志等:

1
2
3
4
5
6
7
volatile bool exit_requested = false;

void run() {
    while (!exit_requested) {
        // do work
    }
}

注意:这仅用于控制标志,而非数据同步

一、什么是“控制标志”?

这是指一种很轻量的用途:只需要告诉另一个线程或模块“事情发生了”,比如:

1
2
3
4
5
6
7
volatile bool stop = false;

void worker() {
while (!stop) {
  // 做点事情
}
}
  • stop 是一个 控制用的布尔标志
  • 不涉及复杂的数据同步(比如多个线程共享数据结构);
  • 只要确保“对方能看到这个变量改变”就行。

在一些平台(特别是嵌入式),volatile 可以勉强胜任这个角色(比如防止编译器优化掉 stop 的读取),但并不可靠,C++ 并不保证它具备多线程内存可见性。

如果你非要用 volatile,建议场景是:只有一个线程写、其他线程只读,而且是简单的 bool/int 值


二、什么是“数据同步”?

“数据同步”是指多个线程 读写共享数据 的时候,要:

  1. 保证原子性(比如一个线程写入的值另一个线程不能读一半)
  2. 保证可见性(线程 A 写入的数据对线程 B 可见)
  3. 保证顺序性(不发生指令重排)

比如下面的场景就属于数据同步

1
2
3
4
5
6
7
8
9
volatile int shared_value = 0;

void writer() {
    shared_value = 42;         // 写
}

void reader() {
    int val = shared_value;    // 读
}

你可能以为这没问题,但其实 volatile

  • 不能保证原子性(多个线程并发访问可能出现竞态)
  • 不能保证写入对另一个线程可见
  • 不能禁止指令重排导致乱序读写

正确做法是:

1
std::atomic<int> shared_value = 0;

它才是 C++ 提供的线程安全的数据同步工具

三、那为什么有时候 volatile 就“看起来能用”?

举例:线程控制标志
1
2
3
4
5
6
7
volatile bool stop = false;

void worker() {
    while (!stop) {
        // 做点事
    }
}
背后的运行模型:
  • 一个线程 (设置 stop = true)
  • 另一个线程 (不停判断 stop 是否变了)

如果这两件事都在主存中发生,那么——

如果 编译器没优化掉CPU 没重排缓存同步刚好很快,就有可能“看起来工作正常”。

🔔 也就是说:你只是运气好而已!

volatile 在这个场景中能有一定效果,是因为它:

  1. 强迫编译器不要优化掉 stop 的读操作(避免把 stop 拉到寄存器缓存中死循环)
  2. 强迫每次都从内存读 stop(而不是用老值)

⚠️ 但注意:

  • 并不强制 CPU 在多核系统中刷新缓存
  • 也不能保证 立即看到另一核的修改
所以它能“生效”的前提是:
  • 写线程只写一次(比如设置停止标志)
  • 读线程频繁读取
  • 没有复杂并发访问
  • 并且你运行在一个比较宽容的硬件和编译器上(比如 x86,强顺序)

四、为什么在数据同步中就完全不能用了

比如下面这种代码:

1
2
3
4
5
6
7
8
9
10
volatile int shared = 0;

void thread1() {
    shared = 42;
}

void thread2() {
    int x = shared;
    // 依赖 shared 的值去做事
}

这就不行了,因为:

  1. shared = 42 可能被写入缓存,但 thread2 并不一定能看到
  2. 多核 CPU 上不会自动刷新彼此缓存
  3. volatile 不保证写→读之间有“可见性屏障”
  4. 编译器和 CPU 都可能进行重排序

五、用类比来理解

想象你家楼下贴了个公告(volatile 变量):

  • 🧍‍♂️你每次下楼都去看(每次读)
  • 🧍‍♀️管理员贴了新公告(写)

如果公告是:

“通知大家:明天停水”

这就像 控制标志,你只需要知道 有没有变化,就可以停止洗衣服。

但如果是:

“明天几点洗衣服、几点吃饭、几点关门”

这就涉及到大量数据的同步,你不仅要知道它变了,还要保证你看到的是完整正确的内容、顺序没乱volatile 就完全不够了。

不应该使用 volatile 的场景

1. ❌ 线程之间共享变量的同步

1
2
3
4
5
// 不安全!
volatile int counter = 0;

void thread1() { counter++; }
void thread2() { counter++; }
  • counter++ 不是原子操作,分为读 → 改 → 写;
  • 多线程下会发生竞态条件
  • volatile 不能保证原子性或内存可见性。

✅ 正确做法是使用:

1
2
std::atomic<int> counter{0};
counter++;

2. ❌ 用来实现锁或信号量机制

1
volatile bool lock = false;

不能确保线程安全,应使用:

1
std::mutex mtx;

或:

1
std::atomic_flag lock = ATOMIC_FLAG_INIT;

3. ❌ 作为优化控制手段(避免编译器重排)

虽然 volatile 可能限制编译器的某些重排行为,但它:

  • 无法阻止 CPU 重排(在多核系统中尤为危险)
  • 不能保证同步语义

所以要使用 std::atomic + memory_order 控制。

volatile 和其他机制的对比总结

场景推荐方式volatile 是否适合
硬件寄存器访问volatile✅ 是
中断标志volatile✅ 是
多线程控制标志(仅读写)std::atomic<bool>⚠️ 可选,但推荐用 atomic
多线程数据共享/同步std::atomic / mutex❌ 否
实现锁、CAS 等并发结构std::atomic❌ 否

volatile 在 x86 和 ARM 架构下行为的差异

背景知识:内存模型 & CPU 重排

现代 CPU 都有乱序执行(Out-of-Order Execution)缓存机制,为了性能:

  • 指令可以乱序执行(只要最终结果看起来“等效”)
  • 写入先到本地缓存(不是马上进内存)
  • 多核之间通过缓存一致性协议(如 MESI)同步数据

这就引发一个问题:代码中写的 a = 1; b = 2;不一定按这个顺序执行/生效,特别是在多线程中。

x86 的内存模型(强顺序)

x86 是目前最宽容、最强内存一致性的平台之一。

  • 除非使用 store-buffering,不会发生写后读乱序
  • CPU 自带比较强的内存屏障(你写的顺序通常就能保持)
  • 对于单变量的 volatile 读写,基本能按预期的顺序工作

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
volatile bool ready = false;
int data = 0;

void producer() {
    data = 42;          // 写入数据
    ready = true;       // 设置标志
}

void consumer() {
    while (!ready);     // 等待标志
    std::cout << data;  // 使用数据
}

x86 架构上,常常“能工作”,因为:

  • x86 通常不会把 data = 42 放在 ready = true 后面执行;
  • ready 的读也能反映最新的内存状态。

但这仍然不是标准保证,只是架构友好而已!

ARM(和 RISC-V 等弱内存模型)

ARM 的内存模型就比较“放飞”了,是一个弱顺序架构(Weak Memory Model)

  • 更积极的重排:可能把写和读都重排
  • 更依赖显式内存屏障(如 dmb, dsb 指令)
  • 如果你只靠 volatile,可能出现令人震惊的错乱行为

同样的代码在 ARM 上,编译器和 CPU 都可以这样重排

  • consumer 线程:提前加载 data 到寄存器
  • producer 线程:先设置 ready = true,然后写入 data

最终结果:consumer 线程看到 ready == true,但 data == 0

所以在 ARM 上,volatile 完全不够

如何解决?

C++ 提供了跨平台的解决方案:std::atomic + memory_order

推荐写法:

1
2
3
4
5
6
7
8
9
10
11
12
std::atomic<bool> ready{false};
int data = 0;

void producer() {
    data = 42;
    ready.store(true, std::memory_order_release);
}

void consumer() {
    while (!ready.load(std::memory_order_acquire));
    std::cout << data;
}
  • release:保证之前所有写操作(如 data = 42)在 ready = true 之前完成
  • acquire:保证之后所有读操作在 ready == true 之后执行

✅ 这个写法在 所有平台都安全,包括 ARM 和 x86

本文由作者按照 CC BY 4.0 进行授权