转载请保留以下声明
近期看到 C++ 标准中对 volatile 关键字的定义, 发现和 java 的 volatile 关键字完全不一样, C++ 的 volatile 对并发编程基本没有帮助. 网上也看到很多关于 volatile 的误解, 于是决定写这篇文章详细解释一下 volatile 的作用到底是什么.
编译器对代码的优化
在讲 volatile 关键字之前, 先讲一下编译器的优化.
- int main() {
- int i = 0;
- i++;
- cout <<"hello world" << endl;}
按照代码, 这个程序会在内存中预留 int 大小的空间, 初始化这段内存为 0, 然后这段内存中的数据加 1, 最后输出 "hello world" 到标准输出中. 但是根据这段代码编译出来的程序(加 - O2 选项), 不会预留 int 大小的内存空间, 更不会对内存中的数字加 1. 他只会输出 "hello world" 到标准输出中.
其实不难理解, 这个是编译器为了优化代码, 修改了程序的逻辑. 实际上 C++ 标准是允许写出来的代码和实际生成的程序不一致的. 虽说优化代码是件好事情, 但是也不能让编译器任意修改程序逻辑, 不然的话我们没办法写可靠的程序了. 所以 C++ 对这种逻辑的改写是有限制的, 这个限制就是在编译器修改逻辑后, 程序对外界的 IO 依旧是不变的. 怎么理解呢? 实际上我们可以把我们写出来的程序看做是一个黑匣子, 如果按照相同的顺序输入相同的输入, 他就每次都会以同样的顺序给出同样的输出. 这里的输入输出包括了标准输入输出, 文件系统, 网络 IO, 甚至一些 system call 等等, 所有程序外部的事物都包含在内. 所以对于程序使用者来说, 只要两个黑匣子的输入输出是完全一致的, 那么这两个黑匣子是一致的, 所以编译器可以在这个限制下任意改写程序的逻辑.
volatile 关键字的作用
不知道有没有注意到, 刚刚提到输入输出的时候, 并没有提到内存, 事实上, 程序对自己内存的操作不属于外部的输入输出. 这也是为什么在上述例子中, 编译器可以去除对 i 变量的操作. 但是这又会出现一个麻烦, 有些时候操作系统会把一些硬件映射到内存上, 让程序通过对内存的操作来操作这个硬件, 比如说把磁盘空间映射到内存中. 那么对这部分内存的操作实际上就属于对程序外部的输入输出了. 对这部分内存的操作是不能随便修改顺序的, 更不能忽略. 这个时候 volatile 就可以派上用场了. 按照 C++ 标准, 对于 glvalue 的 volatile 变量进行操作, 与其他输入输出一样, 顺序和内容都是不能改变的. 这个结果就像是把对 volatile 的操作看做程序外部的输入输出一样.(glvalue 是值类别的一种, 简单说就是内存上分配有空间的对象, 更详细的请看我的另一篇文章.)
按照 C++ 标准, 这是 volatile 唯一的功能, 但是在一些编译器 (如, MSVC) 中, volatile 还有线程同步的功能, 但这就是编译器自己的拓展了, 并不能跨平台应用.
对 volatile 常见的误解
实际上 "volatile 可以在线程间同步" 也是比较常见的误解. 比如以下的例子:
- class AObject
- {
- public:
- void wait()
- {
- m_flag = false;
- while (!m_flag)
- {
- this_thread::sleep(1000ms);
- }
- }
- void notify()
- {
- m_flag = true;
- }
- private:
- volatile bool m_flag;
- };
- AObject obj;
- ...
- // Thread 1
- ...
- obj.wait();
- ...
- // Thread 2
- ...
- obj.notify();
- ...
对 volatile 有误解的人, 或者对并发编程不了解的人可能会觉得这段逻辑没什么问题, 可能会认为 volatile 保证了, wait()对 m_flag 的读取, notify()对 m_flag 的写入, 所以 Thread 1 能够正常醒来. 实际上并不是, Thread 1 可能永远看不到 m_flag 变成 true. 因为在多核 CPU 中, 每个 CPU 都有自己的缓存. 缓存中存有一部分内存中的数据, CPU 要对内存读取与存储的时候都会先去操作缓存, 而不会直接对内存进行操作. 所以多个 CPU"看到" 的内存中的数据是不一样的, 这个叫做内存可见性问题(memory visibility). 并发编程下, 一个程序可以有多个线程在不同的 CPU 核中同时运行, 这个时候内存可见性就会影响程序的正确性. 放到例子中就是, Thread 2 修改了 m_flag 对应的内存, 但是 Thread 1 在其他 CPU 核上运行, 而两个 CPU 缓存和内存没有做同步, 导致 Thread 1 运行的核上看到的一直都是旧的数据, 于是 Thread 1 永远都不能醒来. 内存可见性问题不是多线程环境下会遇到的唯一的问题, CPU 的乱序执行也会导致一些意想不到的事情发生, 关于这点 volatile 能做的也是有限的. 这些都是属于并发编程的内容, 在此我就不多做展开, 总之 volatile 关键字对并发编程基本是没有帮助的.
那么用不了 volatile, 我们该怎么修改上面的例子? C++11 开始有一个很好用的库, 那就是 atomic 类模板, 在 < atomic > 头文件中, 多个线程对 atomic 对象进行访问是安全的. 以下为修改后的代码:
- class AObject
- {
- public:
- void wait()
- {
- m_flag = false;
- while (!m_flag)
- {
- this_thread::sleep(1000ms);
- }
- }
- void notify()
- {
- m_flag = true;
- }
- private:
- atomic<bool> m_flag;
- };
只要把 "volatile bool" 替换为 "atomic<bool>" 就可以.<atomic > 头文件也定义了若干常用的别名, 例如 "atomic<bool>" 就可以替换为 "atomic_bool".atomic 模板重载了常用的运算符, 所以 atomic<bool > 使用起来和普通的 bool 变量差别不大. 一些 atomic 的高级用法, 由于要涉及到 C++ 的内存模型与并发编程, 我就不在此展开了, 以后有时间再补上.
来源: https://www.cnblogs.com/zhao-zongsheng/p/9092520.html