菜单

volatile关键字及编译器指令乱序总结

2020年3月1日 - 计算机服务器
volatile关键字及编译器指令乱序总结

本文介绍多线程环境下并行编程的基础设施。主要包括:

原标题:VOLATILE与内存屏障总结

本文简单介绍volatile关键字的使用,进而引出编译期间内存乱序的问题,并介绍了有效防止编译器内存乱序所带来的问题的解决方法,文中简单提了下CPU指令乱序的现象,但并没有深入讨论。
    

  • class=”wp_keywordlink”>Volatile
  • __thread
  • Memory Barrier
  • __sync_synchronize

一. 内存屏障 Memory Barrior

以下是我搭建的博客地址:
http://itblogs.ga/blog/20150329150706/    欢迎到这里阅读文章。

volatile

编译器有时候为了优化性能,会将一些变量的值缓存到寄存器中,因此如果编译器发现该变量的值没有改变的话,将从寄存器里读出该值,这样可以避免内存访问。

但是这种做法有时候会有问题。如果该变量确实(以某种很难检测的方式)被修改呢?那岂不是读到错的值?是的。在多线程情况下,问题更为突出:当某个线程对一个内存单元进行修改后,其他线程如果从寄存器里读取该变量可能读到老值,未更新的值,错误的值,不新鲜的值。

如何防止这样错误的“优化”?方法就是给变量加上volatile修饰。

volatile int i=10;//用volatile修饰变量i
......//something happened 
int b = i;//强制从内存中读取实时的i的值

OK,毕竟volatile不是完美的,它也在某种程度上限制了优化。有时候是不是有这样的需求:我要你立即实时读取数据的时候,你就访问内存,别优化;否则,你该优化还是优化你的。能做到吗?

不加volatile修饰,那么就做不到前面一点。加了volatile,后面这一方面就无从谈起,怎么办?伤脑筋。

其实我们可以这样:

int i = 2; //变量i还是不用加volatile修饰

#define ACCESS_ONCE(x) (* (volatile typeof(x) *) &(x))

需要实时读取i的值时候,就调用ACCESS_ONCE(i),否则直接使用i即可。

这个技巧,我是从《Is parallel programming hard?》上学到的。

听起来都很好?然而险象环生:volatile常被误用,很多人往往不知道或者忽略它的两个特点:在C/C++语言里,volatile不保证原子性;使用volatile不应该对它有任何Memory
Barrier
的期待。

第一点比较好理解,对于第二点,我们来看一个很经典的例子:

volatile int is_ready = 0;
char message[123];
void thread_A
{
  while(is_ready == 0)
  {
  }
  //use message;
}
void thread_B
{
  strcpy(message,"everything seems ok");
  is_ready = 1;
}

线程B中,虽然is_readyvolatile修饰,但是这里的volatile不提供任何Memory
Barrier
,因此12行和13行可能被乱序执行,is_ready = 1被执行,而message还未被正确设置,导致线程A读到错误的值。

这意味着,在多线程中使用volatile需要非常谨慎、小心。

1.1 重排序

volatile关键字

volatile关键字用来修饰一个变量,提示编译器这个变量的值随时会改变。通常会在多线程、信号处理、中断处理、读取硬件寄存器等场合使用。

程序在执行时,通常将数据(变量的值)从内存的读到寄存器中,然后进行运算,此后对该变量的处理,都是直接访问寄存器就可以了,不再访问内存,因为
访存的代价是很高的(这块是访问寄存器还是重新访存加载到寄存器是编译器在编译阶段就决定了的)。但在上述说的几种情况下,内存会被另一个线程或者信号处
理函数、中断处理函数、硬件改掉,这样,代码只访问寄存器的话,永远得不到真实的值。

   

对这样的变量(会在多线程、线程与信号、线程与中断处理中共同访问的,或者硬件寄存器),在定义时都会加上volatile关键字修饰。这样编译器
在编译时,编译出的指令会重新访存,这样就能保证拿到正确的数据了。但这里需要注意的是,编译器只能做到让指令重新访问内存,而不是直接使用寄存器中的
值,这些和缓存没有关系,具体执行时指令是访问内存还是访问的缓存,编译器也无法干预。

   

另外,除了使用寄存器来避免多次访存外,编译器有时可能直接将变量全部优化掉,使用常数代替。比如:

int main()
{
    int a = 1;
    int b = 2;

    printf(“a = %d, b = %d n”, a, b);
}

   

编译器可能直接优化为:     

int main()
{
    printf(“a = %d, b = %d n”, 1, 2);
}

   

  如果对ab的声明加了 volatile关键字,编译器将不在做这样的优化。

             

还有,对所有volatile变量,编译器在编译阶段保证不会将访问volatile变量的指令进行乱序重排。

    

   

__thread

__threadgcc内置的用于多线程编程的基础设施。用__thread修饰的变量,每个线程都拥有一份实体,相互独立,互不干扰。举个例子:

