目录
提纲
1、 它到底做了什么?
2. 最大误区:volatile 不是线程同步工具
3. 和 const 的区别
4. 修饰指针时的写法
5. volatile 类对象只能调用 volatile 成员函数,且属性会传播到成员
volatile 类对象只能调用 volatile 成员函数,且属性会传播到成员
6、volatile修饰变量的引用问题
1. 可以写 volatile int&
2. 不能写 int& volatile 或 int& const
3. 普通引用不能绑定 volatile 对象
4. 通过 volatile 引用访问才算 volatile 访问
5. 实际开发中的建议
总结
提纲
- 1、它到底做了什么?:volatile 的类型限定符含义、编译器约束、可观测副作用。
- 2. 最大误区:volatile 不是线程同步工具:澄清 volatile 不能保证原子性、跨线程可见性、指令重排和互斥。
- 3. 和 const 的区别:对比 const 与 volatile 的约束对象和典型场景。
- 4. 修饰指针时的写法:区分 volatile 修饰指向数据还是指针本身。
- 5. volatile 类对象只能调用 volatile 成员函数,且属性会传播到成员:成员函数限定、属性传播、拷贝/赋值特殊处理。
- 6、volatile 修饰变量的引用问题:volatile T& 的写法、引用本身不可限定、普通引用不能绑定 volatile 对象、实际开发建议。
- 总结:核心作用、适用场景、常见误区、与 const 的区别、引用写法要点。
volatile在 C++ 里是一个类型限定符,核心含义是:这个对象的值可能在程序正常控制流之外被改变,所以编译器不能对它做“看起来无害”的优化。
它主要解决的是编译器优化带来的可见性问题,不是线程安全问题。
1、 它到底做了什么?
对一个volatile对象进行读写时,编译器通常要遵守这些约束:
每次读取都要从内存重新取值,不能把值长期缓存在寄存器里;
每次写入都要真正写回内存,不能随意合并、消除;
不能把
volatile访问优化掉;(如下图所示,如果没有volatile,编译器在优化时可能认为循环体内没有修改isr_flag,就把它的值缓存起来,导致死循环。)volatile int isr_flag = 0; void interrupt_handler() { isr_flag = 1; } int main() { while (isr_flag == 0) { // 轮询等待 } }在单线程语义下,
volatile访问不能和其他“可见副作用”随意重排
C++ 标准规定:对 volatile 对象的每次读/写都属于可观测副作用,编译器必须按照抽象机语义真实执行,不能优化掉,也不能在同一线程内把 volatile 访问和其他“可观测副作用”随意重排。
volatile int flag; int data; data = 42; // 普通写,不是可见副作用 flag = 1; // volatile 写,是可见副作用在标准 C++ 里,编译器不能把两次 volatile 访问彼此重排,也不能把 volatile 访问和其他可见副作用越过彼此。 但data = 42本身不是可见副作用,所以编译器在不影响单线程可观测行为的前提下,可能对普通变量做更多优化。也就是说,volatile 保证的是“volatile 访问本身不被删、不被合并、彼此不乱序”,并不等于给所有普通内存访问加上了完整内存屏障。
volatile做不到这些。它只能保证:
- 每次读都从对象存储位置读;
- 每次写都真实写回;
- 多核之间缓存何时同步;
- CPU 是否对普通内存访问重排;
- 其他线程能否立即看到修改;
- 是否存在数据竞争。
可观测副作用就是编译器优化不能改变、删除或重排的那些对外部世界有影响的操作。它本 质上是编译器优化的“红线”——编译器可以随便优化内部计算,但一旦涉及能被外部观测到的行为,就必须老老实实按源码顺序执行。
标准定义的可观测副作用包括
- 对 volatile 对象的读写:每次访问都必须真实执行,不能优化掉。
- 文件 I/O 和标准输出:
printf、std::cout等,输出顺序不能乱。 - 原子操作的同步行为(C++11 起):
std::atomic的 load/store 带有内存序语义。 - 浮点环境修改:舍入模式、浮点异常状态等(需
#pragma STDC FENV_ACCESS ON)。 - 调用会触发以上行为的函数。
为什么 volatile 访问算可观测副作用?
因为 volatile 通常对应硬件寄存器、中断共享变量等程序外部的实体。编译器无法知道硬件会怎么响应一次写入,所以必须把每次读写都当作真实发生的事件。
换句话说,volatile告诉编译器:这个变量可能随时被硬件、中断、信号处理程序等外部因素修改,你不能假设它稳定
2. 最大误区:volatile 不是线程同步工具
很多人误以为:
volatile int counter = 0; counter++; // 以为这样就线程安全这是错误的。volatile不能保证:原子性、跨线程可见性、CPU指令重排、互斥。
3. 和const的区别
| 关键字 | 含义 | 典型场景 |
|---|---|---|
const | 程序不应修改它 | 常量、只读接口 |
volatile | 值可能被外部修改 | 硬件寄存器、ISR、信号处理 |
const volatile | 程序不写,但外部可能改 | 只读硬件状态寄存器 |
const是对程序行为的约束,volatile是对编译器优化的约束。
4. 修饰指针时的写法
volatile int* p1; // 指向 volatile int 的指针 int* volatile p2; // 指针本身是 volatile volatile int* volatile p3; // 指针和指向的数据都是 volatile这和const指针的规则类似:看volatile修饰的是“指向的数据”,还是“指针本身”。
5. volatile 类对象只能调用 volatile 成员函数,且属性会传播到成员
- volatile 重排限制:主要约束编译器,保证 volatile 访问不被删、不被合并、彼此不乱序;
- volatile 类对象:只能调用 volatile 成员函数,volatile 限定会传播到成员,拷贝/赋值也需要特殊处理。
volatile修饰类对象时,相当于给这个对象加上了“可能被外部修改”的限定。编译器会据此限制你能调用的接口。
class Device { public: int read(); // 普通成员函数 int read() volatile; // volatile 成员函数 }; volatile Device dev; dev.read(); // 只能调用 volatile 版本普通成员函数默认假设对象状态相对稳定,而volatile成员函数表示:即使对象可能被外部因素改变,这个函数仍然能安全读取或操作它。
“传播到成员”的意思是:通过 volatile 对象访问其数据成员时,这些成员也会被当作 volatile 看待。
struct S { int x; }; volatile S obj; obj.x; // 这个访问被视为对 volatile int 的访问同样,volatile 对象不能直接使用合成的拷贝/赋值运算符,因为默认拷贝构造和赋值运算符的参数是非 volatile 引用。如果确实需要拷贝 volatile 对象,类必须自己提供接受volatile引用的拷贝/赋值接口。
这三个问题其实都围绕同一件事:volatile 到底约束的是谁?答案是:它主要约束编译器优化,不约束 CPU 硬件、缓存、线程调度,也不建立 C++ 标准意义上的跨线程 happens-before。
volatile 类对象只能调用 volatile 成员函数,且属性会传播到成员
volatile修饰类对象时,相当于给这个对象加上了“可能被外部修改”的限定。编译器会据此限制你能调用的接口。
class Device { public: int read(); // 普通成员函数 int read() volatile; // volatile 成员函数 }; volatile Device dev; dev.read(); // 只能调用 volatile 版本普通成员函数默认假设对象状态相对稳定,而volatile成员函数表示:即使对象可能被外部因素改变,这个函数仍然能安全读取或操作它。
“传播到成员”的意思是:通过 volatile 对象访问其数据成员时,这些成员也会被当作 volatile 看待。
struct S { int x; }; volatile S obj; obj.x; // 这个访问被视为对 volatile int 的访问同样,volatile 对象不能直接使用合成的拷贝/赋值运算符,因为默认拷贝构造和赋值运算符的参数是非 volatile 引用。如果确实需要拷贝 volatile 对象,类必须自己提供接受volatile引用的拷贝/赋值接口。
总结一下:
- volatile 重排限制:主要约束编译器,保证 volatile 访问不被删、不被合并、彼此不乱序;
- 跨线程可见性:C++ 里靠
std::atomic、锁等建立 happens-before,volatile 不承担这个责任; - volatile 类对象:只能调用 volatile 成员函数,volatile 限定会传播到成员,拷贝/赋值也需要特殊处理。
6、volatile修饰变量的引用问题
可以兼容,但有两个前提:引用本身不能被 volatile 限定,而且引用的目标类型必须带 volatile。
1. 可以写volatile int&
volatile int v = 0; volatile int& ref = v; // OK这里ref是一个绑定到 volatile int 的引用。通过ref读取或写入时,仍然属于 volatile 访问,编译器不能随意优化。
2. 不能写int& volatile或int& const
C++ 里引用本身不能被 const 或 volatile 限定:
int x = 0; int& volatile r = x; // 错误:引用本身不能加 volatile int& const r2 = x; // 错误:引用本身不能加 const如果看到int& const这种写法,通常是想写const int&,也就是“指向 const int 的引用”。
3. 普通引用不能绑定 volatile 对象
volatile int v = 0; int& r = v; // 错误:非 volatile 引用不能绑定 volatile 对象因为int&是非 volatile 引用,而v是 volatile 对象。要绑定它,引用类型必须带 volatile:
volatile int& r = v; // OK4. 通过 volatile 引用访问才算 volatile 访问
创建引用本身不一定触发 volatile 读,真正触发的是后续读写:
volatile int v = 0; volatile int& ref = v; // 创建引用 int x = ref; // 这里才是 volatile 读5. 实际开发中的建议
在 C++11 之后:
- 硬件寄存器、中断共享变量、信号处理:可以用
volatile和volatile&; - 线程间共享变量:不要用
volatile,应使用std::atomic或锁。
一句话总结:引用可以和 volatile 兼容,但只能写成volatile T&,不能写成T& volatile;而且只有引用目标带 volatile 时,通过它的读写才会保持 volatile 语义。
总结
volatile的核心作用:它是一个类型限定符,告诉编译器对象的值可能在程序正常控制流之外被改变,因此编译器不能对它做“看起来无害”的优化。它主要约束编译器优化,保证 volatile 访问不被删、不被合并、彼此不乱序。
适用场景:硬件寄存器、中断共享变量、信号处理等程序外部实体。
常见误区:volatile不是线程同步工具,不能保证原子性、跨线程可见性、CPU 指令重排和互斥。线程间共享变量应使用std::atomic或锁。
与const的区别:const约束程序行为,volatile约束编译器优化。
引用写法:引用可以和 volatile 兼容,但只能写成volatile T&,不能写成T& volatile;只有引用目标带 volatile 时,通过它的读写才会保持 volatile 语义。