在cpu运行指令之前,内存操作指令都可能在遵循一定的规则下被重排,编译期间以及cpu执行期间,主要目的都是为了使得代码运行更快点。cpu执行指令看似顺序执行的,其实本质内部是并行的,只是达到的结果和顺序执行的没有区别而已。
编译器和cpu产商在内存指令次顺问题上都遵循着一条基本的规则“在单线程程序上不改变其运算的正确结果”
因为这条规则所以当编写单线程程序时,我们不需要注意内存一致性的问题。当编写多线程程序时,由于mutexes,semaphores 以及events都被设计了阻止内存指令重排在他们调用区域内。只有当无锁技术lock-free采用,内存被多个线程共享时而且相互之间没有任何互斥操作,这个时候内存重排才会被关注到。
在多核的平台上编写多线程程序且不用关心内存指令重排的问题是可以实现的,比如通过c++11的atmoics,可能稍微花费一点点额外的消耗。下面重点关注下编译器的内存指令重排。
查看如下代码
//t.c
int A,B;
void f(){
A = B+1;
B= 0;
}
使用如下指令去编译 gcc -S t.c 得到的汇编指令如下(略去无关紧要部分),可以观察到并没有发生指令重排。
movl B(%rip), %eax
addl $1, %eax
movl %eax, A(%rip)
movl $0, B(%rip)
当使用O2的优化去编译时 gcc -O2 -S t.c,得到汇编指令如下,观察到指令发生重排了。B=0;被排到前面去了。
movl B(%rip), %eax
movl $0, B(%rip)
addl $1, %eax
movl %eax, A(%rip)
我们观察编译O2优化后,把写入B提前了。但是内存排序的基本规则并没有打破,单线程程序不可能能发现这两者有啥区别。
当编写lock-free程序时,编译器指令重排就会照成问题了。
参考下面的例子,IsPublished标记着值有没有被写入,如果编译器把IsPublished = 1;提前的话,就算在单处理器的多线程程序中,也会有问题。一个线程执行了IsPublished=1,但是Value并没有被更新。其他线程读取IsPublished后就误认为Value已经被更新了,但实际上并没有。
int Value;
int IsPublished = 0;
void sendValue(int x)
{
Value = x;
IsPublished = 1;
}
显式编译Barriers
如下代码列出了编译阶段阻止内存指令重排的例子
int A, B;
void foo()
{
A = B + 1;
asm volatile("" ::: "memory");
B = 0;
}
使用O2优化编译: gcc -O2 -S t.c,得到的汇编指令如下,观察到并没有被编译器重排指令了,指令顺序和c源码一致了。这个也只能保证在单cpu的是线程安全的。
movl B(%rip), %eax
addl $1, %eax
movl %eax, A(%rip)
movl $0, B(%rip)
emm,现在我们的目标假设是让这个例子可以在单核处理器中运行的没问题,代码如下,不仅在sendValue中加入编译屏障 而且在tryRecvValue中也加入编译屏障。
#define COMPILER_BARRIER() asm volatile("" ::: "memory")
int Value;
int IsPublished = 0;
void sendValue(int x){
Value = x;
COMPILER_BARRIER();
IsPublished = 1;
}
int tryRecvValue(){
if(IsPublished){
COMPILER_BARRIER();
return Value;
}
return -1;
}
上面例子中tryRecvValue我觉得没必要加入编译屏障,可以对比汇编代码得知(都是使用O2优化),仅仅去掉je和mov,用cmovne替代了,这么优化的目的主要减少分支和跳转指令,避免分支预测错误带来的惩罚。
//没加之前
movl IsPublished(%rip), %eax
testl %eax, %eax
movl $-1, %eax
cmovne Value(%rip), %eax
//加入之后
movl IsPublished(%rip), %eax
testl %eax, %eax
je .L4
movl Value(%rip), %eax
要想既可以运行在单核机器上,还可以运行在多核的机器上,仅仅有编译屏障是不够的,还需要有cpu屏障指令。linux提供宏定义比如smb_rmb。
隐式编译Barriers
cpu fence指令同样也作为编译barriers来使用。下列我们定义cpu fence指令
#define RELEASE_FENCE asm volatile("lwsync" ::: "memory")
当我们把RELEASE_FENCE 加到代码中,不仅会阻止处理器的内存指令重排,当然了编译阶段也会阻止指令重排。这个就是多核平台下多线程安全的代码了。
void sendValue(int x){
Value = x;
RELEASE_FENCE ();
IsPublished = 1;
}
在C++11中,提供一些原子库,每个non-relaxed原子操作都被同样被当成内存屏障
int Value;
std::atomic<int> IsPublished(0);
void SendValue(int x){
Value = x;
IsPublished.store(1,std::memory_order_release);
}
就如我们想象的一样,每个含有内存屏障的函数,自身都会被当做是一个内存屏障,即使这个函数是个内联函数(可能早期的VC++编译器并不是这样的)
void doSomeStuff(Foo* foo)
{
foo->bar = 5;
sendValue(123); // prevents reordering of neighboring assignments
foo->bar2 = foo->bar;
}
实际上大多数函数调用都被当成内存屏障,不论函数本身是否包含内存屏障,内联除外。外部调用的函数更会被当做内存屏障了。因为编译阶段无法对函数产生的影响做任何假设。
这个也容易理解的,编译器无法假设sendValue是否会会修改foo->bar的值,如果修改了,如果重排了指令,这样违背了“在单线程程序上不改变其运算的正确结果”这条最基本的规则了。
编译器会有一些优化的准则以及禁止优化的准则的。volatile关键字修饰后,虽然可以阻止编译器重排指令,但是不能阻止cpu重排指令,所以volatile在编写线程安全代码时,并没啥作用。
网友评论