#include<iostream>  
#include<pthread.h>  
#include<unistd.h>  
using namespace std;
__thread int i = 1;
void* thread1(void* arg);
void* thread2(void* arg);
int main()
{
  pthread_t pthread1;
  pthread_t pthread2;
  pthread_create(&pthread1, NULL, thread1, NULL);
  pthread_create(&pthread2, NULL, thread2, NULL);
  pthread_join(pthread1, NULL);
  pthread_join(pthread2, NULL);
  return 0;
}
void* thread1(void* arg)
{
  cout<<++i<<endl;//输出 2  
  return NULL;
}
void* thread2(void* arg)
{
  sleep(1); //等待thread1完成更新
  cout<<++i<<endl;//输出 2,而不是3
  return NULL;
}

需要注意的是:

1,__thread可以修饰全局变量、函数的静态变量,但是无法修饰函数的局部变量。

2,被__thread修饰的变量只能在编译期初始化,且只能通过常量表达式来初始化。

同步的目的是保证不同执行流对共享数据并发操作的一致性。在单核时代,使用原子变量就很容易达成这一目的。甚至因为CPU的一些访存特性,对某些内存对齐数据的读或写也具有原子的特性。但在多核架构下即使操作是原子的,仍然会因为其他原因导致同步失效。

  指令乱序

那么什么是指令乱序,指令乱序是为了提高性能,而导致的执行时的指令顺序和代码写的顺序不一致。指令乱序有编译期间指令乱序和执行时指令乱序。

执行时指令乱序是CPU的一个特性,这块比较复杂,不再这里提及。我们只需要知道在x86/x64的体系架构下,程序员一般不需要关注执行时指令乱序(不需要关注不代表没有)。

编译期间指令乱序是指在编译成二进制代码时,编译器为了所谓的优化进行了指令重排,导致二进制指令的顺序和我们写的代码的顺序是不一致的。

比如以下代码:

int a;
int b;

int main()
{
    a = b + 1;
    b = 0;
}

会被优化成(实际上在汇编阶段进行的乱序优化,优化后的代码也只能以汇编的方式查看,这里只是拿C代码举例说明一下):

int a;
int b;

int main()
{
    b = 0;
    a = b + 1;
}

对加上volatile关键字的变量的访问,编译器不会进行指令乱序的优化,保证volatile变量的访问顺序和代码写的是一样的。比如如下代码不会优化:

volatile int a;
volatile int b;

int main()
{
    a = b + 1;
    b = 0;
}

   

但是以下代码,依然会乱序,因为编译器只是保证volatile变量访问的顺序,对于非volatile变量之间,以及volatile以及非volatile变量之间的顺序,编译器还是会优化。

int a;volatile int b;int main(){    a = b + 1;    b = 0;}

   

       

Memory Barrier

为了优化,现代编译器和CPU可能会乱序执行指令。例如:

int a = 1;
int b = 2;
a = b + 3;
b = 10;

CPU乱序执行后,第4行语句和第5行语句的执行顺序可能变为先b=10然后再a=b+3

有些人可能会说,那结果不就不对了吗?b为10,a为13?可是正确结果应该是a为5啊。

哦,这里说的是语句的执行,对应的汇编指令不是简单的mov b,10和mov b,a+3。

生成的汇编代码可能是:

movl    b(%rip), %eax ; 将b的值暂存入%eax
movl    $10, b(%rip) ; b = 10
addl    $3, %eax ; %eax加3
movl    %eax, a(%rip) ; 将%eax也就是b+3的值写入a,即 a = b + 3

这并不奇怪,为了优化性能,有时候确实可以这么做。但是在多线程并行编程中,有时候乱序就会出问题。

一个最典型的例子是用锁保护临界区。如果临界区的代码被拉到加锁前或者释放锁之后执行,那么将导致不明确的结果,往往让人不开心的结果。

还有,比如随意将读数据和写数据乱序,那么本来是先读后写,变成先写后读就导致后面读到了脏的数据。因此,Memory
Barrier
就是用来防止乱序执行的。具体说来,Memory Barrier包括三种:

1,acquire barrieracquire
barrier
之后的指令不能也不会被拉到该acquire barrier之前执行。

2,release barrierrelease
barrier
之前的指令不能也不会被拉到该release barrier之后执行。

3,full barrier。以上两种的合集。

所以,很容易知道,加锁,也就是lock对应acquire
barrier
;释放锁,也就是unlock对应release
barrier
。哦,那么full barrier呢?

首先是现代编译器的代码优化和编译器指令重排可能会影响到代码的执行顺序。

asm volatile (“” : : : “memory”);

一般编程时如果使用到volatile关键字,那么基本上都需要考虑编译器指令乱序的问题。解决编译器指令乱序所带来的问题,除了上面将必要的变量声明为volatile,还可以使用下面一条嵌入式汇编语句:

1 asm volatile ("" : : : "memory");

这是一条空汇编语句,只是告诉编译器,内存发生了变化。编译器遇到这条语句后,会生成访存更新寄存器的指令,将所有的寄存器的值更新一遍。这里是编译器遇到这条语句额外生成了一些代码,而不是CPU遇到这条语句执行了一些处理,因为这条语句本身并没有CPU指令与之对应。

