简介
我们来讲一下volatile这个关键字,在很多语言中都会涉及到这关键字,首先我们来讲一下计算机的一些运行规则,其次我们简单来浅析一下这个关键字的定义,然后通过实例讲一下这个关键字的用处,及和memorybarrier的比较。
计算机运行规则
首先我们可能以为cpu运行指令无非是取指,译指,执行指令,存取数据,我们所接触到的都是这样顺序的,然而实际上cpu为了提高执行效率,再加上现在计算机都是多核的,基于这种情况下,cpu及编译器会做一定程度的优化,优化后的结果如下:
内存乱序访问
首先内存乱序访问主要发生在两个阶段:
- 编译时,编译器优化导致内存乱序访问(指令重排),即有可能改变指令的执行顺序
- 运行时,多 CPU 间交互引起内存乱序访问,因为多CPU运行时取指将指令放到队列里,之后被分发到适当的CPU处理(不一定按序分发)
缓存机制
cpu是从内存拿数据,然后更新到内存中,但是cpu运行速率要比内存快的多,所以为了提高效率,cpu和内存之间有多级缓存及寄存器,通过一些算法来进行映射,这样也就是说cpu对数据的获取和更新并不是直接是对内存来进行的。
volatile的定义
cpp primer中的定义是:
volatile的定义其实是和机器有关,直接处理硬件的程序常常包含这样的数据元素,他们的值由程序直接控制之外的过程控制。例如,程序可能包含一个由系统时钟定时更新的变量。当对象的值可能在程序的控制和检测之外被改变时,应该将该对象声明为volatile。关键字volatile告诉编译器不对这样的对象进行优化。
上边所述其实并没有很理解,就是说volatile声明的变量不会被编译器优化,比如说编译器对这个值优化是存储到寄存器方便使用,这里就是从内存中存取。
我们来举例来说明一下,在visual studio中选择release模式,因为这个在release下编译器会对代码做一些优化。代码如下:
int r;
volatile int x;
volatile int u;
int main()
{
r = 1;
x = r;
u = x;
return 0;
}
我们看r没有被volatile声明,其余两个被volatile声明,代码比较简单,在visual studio中项目->属性->C/C+±>输出文件->汇编程序输出->带源代码的程序集。然后运行程序,我们来看汇编代码:
_main PROC ; COMDAT
; 11 : r = 1;
mov DWORD PTR ?r@@3HA, 1 ; r
; 12 : x = r;
mov DWORD PTR ?x@@3HC, 1 ; x
; 13 : u = x;
mov eax, DWORD PTR ?x@@3HC ; x
mov DWORD PTR ?u@@3HC, eax ; u
; 14 : return 0;
xor eax, eax
; 15 : }
ret 0
_main ENDP
_TEXT ENDS
END
类似这样的字眼DWORD PTR ?r@@3HA表示的内存地址,我们看到在x=r;中是直接将立即数1赋给x,但是在u=x;却是从x所在内存地址取出然后在赋给u。所以由此我们可以看出来如果有volatile时能够避免编译器对其对象的优化。
volatile的语法
volatile限定符和const很相似,对类型额外修饰的作用:
volatile int display; // 修饰int值
volatile Work* cur_work; // cur_work指向一个volatile的对象
volatile int tax[size]; // tax每个元素都是volatile
volatile Work work; // work的每个成员都是volatile
int *volatile vip; // vip是一个volatile指针,指向int
volatile int * vip; // vip是一个指针,指向volatile int
const和volatile他俩没啥影响,对象的属性可以既是const,也是volatile的
volatile 也可以形容成员函数,只有volatile成员函数才能被volatile对象调用。
const和volatile重要的区别是针对volatile,不能合成的拷贝/移动构造函数及赋值运算符。因为编译器自己合成形参类似这样:const Work &work,显然我们不能把非volatile赋值给volatile对象,那么我们如果想要拷贝,赋值,移动volatile对象,就需要自己定义。可以参照声明:
class Work
{
public:
Work(const volatile Work&); // 拷贝构造函数
Work& operator=(volatile const Work&); // 将volatile赋值给非volatile
Work& operator=(volatile const Work&) volatile; // 将volatile赋值volatile
};
volatile的使用
1:编译器基于cpu对内存的缓存的优化
首先我们先看一个列子:
#include <thread>
#include <iostream>
bool ok = true;
int x = 0;
void foo()
{
while (ok) {
std::cout << "foo:" << x << std::endl;
}
std::cout << "foo end" << x << std::endl;
}
int main()
{
std::thread t1(foo);
while (1) {
x++;
if (x == 900000) {
ok = false;
}
if (x == 1800900) {
break;
}
}
std::cout << "main:" << std::this_thread::get_id() << std::endl;
t1.join();
return 0;
}
我们看上边代码,首先我们初始化x和ok的值,然后开启两个线程(单线程下不会出现),子线程用来打印x的值,主线程用来改变x的值。然后我们看下输出:
是不是感觉很奇怪,子线程完全感受不到主线程对x值得修改,主线程修改x的值的时候,因为要大量访问x的值,所以会将其值放到寄存器或者缓存中进行存取,而子线程显然也就是访问不到,所以我们写代码的时候一定要注意到这种情况,我们看下加了volatile的情况:
我们看子线程能明显能感觉到x值得变化,注意到化红线的地方,在子线程end的时候x值肯定是大于900000的。
2:针对内存乱序访问
针对内存乱序访问的情况,我们可以使用内存屏障来处理。
内存屏障:msdn上的解释(windows下函数void MemoryBarrier())
Creates a hardware memory barrier (fence) that prevents the CPU from re-ordering read and write operations. It may also prevent the compiler from re-ordering read and write operations.
所谓内存屏障就是阻止cpu和编译器乱序内存的读写操作。大概的使用方法就是说使用MemoryBarrier();函数来实现这样的效果,在该函数之前的代码对数据的存取要在该函数之后的代码对数据的存取之前执行。
我们接下来再来看一个例子:
#include <thread>
#include <iostream>
bool ok = true;
int val = 0;
void foo()
{
while (1) {
if (!ok) {
std::cout << "foo:" << val << std::endl;
break;
}
}
}
int main()
{
std::thread t1(foo);
std::this_thread::sleep_for(std::chrono::milliseconds(20));
val = 56;
ok = false;
t1.join();
return 0;
}
这个例子子线程有可能打印的是0(只是可能,毕竟我是没有试验成功),因为在val = 56; ok = false;这两句代码有可能乱序,即ok= false可能先执行。再这两句话中间加入MemoryBarrier();就能达到顺序的效果。
讲到这里,我们回归主题,这里如果我们将 val和ok声明为volatile类型的其实也是可以达到内存顺序访问的情况,在这种情况下volatile可以作为MemoryBarrier的一种替代。
3:针对原子操作
我们看了上边的所说是不是觉得volatile关键字具有使变量称为原子性的功能。所谓该变量的原子性,是指这个变量一个线程存取对另一个线程是可见的,另外针对整性或浮点型,它的递增和递减也是一个原子性的操作。
我们来说下volatile,volatile针对存取是原子性的,但是如果用于递增递减则不会是原子性的。
volatile int a = 6; // 原子性
a++; //非原子性
我们来分析下原因:
首先存取的时候我们都知道是直接对内存的操作了,所以原子性大家应该都能理解了。我们着重看下递增和递减的情况,理由是递增或者递减是一个复合操作,针对上边的代码我们看下汇编:
// volatile int a = 6;
mov DWORD PTR _a$[ebp], 6
// a++;
mov eax, DWORD PTR _a$[ebp]
inc eax
mov DWORD PTR _a$[ebp], eax
可以看到首先将立即数6赋值到a的内存中,然后将a的数值取到eax寄存器当中,递增eax,将eax值写回内存。所以我们大概明白了为什么volatile的递增和递减操作不是原子性的了,如果在mov eax, DWORD PTR _a$[ebp]时,另一个线程读取了a的值,就是说更新的这个操作对另外的线程成了不可见的,另一个线程取到的不是最新的值。如果想要使用原子的递增递减可以使用windows提供的Interlocked函数族,另外也可以使用std::atomic等关键字。
总结
本文大致讲了volatile的语法及设计到的计算机知识,然后继续讲了下其用法,然后又讲了下出现问题的替代方案。欢迎大家指教
参照文章:
https://note.wiz.cn/web/pages/manage/biz/payRead.html?kb=eae6e296-3d77-4091-a643-f14b6c9f75a3/4535.html
https://monkeysayhi.github.io/2016/11/29/volatile%E5%85%B3%E9%94%AE%E5%AD%97%E7%9A%84%E4%BD%9C%E7%94%A8%E3%80%81%E5%8E%9F%E7%90%86/