服务器之家:专注于服务器技术及软件下载分享
分类导航

PHP教程|ASP.NET教程|Java教程|ASP教程|编程技术|正则表达式|C/C++|IOS|C#|Swift|Android|VB|R语言|JavaScript|易语言|vb.net|

服务器之家 - 编程语言 - C/C++ - C++中volatile关键字的使用详解以及常见的误解

C++中volatile关键字的使用详解以及常见的误解

2021-08-12 14:48zhao-zongsheng C/C++

volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统,硬件或者其他线程等

为什么使用volatile ?

C/C++中的 volatile 关键字 和const对应,用来修饰变量,通常用于建立语言级别的memory barrier。这是BS在“The C++ Programming Language”对volatile修饰词的解释:
A volatile specifier is a hint to a compiler that an object may change its value in ways not specified by the language so that aggressive optimizations must be avoided.

volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统,硬件或者其他线程等。

遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。声明时语法:int volatile vInt; 当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。而且读取的数据立刻被保存。例如:

  1. volatile int i=10;
  2. int a = i;
  3. ...
  4. // 其他代码,并未明确告诉编译器,对 i 进行过操作
  5. int b = i;
  6. volatile int i=10;
  7. int a = i;
  8. ...
  9. // 其他代码,并未明确告诉编译器,对 i 进行过操作
  10. int b = i;

volatile 指出 i 是随时可能发生变化的,每次使用它的时候必须从 i的地址中读取,因而编译器生成的汇编代码会重新从i的地址读取数据放在 b 中。而优化做法是,由于编译器发现两次从 i读数据的代码之间的代码没有对 i 进行过操作,它会自动把上次读的数据放在 b 中。而不是重新从 i 里面读。这样以来,如果 i是一个寄存器变量或者表示一个端口数据就容易出错,所以说 volatile 可以保证对特殊地址的稳定访问。注意,在 VC 6 中,一般调试模式没有进行代码优化,所以这个关键字的作用看不出来。下面通过插入汇编代码,测试有无 volatile 关键字,对程序最终代码的影响:

输入下面的代码:

  1. #include <stdio.h>
  2.  
  3. void main()
  4. {
  5. int i = 10;
  6. int a = i;
  7.  
  8. printf("i = %d", a);
  9.  
  10. // 下面汇编语句的作用就是改变内存中 i 的值
  11. // 但是又不让编译器知道
  12. __asm{
  13. mov dword ptr [ebp-4], 20h
  14. }
  15.  
  16. int b = i;
  17. printf("i = %d", b);
  18. }
  19. #include <stdio.h>
  20.  
  21. void main()
  22. {
  23. int i = 10;
  24. int a = i;
  25.  
  26. printf("i = %d", a);
  27.  
  28. // 下面汇编语句的作用就是改变内存中 i 的值
  29. // 但是又不让编译器知道
  30. __asm{
  31. mov dword ptr [ebp-4], 20h
  32. }
  33.  
  34. int b = i;
  35. printf("i = %d", b);
  36. }

然后,在 Debug 版本模式运行程序,输出结果如下:

i = 10
i = 32
然后,在 Release 版本模式运行程序,输出结果如下:

i = 10
i = 10
输出的结果明显表明,Release 模式下,编译器对代码进行了优化,第二次没有输出正确的 i 值。下面,我们把 i 的声明加上 volatile 关键字,看看有什么变化:

  1. #include <stdio.h>
  2. void main()
  3. {
  4. volatile int i = 10;
  5. int a = i;
  6. printf("i = %d", a);
  7. __asm {
  8. mov dword ptr [ebp-4], 20h
  9. }
  10.  
  11. int b = i;
  12. printf("i = %d", b);
  13. }
  14. #include <stdio.h>
  15.  
  16. void main()
  17. {
  18. volatile int i = 10;
  19. int a = i;
  20.  
  21. printf("i = %d", a);
  22. __asm {
  23. mov dword ptr [ebp-4], 20h
  24. }
  25.  
  26. int b = i;
  27. printf("i = %d", b);
  28. }

分别在 Debug 和 Release 版本运行程序,输出都是:

i = 10
i = 32

这说明这个 volatile 关键字发挥了它的作用。其实不只是“内嵌汇编操纵栈”这种方式属于编译无法识别的变量改变,另外更多的可能是多线程并发访问共享变量时,一个线程改变了变量的值,怎样让改变后的值对其它线程 visible。一般说来,volatile用在如下的几个地方:
1) 中断服务程序中修改的供其它程序检测的变量需要加volatile;
2) 多任务环境下各任务间共享的标志应该加volatile;
3) 存储器映射的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能由不同意义;