由于编译器知道这条语句之后内存发生了变化,编译器在编译时就会保证这条语句上下的指令不会乱,即这条语句上面的指令,不会乱序到语句下面,语句下面的指令不会乱序到语句上面。

利用编译器这个功能,程序员可以:

1、利用这条语句,强制程序访存,而不是使用寄存器中的值,作为使用volatile关键字的一个替代手段;

2、在不允许乱序的两个语句之间插入这条语句从而保证不会被编译器乱序。

   

下面看一个应用的例子,两个线程访问共享的全局变量:

#define ARRAY_LEN 12

volatile int flag = 0;
int a[ARRAY_LEN];

pthread1()
{
    a[ARRAY_LEN – 1] = 10; <br>    asm volatile (“” : : :
“memory”);
    flag = 1;
}

pthread2()
{
    int sum = 0;

    if(flag == 0) {
        sum += a[ARRAY_LEN – 1];
    }    
}线程2假定flag==1时,线程1已经将数据放到数组中了。但实际上,如果没有 
asm volatile (“” : : : “memory”),线程1并不能保证flag =
1在数组赋值之后。原因就是我们前面提到的编译器指令乱序。

     

指令乱序是一个比较复杂的话题,我们这里只考虑了编译器指令乱序,在intel架构的CPU上,基本上考虑到这些就足够了。但在弱指令序的CPU上,比如mips,了解这些还远远不够。本文不打算展开CPU指令乱序的话题,感兴趣的可以参考以下文章了解以下:

        Memory Reordering Caught in the
Act

        This Is Why They Call It a Weakly-Ordered
CPU

   

   

__sync_synchronize

__sync_synchronize就是一种full barrier

其次还有指令执行级别的乱序优化,流水线、乱序执行、分支预测都可能导致处理器次序(Process
Ordering,机器指令在CPU实际执行时的顺序)和程序次序(Program
Ordering,程序代码的逻辑执行顺序)不一致。可惜不影响语义依旧只能是保证单核指令序列间,单核时代CPU的Self-Consistent特性在多核时代已不存在(Self-Consistent即重排原则:有数据依赖不会进行重排,单核最终结果肯定一致)。

volatile关键字的使用

volatile关键字使用和const一致,下面是一个总结:

char const * pContent;       // *pContent是const,   pContent可变
(char *) const pContent;     //  pContent是const,  *pContent可变
char* const pContent;        //  pContent是const,  *pContent可变
char const* const pContent;  //  pContent 和       *pContent都是const

   

沿着*号划一条线,如果const位于*的左侧,则const就是用来修饰指针所指向的变量,即指针指向为常量;如果const位于*的右侧,const就是修饰指针本身,即指针本身是常量。

   

   

除此还有硬件级别Cache一致性(Cache
Coherence)带来的问题:CPU架构中传统的MESI协议中有两个行为的执行成本比较大。一个是将某个Cache
Line标记为Invalid状态,另一个是当某Cache
Line当前状态为Invalid时写入新的数据。所以CPU通过Store Buffer和Invalidate
Queue组件来降低这类操作的延时。如图:

参考资料

Memory Ordering at Compile
Time

以下是我搭建的博客地址:
原文链接:http://itblogs.ga/blog/20150329150706/ 转载请注明出处

    

图片 1

当一个核心在Invalid状态进行写入时,首先会给其它CPU核发送Invalid消息,然后把当前写入的数据写入到Store
Buffer中。然后异步在某个时刻真正的写入到Cache
Line中。当前CPU核如果要读Cache Line中的数据,需要先扫描Store
Buffer之后再读取Cache Line(Store-Buffer
Forwarding)。但是此时其它CPU核是看不到当前核的Store
Buffer中的数据的,要等到Store Buffer中的数据被刷到了Cache
Line之后才会触发失效操作。

而当一个CPU核收到Invalid消息时,会把消息写入自身的Invalidate
Queue中,随后异步将其设为Invalid状态。和Store
Buffer不同的是,当前CPU核心使用Cache时并不扫描Invalidate
Queue部分,所以可能会有极短时间的脏读问题。这里的Store
Buffer和Invalidate
Queue的说法是针对一般的SMP架构来说的,不涉及具体架构。

内存对于缓存更新策略,要区分Write-Through和Write-Back两种策略。前者更新内容直接写内存并不同时更新Cache,但要置Cache失效,后者先更新Cache,随后异步更新内存。通常X86
CPU更新内存都使用Write-Back策略。

1.2 编译器屏障 Compiler Barrior

/* The “volatile” is due to gcc bugs */

#define barrier() __asm__ __volatile__(“”: : :”memory”)

阻止编译器重排,保证编译程序时在优化屏障之前的指令不会在优化屏障之后执行。

1.3 CPU屏障 CPU Barrior

CPU级别内存屏障其作用有两个:

  1. 防止指令之间的重排序
  2. 保证数据的可见性

指令重排中Load和Store两种操作会有Load-Store、Store-Load、Load-Load、Store-Store这四种可能的乱序结果。

Intel为此提供三种内存屏障指令:

相关文章

发表评论

电子邮件地址不会被公开。 必填项已用*标注

网站地图xml地图