http://renyan.spaces.eepw.com.cn/articles/article/item/86826
随着多核的日益普及,越来越多的程序将通过多线程并行化的方式来提升性能。然而,编写正确的多线程程序一直是一件非常困的事情,volatile关键字的使用就是其中一个典型的例子。
C/C++中的volatile一般不能用于多线程同步
在C/C++中,如果想把一个变量声明为volatile,就相当于告诉编译器这个变量是“易变的”,他随时可能在其他地方被修改,所以编译器不能对其做任何变化:即每次读写该变量时都必须对其内存地址直接进行操作,并且所以对该变量的操作都必须严格按照程序中规定的顺序执行。举例来说,编译器的常常做的一种性能优化就是把需频繁读取的变量缓存到寄存器中,以提升访问速度。但如果该变量的值随时可能在片外被改变的话,那么就有可能出现被缓存的值并不是该变量的最新值情况,从而出现运行错误。在这种情况就需要用volatile关键字来修饰这个变量,以确保编译器不会对该变量读写操作进行任何缓存优化。另一个例子就是内存映射I/O操作。如下代码所示:
Int *p = get_io_address();
Int a, b;
A = *p;
B = *p;
P是一个指向硬件I/O端口的指针,该端口的值在每进行一次读操作后都会变化。这个程序连续对该端口进行两次读取操作已将两个不同的值分别赋值给a和b。如果不把a和b声明为volatile的话,编译器可能会”自作聪明”地认为两次从p读取的值都是一样的,从而把*b=*p优化成b = a,最终导致程序出错。
虽然C/C++中volatile关键字对这种“易变“的读写操作能起到一定的保护,但他却并不适用于多线程程序中共享变量的同步操作。究其根源,就在于C/C++标准中并没有volatile赋予原子性和顺序性的语义。
原子性
下面举个例子说明原子性。i++这看似原子的语句其实有三个操作组成:将该值从内存地址读取到寄存器中,对寄存器中的值进行加1操作,最后再将新值写回内存中,正是因为i++并不是原子的,所以如果两个线程同时进行i++操作的话仍会产生数据竞跑,从而导致i的最终值不等于2.在这种情况下,C/C++中的volatile关键字根本无法对该操作的原子性提供任何保障。
Volatile int i=0;
//线程1
I++;
//线程2
I++;
顺序性
不幸的是,现在C/C++标准中的volatile关键字对共享变量操作的顺序性也未提供任何保障。以本文中的dekker算法为例:当两个线程分别执行dekker1和dekker2函数时候,改程序通过对flag1/2和turn的读写来实现两个线程对临界区中共享变量gCounter的互斥访问。这个算法的关键就在于对flag1/2和turn的读写操作是在其写操作之后进行的,因此它能保证dekker1和dekker2中对gCounterde的操作时互斥的,相当于把gCounter++放到一个临界区中去了。Dekker算法如下所示:
Volatile int flag1 = 0;
Volatile int flag2 = 0;
Volatile int turn = 1;
Volatile int gCounter = 0;
Void dekker1()
{
Flag1 = 1;
Turn = 2;
While( (flag2 == 1) && ( turn == 2) ){}
//进入临界区
gCounter++;
flag1 = 0; //离开临界区
}
Void dekker2()
{
Flag2 = 1;
Turn = 2;
While( (flag1 == 1) && ( turn == 2) ){}
//进入临界区
gCounter++;
flag2 = 0; //离开临界区
}
尽管volatile规定编译器不能对同一变量的所有操作进行乱序优化,但它却不能阻止编译器对不同volatile变量间的操作进行乱序优化。例如,编译器可能把dekker1中的flag2读操作提到flag1和turn写操作之前,从而导致对临界区的互斥访问失效,最终gCounter++操作就会出现数据竞跑现象。线程1在执行flag = 1和turn = 2 之前,由于编译器对操作的乱序优化,导致先读取flag2, 此时flag2 = 0,所以线程1直接跳出while循环,要进行gCount++, 此时,暂停线程1的执行,开始执行线程2,线程2也出现和线程1类似的情况,所以线程2也开始执行gCount++。这时就会出现数据竞跑。事实上,即使编译器没有对这个程序做任何优化,volatile 关键字也不能阻止多核CPU对该程序的乱序优化。以常见的x86硬件来说,它可以对不同变量x,y的store x --àload y进行乱序优化,把load y操作提到store x操作之前。这样的话,dekker1中flag2的读操作还是有可能会被提到flag1和turn的写操作之前,最终导致错误的计算结果。
那为什么编译器和多核CPU会对多线程程序做这样的乱序优化呢?因为从单核的视角来看,flag1 和 flag2,turn的读写操作之间没有任何依赖关系的,使用编译器/CPU当然可以对他们进行乱序优化以隐藏一部分的内存访问延迟,从而更好的利用CPU里的流水线。换句话说,这样的优化虽从单线程的角度来讲没有错,但却违反了设计这个多线程算法时所期望的多线程语义。要是解决这个问题,我们需要解决这个问题,我们需要自己添加内存栅栏以显式保证顺序性,或者干脆去别去实现这样的算法,转而使用类似pthread_mutex_lock这样的加锁操作来实现互斥访问。
综合上述,由于现有的C/C++标准中并没有对volatile添加原子性和顺序性的语义,所以绝大部分C/C++程序中使用volatile来进行多线程同步的用法是错误的。其实,我们之所以想用volatile变量进行同步,无非是因为锁,条件变量等方式的开销太大,所以想有一种轻量级的,高效的同步机制。