2.volatile 指针

和 const 修饰词类似,const 有常量指针和指针常量的说法,volatile 也有相应的概念:

修饰由指针指向的对象、数据是 const 或 volatile 的:

  1. const char* cpch;
  2. volatile char* vpch;
  3. const char* cpch;
  4. volatile char* vpch;

注意:对于 VC,这个特性实现在 VC 8 之后才是安全的。指针自身的值——一个代表地址的整数变量,是 const 或 volatile 的:

  1. char*const pchc;
  2. char*volatile pchv;
  3. char*const pchc;
  4. char*volatile pchv;

注意:

(1) 可以把一个非volatile int赋给volatile int,但是不能把非volatile对象赋给一个volatile对象。

(2) 除了基本类型外,对用户定义类型也可以用volatile类型进行修饰。
(3) C++中一个有volatile标识符的类只能访问它接口的子集,一个由类的实现者控制的子集。用户只能用const_cast来获得对类型接口的完全访问。此外,volatile向const一样会从类传递到它的成员。

3. 多线程下的volatile
有些变量是用volatile关键字声明的。当两个线程都要用到某一个变量且该变量的值会被改变时,应该用volatile声明,该关键字的作用是防止优化编译器把变量从内存装入CPU寄存器中。如果变量被装入寄存器,那么两个线程有可能一个使用内存中的变量,一个使用寄存器中的变量,这会造成程序的错误执行。volatile的意思是让编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的值,如下:

  1. volatile BOOL bStop = FALSE;
  2. // 在一个线程中:
  3. while( !bStop ) { ... }
  4. bStop = FALSE;
  5. return;
  6.  
  7. //在另外一个线程中,要终止上面的线程循环:
  8. bStop = TRUE;
  9. while( bStop ); //等待上面的线程终止,
  10. volatile BOOL bStop = FALSE;
  11.  
  12. // 在一个线程中:
  13. while( !bStop ) { ... }
  14. bStop = FALSE;
  15. return;
  16.  
  17. //在另外一个线程中,要终止上面的线程循环:
  18. bStop = TRUE;
  19. while( bStop ); //等待上面的线程终止,

如果bStop不使用volatile申明,那么这个循环将是一个死循环,因为bStop已经读取到了寄存器中,寄存器中bStop的值永远不会变成FALSE,加上volatile,程序在执行时,每次均从内存中读出bStop的值,就不会死循环了。
这个关键字是用来设定某个对象的存储位置在内存中,而不是寄存器中。因为一般的对象编译器可能会将其的拷贝放在寄存器中用以加快指令的执行速度,例如下段代码中:

在此段代码中,nMyCounter的拷贝可能存放到某个寄存器中(循环中,对nMyCounter的测试及操作总是对此寄存器中的值进行),但是另外又有段代码执行了这样的操作:nMyCounter -= 1;这个操作中,对nMyCounter的改变是对内存中的nMyCounter进行操作,于是出现了这样一个现象:nMyCounter的改变不同步。

下面是volatile变量的几个例子:

