关键词
编译器优化,多线程并行,非临界区共享对象
volatile修饰符
volatile 类似于大家所熟知的const也是一个类型修饰符,用于指示编译器对它所修饰的对象不应该执行优化。多见于多线程编程中的“共享”环境中。
定义为volatile的变量是说这变量可能会被意想不到地改变,即在你程序运行过程中一直会变,你希望这个值被正确的处理,每次从内存中去读这个值,而不是因编译器优化从缓存的地方读取,比如读取缓存在寄存器中的数值,从而保证volatile变量被正确的读取。
在单任务的环境中,一个函数体内部,如果在两次读取变量的值之间的语句没有对变量的值进行修改,那么编译器就会设法对可执行代码进行优化。由于访问寄存器的速度要快过RAM(从RAM中读取变量的值到寄存器),以后只要变量的值没有改变,就一直从寄存器中读取变量的值,而不对RAM进行访问。
而在多任务环境中,虽然在一个函数体内部,在两次读取变量之间没有对变量的值进行修改,但是该变量仍然有可能被其他的程序(如中断程序、另外的线程等)所修改。如果这时还是从寄存器而不是从RAM中读取,就会出现被修改了的变量值不能得到及时反应的问题。如下程序对这一现象进行了模拟。
#include <iostream> using namespace std; int main(int argc, char* argv[]) { int i = 10; int a = i; cout << a << endl; _asm { mov dword ptr [ebp-4],80 } int b = i; cout << b << endl; } /*程序在VS2012环境下生成Release版本,输出结果是: 10 10 */
关注三点:
(1)以上代码必须在Release模式下考查,因为只有Release模式下才会对程序代码进行优化,而这种优化在变量共享的环境下容易引发问题。
(2)在语句 b = i; 之前,已经通过内联汇编代码修改了i的值,但是i的变化却没有反映到b中,如果i是一个被多个任务共享的变量,这种优化带来的错误很可能是致命的。
(3)汇编代码[ebp-4]表示变量i的存储单元,因为ebp是扩展基址指针寄存器,存放函数所属栈的栈底地址,先入栈,占用4个字节。随着函数内申明的局部变量的增多,esp(栈顶指针寄存器)就会相应的减小,因为栈的生长方向由高地址向低地址生长。i为第一个变量,栈空间已被ebp入栈占用了4个字节,所以i的地址为ebp-i,[ebp-i]则表示变量i的存储单元。
那如何抑制编译器对读取变量的这种优化,来防止错误读取呢?volatile可以轻松胜任,将上面的程序稍作修改,将变量i前申明为volatile即可,观察如下程序:
#include <iostream> using namespace std; int main(int argc,char* argv[]) { volatile int i=10; int a=i; cout<<a<<endl; _asm { mov dword ptr [ebp-4],80 } int b=i; cout<<b<<endl; getchar(); } /*output: 10 80 */
也就是说,第二次读取变量i的值的时候,已经获得了变化之后的值。跟踪汇编代码可知,凡是申明为volatile的变量,每次都是从内存中读取变量的值,而不是在某些情况下直接从寄存器中取值。
volatile的应用场景
并行设备的硬件寄存器(如状态寄存器)
假设要对一个设备进行初始化,此设备的某一个寄存器为0xff800000。
int *output = (unsigned int *)0xff800000; //定义一个IO端口; int init(void) { int i; for(i=0;i< 10;i++) { *output = i; } }
经过编译器优化后,编译器认为前面循环半天都是废话,对最后的结果毫无影响,因为最终只是将output这个指针赋值为 9,所以编译器最后给你编译编译的代码结果相当于:
int init(void) { *output = 9; }
如果你对此外部设备进行初始化的过程是必须是像上面代码一样顺序的对其赋值,显然优化过程并不能达到目的。反之,如果你不是对此端口反复写操作,而是反复读操作,其结果是一样的,编译器在优化后,也许你的代码对此地址的读操作只做了一次。然而从代码角度看是没有任何问题的。这时候就该使用volatile通知编译器这个变量是一个不稳定的,在遇到此变量时候不要优化。
一个中断服务子程序中访问到的变量
static int i=0; int main() { while(1) { if(i) dosomething(); } } /* Interrupt service routine */ void IRS() { i=1; }
上面示例程序的本意是产生中断时,由中断服务子程序IRS响应中断,变更程序变量i,使在main函数中调用dosomething函数,但是,由于编译器判断在main函数里面没有修改过i,因此可能只执行一次对从i到某寄存器的读操作,然后每次if判断都只使用这个寄存器里面的“i副本”,导致dosomething永远不会被调用。如果将变量i加上volatile修饰,则编译器保证对变量i的读写操作都不会被优化,从而保证了变量i被外部程序更改后能及时在原程序中得到感知。
多线程应用中被多个任务共享的变量
当多个线程共享某一个变量时,该变量的值会被某一个线程更改,应该用volatile声明。作用是防止编译器优化把变量从内存装入CPU寄存器中,当一个线程更改变量后,未及时同步到其它线程中导致程序出错。volatile的意思是让编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的值。示例如下:
volatile bool bStop=false; //bStop 为共享全局变量 //第一个线程 void threadFunc1() { ... while(!bStop){...} } //第二个线程终止上面的线程循环 void threadFunc2() { ... bStop = true; }
要想通过第二个线程终止第一个线程循环,如果bStop不使用volatile定义,那么这个循环将是一个死循环,因为bStop已经读取到了寄存器中,寄存器中bStop的值永远不会变成true,加上volatile,程序在执行时,每次均从内存中读出bStop的值,就不会死循环了。
是否了解volatile的应用场景是区分C/C++程序员和嵌入式开发程序员的有效办法,搞嵌入式的家伙们经常同硬件、中断、RTOS等等打交道,这些都要求用到volatile变量,不懂得volatile将会带来程序设计的灾难。
volatile与编译器优化
volatile总是与优化有关,编译器有一种技术叫做数据流分析,分析程序中的变量在哪里赋值、在哪里使用、在哪里失效,分析结果可以用于常量合并,常量传播等优化,进一步可以死代码消除。但有时这些优化不是程序所需要的,这时可以用volatile关键字禁止做这些优化,volatile的字面含义是易变的,它有下面的作用:
(1)不会在两个操作之间把volatile变量缓存在寄存器中。在多任务、中断、甚至setjmp环境下,变量可能被其他的程序改变,编译器自己无法知道,volatile就是告诉编译器这种情况。
(2)不做常量合并、常量传播等优化,所以像下面的代码:
volatile int i = 1; if (i > 0) ...
if的条件不会当作无条件真。
(3)对volatile变量的读写不会被优化掉。如果你对一个变量赋值但后面没用到,编译器常常可以省略那个赋值操作,然而对Memory Mapped IO的处理是不能这样优化的。
前面有人说volatile可以保证对内存操作的原子性,这种说法不大准确,其一,x86需要LOCK前缀才能在SMP下保证原子性,其二,RISC根本不能对内存直接运算,要保证原子性得用别的方法,如atomic_inc。
对于jiffies,它已经声明为volatile变量,我认为直接用jiffies++就可以了,没必要用那种复杂的形式,因为那样也不能保证原子性。
你可能不知道在Pentium及后续CPU中,下面两组指令作用相同,但一条指令反而不如三条指令快。
;指令1 inc jiffies ;指令2 mov jiffies, %eax inc %eax mov %eax, jiffies
编译器优化
内存访问速度远不及CPU处理速度,为提高机器整体性能,在硬件上引入硬件高速缓存Cache,加速对内存的访问。另外,在现代CPU中,指令的执行并不一定严格按照顺序执行,没有相关性的指令可以乱序执行,以充分利用CPU的指令流水线,提高执行速度。以上是硬件级别的优化。再看软件一级的优化:一种是在编写代码时由程序员优化,另一种是由编译器进行优化。编译器优化常用的方法有:将内存变量缓存到寄存器;调整指令顺序充分利用CPU指令流水线,常见的是重新排序读写指令。对常规内存进行优化的时候,这些优化是透明的,而且效率很好。由编译器优化或者硬件重新排序引起的问题的解决办法是在从硬件(或者其他处理器)的角度看必须以特定顺序执行的操作之间设置内存屏障(memory barrier),linux提供了一个宏解决编译器的执行顺序问题:
void Barrier(void)
这个函数通知编译器插入一个内存屏障,但对硬件无效,编译后的代码会把当前CPU寄存器中的所有修改过的数值存入内存,需要这些数据的时候再重新从内存中读出。
C语言关键字volatile
C语言关键字volatile(注意它是用来修饰变量而不是上面介绍的volatile)表明某个变量的值可能在外部被改变,因此对这些变量的存取不能缓存到寄存器,每次使用时需要重新存取。该关键字在多线程环境下经常使用,因为在编写多线程的程序时,同一个变量可能被多个线程修改,而程序通过该变量同步各个线程,例如:
DWORD __stdcall threadFunc(LPVOID signal) { int *intSignal = reinterpret_cast<int *>(signal); *intSignal = 2; while (*intSignal != 1) { sleep(1000); } return 0; }
该线程启动时将intSignal置为2,然后循环等待直到intSignal为1时退出。显然intSignal的值必须在外部被改变,否则该线程不会退出。但是实际运行的时候该线程却不会退出,即使在外部将它的值改为1,看一下对应的伪汇编代码就明白了:
mov ax, signal label: if (ax != 1) goto label
对于C编译器来说,它并不知道这个值会被其他线程修改。自然就把它cache在寄存器里面。记住,C 编译器是没有线程概念的!这时候就需要用到volatile。volatile 的本意是指:这个值可能会在当前线程外部被改变。也就是说,我们要在threadFunc中的intSignal前面加上volatile关键字,这时候,编译器知道该变量的值会在外部改变,因此每次访问该变量时会重新读取,所作的循环变为如下面伪码所示:
memory破坏描述符
有了上面的知识就不难理解memory破坏描述符了,memory描述符告知GCC:
1)不要将该段内嵌汇编指令与前面的指令重新排序;也就是在执行内嵌汇编代码之前,它前面的指令都执行完毕。
2)不要将变量缓存到寄存器,因为这段代码可能会用到内存变量,而这些内存变量会以不可预知的方式发生改变,因此GCC插入必要的代码先将缓存到寄存器的变量值写回内存,如果后面又访问这些变量,需要重新访问内存。
如果汇编指令修改了内存,但是GCC 本身却察觉不到,因为在输出部分没有描述,此时就需要在修改描述部分增加“memory”,告诉GCC内存已经被修改,GCC得知这个信息后,就会在这段指令之前,插入必要的指令将前面因为优化Cache到寄存器中的变量值先写回内存,如果以后又要使用这些变量再重新读取。
使用“volatile”也可以达到这个目的,但是我们在每个变量前增加该关键字,不如使用“memory”方便。
volatile常见问题
下面的问题可以看一下面试者是不是真正了解volatile。
(1)一个参数既可以是const还可以是volatile吗?为什么?
是的。一个例子是只读的状态寄存器。它是volatile因为它可能被意想不到地改变。它是const因为程序不应该试图去修改它。
(2)一个指针可以是volatile吗?为什么?
是的。尽管这并不很常见。一个例子是当一个中服务子程序修该一个指向一个buffer的指针时。
(3)下面的函数有什么错误?
int square(volatile int *ptr) { return *ptr * *ptr; }
这段代码有点变态,其目的是用来返回指针ptr指向值的平方,但是,由于ptr指向一个volatile型参数,编译器将产生类似下面的代码:
int square(volatile int *ptr) { int a,b; a = *ptr; b = *ptr; return a * b; }
由于*ptr的值可能被意想不到地该变,因此a和b可能是不同的。结果,这段代码可能返回的不是你所期望的平方值!正确的代码如下:
long square(volatile int *ptr) { int a; a = *ptr; return a * a; }
嵌入式编程中volatile的作用
嵌入式编程中经常用到 volatile这个关键字,常见用法可以归结为以下两点:
(1)告诉compiler不能做任何优化。比如要往某一地址送两指令。
int *ip =...; //设备地址 *ip = 1; //第一个指令 *ip = 2; //第二个指令
以上程序compiler可能做优化而成:
int *ip = ...; *ip = 2;
结果第一个指令丢失。如果用volatile, compiler就不允许做任何的优化,从而保证程序的原意:
volatile int *ip = ...; *ip = 1; *ip = 2;
(2)volatile定义的变量如果在程序外被改变,每次都必须从内存中读取,而不能把他放在cache或寄存器中重复使用。如:
volatile char a; a=0; while(!a) { //do some things; } doother();
如果没有volatile,doother()不会被执行。
volatile能够避免编译器优化带来的错误,但使用volatile的同时,也需要注意频繁地使用volatile很可能会增加代码尺寸和降低性能,因此要合理的使用volatile。
如何在多线程中使用好volatile
在多线程中,我们可以利用锁的机制来保护好资源临界区。在临界区的外面操作共享变量则需要volatile,在临界区的里面则non-volatile了。我们需要一个工具类 LockingPtr 来保存mutex的采集和volatile的利用 const_cast 的转换(通过 const_cast 来进行volatile的转换)。
首先我们声明一个LockingPtr中要用到的Mutex类的框架:
class Mutex { public: void Acquire(); void Release(); ... };
接着声明最重要的LockingPtr模板类:
template <typename T> class LockingPtr { public: // Constructors/destructors LockingPtr(volatile T& obj, Mutex& mtx) : pObj_(const_cast<T*>(&obj)), pMtx_(&mtx) { mtx.Lock(); } ~LockingPtr() { pMtx_->Unlock(); } // Pointer behavior T& operator*() { return *pObj_; } T* operator->() { return pObj_; } private: T* pObj_; Mutex* pMtx_; LockingPtr(const LockingPtr&); LockingPtr& operator=(const LockingPtr&); };
尽管这个类看起来简单,但是它在编写争取的多线程程序中非常的有用。你可以通过对它的使用来使得对多线程中共享的对象的操作就好像对volatile修饰的基本变量一样简单而且从不会使用到 const_cast 。下面来给一个例子:
假设有两个线程共享一个 vector<char> 对象:
class SyncBuf { public: void Thread1(); void Thread2(); private: typedef vector<char> BufT; volatile BufT buffer_; Mutex mtx_; // controls access to buffer_ };
在函数Thread1中,你通过lockingPtr<BufT>来控制访问buffer_成员变量:
void SyncBuf::Thread1() { LockingPtr<BufT> lpBuf(buffer_, mtx_); BufT::iterator i = lpBuf->begin(); for (; i != lpBuf->end(); ++i) { ... use *i ... } }
这个代码很容易编写和理解。只要你需要用到buffer_你必须创建一个lockingPtr<BufT>指针来指向它,并且一旦你这么做了,你就获得了容器vector的整个接口。而且你一旦犯错,编译器就会指出来:
void SyncBuf::Thread2() { // Error! Cannot access 'begin' for a volatile object BufT::iterator i = buffer_.begin(); // Error! Cannot access 'end' for a volatile object for (; i != lpBuf->end(); ++i) { ... use *i ... } }
这样的话你就只有通过const_cast或LockingPtr来访问成员函数和变量了。这两个方法的不同之处在于后者提供了顺序的方法来实现而前者是通过转换为volatile来实现。LockingPtr是相当好理解的,如果你需要调用一个函数,你就创建一个未命名的暂时的LockingPtr对象并直接使用:
unsigned int SyncBuf::Size() { return LockingPtr<BufT>(buffer_, mtx_)->size(); }
LockingPtr在基本类型中的使用
在上面我们分别介绍了使用volatile来保护对象的意外访问和使用LockingPtr来提供简单高效的多线程代码。现在来讨论比较常见的多线程处理共享基本类型的一种情况:
class Counter { public: ... void Increment() { ++ctr_; } void Decrement() { —-ctr_; } private: int ctr_; };
这个时候可能大家都能看出来问题所在了。1.ctr_需要是volatile型。2.即便是++ctr_或--ctr_,这在处理中仍是需要三个原子操作的(Read-Modify-Write)。基于上述两点,这个类在多线程中会有问题。现在我们就来利用LockingPtr来解决:
class Counter { public: ... void Increment() { ++*LockingPtr<int>(ctr_, mtx_); } void Decrement() { —?*LockingPtr<int>(ctr_, mtx_); } private: volatile int ctr_; Mutex mtx_; };
volatile成员函数
关于类的话,首先如果类是volatile则里面的成员都是volatile的。其次要将成员函数声明为volatile则同const一样在函数最后声明即可。当你设计一个类的时候,你声明的那些volatile成员函数是线程安全的,所以那些随时可能被调用的函数应该声明为volatile。考虑到volatile等于线程安全代码和非临界区;non-volatile等于单线程场景和在临界区之中。我们可以利用这个做一个函数的volatile的重载来在线程安全和速度优先中做一个取舍。具体的实现此处就略去了。
总结
在编写多线程程序中使用volatile的关键四点:
1. 将所有的共享对象声明为volatile;
2. 不要将volatile直接作用于基本类型;
3.当定义了共享类的时候,用volatile成员函数来保证线程安全;
4.多多理解和使用volatile和LockingPtr!(强烈建议)
参考资料
https://blog.csdn.net/weixin_41656968/article/details/80958973
https://blog.csdn.net/weixin_44363885/article/details/92838607
https://www.cnblogs.com/cmt/p/14580194.html