Volatile关键字与原子性操作

什么是原子性操作

原子性操作具有不可分割性。比如 i=0 这个操作是不可分割的,那么我们说这个操作时原子操作。再比如:

i++

这个操作实际是

i = i + 1

是可分割的,所以他不是一个原子操作。非原子操作都会存在线程安全问题,需要我们使用同步技术(sychronized)来让它变成一个原子操作。一个操作是原子操作,那么我们称它具有原子性。

那么x=y;这个操作是不是原子性操作呢?答案也是否定的,该操作可以向下细分为首先读取y的数据,再赋值给x的过程。

注意:在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。

什么是Volatile关键字

volatile 关键字是一种类型修饰符,被它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。声明时语法如下:

 int volatile vInt;

为什么需要原子性操作

计算机在执行程序时,每条指令都是在CPU中执行的,而在执行指令过程中,会涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。

也就是,当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。

而当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。而且读取的数据立刻被保存。

同样针对i++; 操作。当线程执行这个语句时,会先从主存当中读取i的值,然后复制一份到高速缓存当中,然后CPU执行指令对i进行加1操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。

这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了。在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。本文我们以多核CPU为例。

当同时有2个线程执行这段代码,假如初始时i的值为0,那么我们希望两个线程执行完之后i的值变为2。在实际运行过程中可能存在下面一种情况:最初,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,然后线程1进行加1操作,然后把i的最新值1写入到内存。此时线程2的高速缓存当中i的值还是0,进行加1操作之后,i的值为1,然后线程2把i的值写入内存。

最终结果i的值是1,而不是2。这就导致了结果的出错。通常称这种被多个线程访问的变量为共享变量。

也就是说,如果一个变量在多个CPU中都存在缓存(主要当多线程时),那么就可能存在缓存不一致的问题。

针对这类问题从硬件角度讲可以通过在总线加LOCK锁或者通过缓存一致性协议的方式实现。软件方面不同的语言存在着不同的操作。

Volatile能不能实现原子性操作?

在并发编程中,多进程的一个共享变量被volatile修饰之后,那么就具备了两层语义:

1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
2)禁止进行指令重排序。(保证指令的有序性)

通过例子来说明第一层语义,假设线程1先执行,线程2后执行:

//线程1
boolean stop = false;
while(!stop){
    
    
    doSomething();
}
//线程2
stop = true;

针对这个程序存在以下的问题:每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。

但是用volatile修饰之后就变得不一样了:

首先使用volatile关键字会强制将修改的值立即写入主存;

之后使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);

最后由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。

那么在线程2修改stop值时(当然这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写入内存),会使得线程1的工作内存中缓存变量stop的缓存行无效,然后线程1读取时,发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。

那么线程1读取到的就是最新的正确的值。

但是Volatile关键字仍然不能保证原子性。

问题在于volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。

在前面已经提到过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致缓存不同步的情况出现。

Volatile关键字常用的场景

通常volatile关键字被用于以下的几个地方:
1、中断服务程序中修改的供其它部分程序检测的共享变量。
2、多任务环境下各个任务的共享标志应该加volatile。
3、存储器的硬件寄存器需要加volaile说明,因为对它的每次读写都有不同的含义。

CUDA编程实现原子性操作的方法

在这里简单的提供一个使用倒原子性操作的例程,并记录一些注意事项。

统计计算一组数据并作出直方图:

CPU版本:

#define SIZE (100*1024*1024)
int main(void){
    
    
    unsigned char *buffer = (unsigned char*)big_random_block(SIZE);
    unsigned int histo[256];
    for(int i=0;i<256;i++)
        histo[i]=0;
    for(itn i=0;i<SIZE;i++)
        histo[buffer[i]]++;

atomicAdd(addr,y ) 将生成一个原子操作序列,这个操作序列包括读取地址 addr 处的值,将 y 增加到这个值,以及将结果保存回地址 addr. 底层硬件将保证执行这些操作时,任何其他的线程都不会读取写入 addr 上的值。

CPU版本的kernel函数部分:

__global__ void histo_kernel(unsigned char *buffer,long size,unsigned int *histo){
    
    
    int i = threadIdx.x + blockIdx.x*blockDim.x;
    int stride = blockDim.x * gridDim.x;
    while(x < size){
    
    
        atomicAdd(&(histo[buffer[i]]),1);
        i += stride;
    }
}

这个方法比 cpu 还要慢,因为几千个线程访问少量的内存,将发生大量的竞争,为了确保递增操作的原子性,对相同内存位置的操作都将被硬件串行化。

使用共享内存原子操作和全局内存原子操作的直方图kernel函数

__global__ void histo_kernel(char *buffer,long size,unsinged int *histo){
    
    
    int i= threadIdx.x +blockDim.x * blockIdx.x;
    int stride = gridDim.x * blockDim.x;
    __share__ unsigned int temp[256];
    temp[threadIdx.x]=0;
    __syncthreads();
    while(i<size){
    
    
        atomicAdd(&(temp[buffer[i]]),1);
        i += stride;
    }
    __syncthreads();
    //最后将所用共享内存上的直方图相加到一个直方图上
    atomicAdd(&(histo[threadIdx.x]),temp[threadIdx.x]);
}

虽然最初将直方图程序修改为GPU版本使性能下降,但同时使用共享内存与原子性操作仍然能使程序加速。实测程序速度提升了大概7倍左右。

猜你喜欢

转载自blog.csdn.net/daijingxin/article/details/113767433