1.并行设备的硬件寄存器(如:状态寄存器

2.一个中断服务子程序中会访问到的非自动变量(Non-automatic variables)

3.多线程应用中被几个任务共享的变量

看下面例题:

  1. int square(volatile int *ptr)
  2. {
  3. return *ptr * *ptr;
  4. }

这个程序有什么问题吗? 如果我们不去关心volatile关键字的话,那么这个程序你怎么看都会觉得没多大问题.但是这里

面问题大这ne, 首先参数声明为volatile就是表明*ptr可能会随时改变.上述代码运行时,编译器可能产生这样的代码:

  1. int square(volatile int *ptr)
  2. {
  3. int a,b;
  4. a = *ptr;
  5. b = *ptr;
  6. return a * b;
  7. }

因为你的*ptr是随时都可以意想不到的变化,所以有可能a*b的时候,a b的值不相同. 这样你就得到一个错误的结果

改正后的程序:

  1. int square(volatile int *ptr)
  2. {
  3. int a;
  4. a = *ptr;
  5. return a * a;
  6. }

第二个问题,看如下代码:

  1. #include<iostream>
  2. #include<Windows.h>
  3. #include<assert.h>
  4.  
  5. using namespace std;
  6.  
  7. int main()
  8. {
  9. const int a = 2;
  10. int *p = const_cast<int*>(&a);
  11. *p = 3;
  12. cout << a << endl;
  13. system("pause");
  14. return 0;
  15. }

我们有理由的认为在内存当中a的值被修改为3,但是结果呢? 我们来看一看

C++中volatile关键字的使用详解以及常见的误解

这不科学啊?? 我们再打开监视窗口看一下a的值.

C++中volatile关键字的使用详解以及常见的误解

我们都知道监视窗口看到的都是从内存当中拿到的,但是为什么内存当中为3,打印出来就是2呢? 我来解释一下.

C++编译器具有优化功能,当你定一个const的常量的时候,系统觉得它不会被改变了,于是做一个优化把该常量存到寄

存器当中,下次访问的过程更快速一点. 所以当显示窗口读取数据的时候,他会直接去寄存器当中读取数据.而不是去

内存,所以导致我们明明该掉了a的值,却打印不出来.

这个时候该我们的volatile出马了,往i前面加一个volatile之后就会解决这个问题,来看结果:

C++中volatile关键字的使用详解以及常见的误解

谈谈C++的volatile关键字以及常见的误解

近期看到C++标准中对volatile关键字的定义,发现和java的volatile关键字完全不一样,C++的volatile对并发编程基本没有帮助。网上也看到很多关于volatile的误解,于是决定写这篇文章详细解释一下volatile的作用到底是什么。

编译器对代码的优化

在讲volatile关键字之前,先讲一下编译器的优化。

  1. int main() {
  2. int i = 0;
  3. i++;
  4. cout << "hello world" << endl;
  5. }

按照代码,这个程序会在内存中预留int大小的空间,初始化这段内存为0,然后这段内存中的数据加1,最后输出“hello world”到标准输出中。但是根据这段代码编译出来的程序(加-O2选项),不会预留int大小的内存空间,更不会对内存中的数字加1。他只会输出“hello world”到标准输出中。

其实不难理解,这个是编译器为了优化代码,修改了程序的逻辑。实际上C++标准是允许写出来的代码和实际生成的程序不一致的。虽说优化代码是件好事情,但是也不能让编译器任意修改程序逻辑,不然的话我们没办法写可靠的程序了。所以C++对这种逻辑的改写是有限制的,这个限制就是在编译器修改逻辑后,程序对外界的IO依旧是不变的。怎么理解呢?实际上我们可以把我们写出来的程序看做是一个黑匣子,如果按照相同的顺序输入相同的输入,他就每次都会以同样的顺序给出同样的输出。这里的输入输出包括了标准输入输出、文件系统、网络IO、甚至一些system call等等,所有程序外部的事物都包含在内。所以对于程序使用者来说,只要两个黑匣子的输入输出是完全一致的,那么这两个黑匣子是一致的,所以编译器可以在这个限制下任意改写程序的逻辑。这个规则又叫as-if原则。

volatile关键字的作用

不知道有没有注意到,刚刚提到输入输出的时候,并没有提到内存,事实上,程序对自己内存的操作不属于外部的输入输出。这也是为什么在上述例子中,编译器可以去除对i变量的操作。但是这又会出现一个麻烦,有些时候操作系统会把一些硬件映射到内存上,让程序通过对内存的操作来操作这个硬件,比如说把磁盘空间映射到内存中。那么对这部分内存的操作实际上就属于对程序外部的输入输出了。对这部分内存的操作是不能随便修改顺序的,更不能忽略。这个时候volatile就可以派上用场了。按照C++标准,对于glvalue的volatile变量进行操作,与其他输入输出一样,顺序和内容都是不能改变的。这个结果就像是把对volatile的操作看做程序外部的输入输出一样。(glvalue是值类别的一种,简单说就是内存上分配有空间的对象,更详细的请看我的另一篇文章。)

按照C++标准,这是volatile唯一的功能,但是在一些编译器(如,MSVC)中,volatile还有线程同步的功能,但这就是编译器自己的拓展了,并不能跨平台应用。

对volatile常见的误解

实际上“volatile可以在线程间同步”也是比较常见的误解。比如以下的例子:

  1. class AObject
  2. {
  3. public:
  4. void wait()
  5. {
  6. m_flag = false;
  7. while (!m_flag)
  8. {
  9. this_thread::sleep(1000ms);
  10. }
  11. }
  12. void notify()
  13. {
  14. m_flag = true;
  15. }
  16.  
  17. private:
  18. volatile bool m_flag;
  19. };
  20.  
  21. AObject obj;
  22.  
  23. ...
  24.  
  25. // Thread 1
  26. ...
  27. obj.wait();
  28. ...
  29.  
  30. // Thread 2
  31. ...
  32. obj.notify();
  33. ...

对volatile有误解的人,或者对并发编程不了解的人可能会觉得这段逻辑没什么问题,可能会认为volatile保证了,wait()对m_flag的读取,notify()对m_flag的写入,所以Thread 1能够正常醒来。实际上并不是这么简单,因为在多核CPU中,每个CPU都有自己的缓存。缓存中存有一部分内存中的数据,CPU要对内存读取与存储的时候都会先去操作缓存,而不会直接对内存进行操作。所以多个CPU“看到”的内存中的数据是不一样的,这个叫做内存可见性问题(memory visibility)。放到例子中就是,Thread 2修改了m_flag对应的内存,但是Thread 1在其他CPU核上运行,所以Thread 1不一定能看到Thread 2对m_flag做的更改。C++11开始,C++标准中有了线程的概念,C++标准规定了什么情况下一个线程一定可以看到另一个线程做的内存的修改。而根据标准,上述例子中的Thread 1可能永远看不到m_flag变成true,更严重的是,Thread 1对m_flag的读取会导致Undefined Behavior。

从C++标准来说,这段代码是Undefined Behavior,既然是Undefined Behavior的话,是不是也可能正确执行?是的,熟悉MESI的应该会知道,Thread 2的修改导致缓存变脏,Thread 1读取内存会试图获取最新的数据,所以这段代码可以正常执行。那是不是就意味着我们可以放心使用volatile来做线程的同步?不是的,只是在这个例子能够正确执行而已。我们对例子稍作修改,volatile就没那么好使了。

  1. class AObject
  2. {
  3. public:
  4. void wait()
  5. {
  6. m_flag = false;
  7. while (!m_flag)
  8. {
  9. this_thread::sleep(1000ms);
  10. }
  11. }
  12. void notify()
  13. {
  14. m_flag = true;
  15. }
  16.  
  17. private:
  18. volatile bool m_flag;
  19. };
  20.  
  21. AObject obj;
  22. bool something = false;
  23. ...
  24.  
  25. // Thread 1
  26. ...
  27. obj.wait();
  28. assert(something)
  29. ...
  30.  
  31. // Thread 2
  32. ...
  33. something = true;
  34. obj.notify();
  35. ...

在以上代码中,Thread 1的assert语句可能会失败。就如前文所说,C++编译器在保证as-if原则下可以随意打乱变量赋值的顺序,甚至移除某个变量。所以上述例子中的“something = true"语句可能发生在obj.notify()之后。这样的话,“assert(something)”就会失败了。

那么我们可不可能把something也变成volatile?如果something是volatile,我们确实能够保证编译出来的程序中的语句顺序和源代码一致,但我们仍然不能保证两个语句是按照源代码中的顺序执行,因为现代CPU往往都有乱序执行的功能。所谓乱序执行,CPU会在保证代码正确执行的基础上,调整指令的顺序,加快程序的运算,更多细节我们不在这里展开。我们如果单看Thread 2线程,something和m_flag这两个变量的读写是没有依赖关系的,而Thread 2线程看不到这两个变量在其他线程上的依赖关系,所以CPU可能会打乱他们的执行顺序,或者同时执行这两个指令。结果就是,在Thread 1中,obj.wait()返回后,something可能仍然是false,assert失败。当然,会不会出现这样的状况,实际上也和具体的CPU有关系。但是我们知道错误的代码可能会引起错误的结果,我们应该避免错误的写法,而这个错误就在于误用了volatile关键字,volatile可以避免优化、强制内存读取的顺序,但是volatile并没有线程同步的语义,C++标准并不能保证它在多线程情况的正确性。

那么用不了volatile,我们该怎么修改上面的例子?C++11开始有一个很好用的库,那就是atomic类模板,在<atomic>头文件中,多个线程对atomic对象进行访问是安全的,并且提供不同种类的线程同步。不同种类的线程同步非常复杂,要涉及到C++的内存模型与并发编程,我就不在此展开。它默认使用的是最强的同步,所以我们就使用默认的就好。以下为修改后的代码:

  1. class AObject
  2. {
  3. public:
  4. void wait()
  5. {
  6. m_flag = false;
  7. while (!m_flag)
  8. {
  9. this_thread::sleep(1000ms);
  10. }
  11. }
  12. void notify()
  13. {
  14. m_flag = true;
  15. }
  16.  
  17. private:
  18. atomic<bool> m_flag;
  19. };

只要把“volatile bool”替换为“atomic<bool>”就可以。<atomic>头文件也定义了若干常用的别名,例如“atomic<bool>”就可以替换为“atomic_bool”。atomic模板重载了常用的运算符,所以atomic<bool>使用起来和普通的bool变量差别不大。

原文链接:https://www.cnblogs.com/zhao-zongsheng/p/9092520.html

延伸 · 阅读

精彩推荐