Memory Ordering at Compile Time

JUN 25, 2012 原文在这:http://preshing.com/20120625/memory-ordering-at-compile-time/
前言:这是译自preshing博客的第二篇文章,本文讨论的是编译优化导致的重排序问题。

从你写C/C++源代码到它在CPU里执行,这段代码的内存交互可能会根据特定的规则被重新排序了。编译器(编译期)和processor(运行期)都有可能导致内存序的改变,都是为了能够让你的代码运行的更快。
对于memory reordering,被编译器开发者和CPU制造商广泛遵循的主要原则就是:
绝对不能修改单线程程序的行为;
Thou shalt not modify the behavior of a single-threaded program.

这条规则的结果就是,对于单线程代码,程序员完全无需关注memory reordering。通常多线程程序也不需要关注,因为从设计上,mutex, semaphore和event都会在它们的调用点防止memory reordering。仅仅在编写lock-free代码时memory reordering才可能会出来捣乱——被多线程共享的内存没有任何的互斥锁,memory reordering的影响很容易观察到,可见前一章的例子。

需要提醒你的是,在编写多核平台上的lock-free代码时还是有方法可以回避memory reordering的困难。就像在introduction to lock-free programming中提到的,你可以利用sequential consistent类型,比如Java中的volatile和C++11的atomic——可能只是很小的性能代价。这里不再深入说了。本篇文章,我将集中关注编译器memory reordering对常规的non-consequential-consistent类型的影响。

Compiler Instruction Reordering

如你所知,编译器的职责就是将源代码转换为CPU可以执行的机器码,在转换过程中,编译器有许多自由空间可以操作。
compiler-reordering
其中一种自由就是指令重排序——再一次,只有单线程程序的行为不会修改。这种指令重排序只是在开启了编译优化时才生效。考虑下面的函数:

int A, B;
void foo() {
    A = B + 1;
    B = 0;
}

如果我们在关闭编译优化的情况下使用GCC4.6.1编译这个函数,它会生成如下的机器码,对全局变量B的store刚好在对A的store之后,就像在源代码中那样。

$ gcc -S -masm=intel foo.c
$ cat foo.s
        ...
        mov     eax, DWORD PTR _B  (redo this at home...)
        add     eax, 1
        mov     DWORD PTR _A, eax
        mov     DWORD PTR _B, 0
        ...

注意上面的mov DWORD PTR B, 0的位置,这一语句是对B的赋值。然后开启-O2优化再编译一次,对比看看:

$ gcc -O2 -S -masm=intel foo.c
$ cat foo.s
        ...
        mov     eax, DWORD PTR B
        mov     DWORD PTR B, 0
        add     eax, 1
        mov     DWORD PTR A, eax
        ...

这一次,编译器自由发挥了,将对B的store重新排序到了对A store的前面。有何不可呢?这并没有破坏memory reordering的规则:一个单线程执行的程序永远不知道这种差异。

在另一方面,这样的编译器reordering会使lock-free代码产生问题。下面就是一个被广泛引用的例子,一个共享flag用来表示另外的一些共享数据是否ready:

int Value;
int IsPublished = 0;

void sendValue(int x) {
    Value = x;
    IsPublished = 1;
}

想象一下,如果编译器将对IsPublished的store操作重排到对value的store操作之前,会发生什么。即使在单核系统上,我们也会遇到一个问题:一个线程可能正好在执行这两个store操作之间被操作系统抢占,其它线程将会相信value已经更新了,而其实并没有。

当然,编译器可能不会重排这些操作,生成的机器码是lock-free的操作,并且可以在任何具有强内存模型的多核CPU上运行良好(strong memory model,后面会详解),比如x86/64,或者单核CPU。这种情况下,其实是运气成分。不用说,我们最好能认识到可能对共享变量的memory reordering,并且强制保证正确的顺序。

Explicit Compiler Barriers

阻止编译器重排序的最小手段就是使用特别的指令,称之为compiler barrier(编译器栅栏),前面已经提到过。下面就是一个GCC中的full compiler barrier,在Visual C++中,_ReadWriteBarrier具有相同的效果。

int A, B;

void foo() {
    A = B + 1;
    asm volatile("" ::: "memory");
    B = 0;
}

加上了这个barrier,然后再打开编译器优化,内存store指令的顺序将不会被编译器重排。

$ gcc -O2 -S -masm=intel foo.c
$ cat foo.s
        ...
        mov     eax, DWORD PTR _B
        add     eax, 1
        mov     DWORD PTR _A, eax
        mov     DWORD PTR _B, 0
        ...

类似的,如果你想保证前面的sendMessage正确工作,并且我们只关注单核系统,我们就可以使用compiler barrier。不仅发送操作需要compiler barrier来防止store操作的reordering,接收端同样需要。

#define COMPILER_BARRIER() asm volatile("" ::: "memory")

int Value;
int IsPublished = 0;

void sendValue(int x) {
    Value = x;
    COMPILER_BARRIER();   // prevent reordering of stores
    IsPublished = 1;
}

int tryRecvValue() {
    if (IsPublished) {
        COMPILER_BARRIER(); // prevent reordering of loads
        return Value;
    }
    return -1;  // or some other value to mean not yet received
}

就像我提到的,compiler barrier对于防止单核系统的memory reordering是足够的。但是在当前的时代,多核系统是很普遍的。如果要确保我们的代码在多核环境下按照期望的顺序执行,那么仅仅只有compiler barrier是不够的,我们或者需要一个CPU fence,或者任何具有运行时memory barrier的操作。这就是下一篇文章了。

Linux内核以宏的方式暴露了集中CPU fence指令,比如smb_rmb,并且这些宏在单核系统下编译时会退化为compiler barrier。

Implied Compiler Barriers

还有其他的方式可以阻止编译器重排序,确实,刚才提到的CPU fence就可以作为compiler barrier。这里是PowerPC上的fence命令,在GCC中是一个宏:

#define RELEASE_FENCE() asm volatile("lwsync" ::: "memory")

无论我们在代码的任何地方加上RELEASE_FENCE宏,除了阻止compiler reordering,还有processor reordering。比如它可以使我们的sendValue函数安全的运行在多核环境下:

void sendValue(int x) {
    Value = x;
    RELEASE_FENCE();
    IsPublished = 1;
}

在新的C++11 atomic标准库,每一个non-relaxed atomic操作都可以作为compiler barrier。

int Value;
std::atomic<int> IsPublished(0);

void sendValue(int x) {
    Value = x;
    // <-- reordering is prevented here!
    IsPublished.store(1, std::memory_order_release);
}

如何所期望的那样,每一个包含compiler barrier的函数同样也是一个compiler barrier,即使是inline函数(然而微软的文档建议在早期的Visual C++编译器中可能并非如此!!!)。

void doSomeStuff(Foo* foo) {
    foo->bar = 5;
    sendValue(123);   // prevents reordering of neighboring assignments
    foo->bar2 = foo->bar;
}

实际上不管函数本身有没有compiler barrier,大多数的函数调用都可以作为memory barrier,除了inline函数,使用了pure属性的函数声明,以及使用了link-time代码生成的情况。其它情况下,调用外部函数甚至是比compiler barrier更强的barrier,因为编译器不知道函数是否有其它影响(side effects)。对于被函数潜在可见的内存,它必须忘记任何有关的假设。

仔细想想,这很有道理。在上面的代码片段中,假设我们的sendValue实现是在一个外部库中。Compiler怎么会知道sendValue并不依赖foo->bar呢?它不会知道。因此,为了遵守memory reordering规则,它必须不能重排sendValue函数调用周围的所有内存操作。类似的,在调用完成后,它必须从内存重新load foo->bar,而不能假设它还是5,即使优化是打开的。

$ gcc -O2 -S -masm=intel dosomestuff.c
$ cat dosomestuff.s
        ...
        mov    ebx, DWORD PTR [esp+32]
        mov    DWORD PTR [ebx], 5            // Store 5 to foo->bar
        mov    DWORD PTR [esp], 123
        call    sendValue                     // Call sendValue
        mov    eax, DWORD PTR [ebx]          // Load fresh value from foo->bar
        mov    DWORD PTR [ebx+4], eax
        ...

正如你所看到的,有很多情况下编译器指令reordering都是被禁止的,甚至编译器必须重新从内存中reload一些值。我相信,人们一直以来之所以说在C中正确编写多线程程序时volatile并不是必要的,和这些隐藏的规则有很大的关系。
——>spark注:这里可能不完全正确,结合Meyers在关于DCLP的文章,有些编译器可以利用过程间分析来发现你对temp懂得小脑筋,再一次优化掉temp。还有一些编译环境会采用链接时内联的代码优化。
也就是说编译器对于本地函数可能有优化的手段,导致实际生产的memory ordering与你的期望不符。
<——over

Out-Of-Thin-Air Stores

(spark:out-of-thin-air,无中生有的,可见这种编译器技巧有多么的坑人!)
指令重排会导致lock-free编程变得很tricky?在C++11标准化之前,技术上没有能阻止编译器通向更加糟糕的窍门的技巧。特别的,编译器可以自由的引入对共享内存的store操作,如果目前还没有。这是一个极度简化的例子,灵感来自于Hans Boehm在多篇文章中提供的例子。

int A, B;

void foo() {
    if (A) B++;
}

尽管实际中很不常见,并没有什么能阻止编译器在检查A之前将B提升到一个register,从而生成和下面等价的机器码:

void foo() {
    register int r = B;  // Promote B to a register before checking A.
    if (A)  r++;
    B = r;    // Surprise! A new memory store where there previously was none.
}

再一次,依然遵循了memory reordering的规则。一个单线程运行的程序依然不会察觉。但是在多线程环境下,这个函数可能会将其它线程对B的任何并发修改清除掉——即使当A为0的时候,而原始代码不会这样。这类很晦涩的技术上并非不可能的情况,正是人们说C++不支持线程的一部分原因,即使我们已经愉快的使用C/C++写了几十年的多线程和lock-free的代码。

我不知道是否有人在实际中踩过这种“out-of-thin-air” stores的大坑(I don’t know anyone who ever fell victim to such “out-of-thin-air” stores in practice)。可能正好因为我们趋向于写的lock-free代码,并没有大量的优化对应这种模式。如果你遇到过,希望能在评论中让我知道。

无论如何,新的C++11标准明确禁止了编译器的这种行为,以防止它可能引入的竞争。在最新的C++11草稿的1.10.22节:
Compiler transformations that introduce assignments to a potentially shared memory location that would not be modified by the abstract machine are generally precluded by this standard.
这句话的大意是说,对不会被抽象机器修改的潜在共享内存地址,编译器不能引入赋值操作。这样,上面的那种“无中生有”类型的store优化就被禁止了,因为它引入了对r的赋值,而实际上r是不会被抽象机器修改的内存(编译器自己引入的)。

Why Compiler Reordering?

就像我在开始时指出的,编译器修改内存指令的顺序的原因和processor是一样的——性能优化。这些优化是现代CPU复杂性的直接后果。
后面回顾了CPU的发展历程,以及编译优化的情况。不翻译了。

后注:还真有人在评论中提到了“out-of-thin-air”的例子:
http://www.airs.com/blog/archives/79,Linux kernel和gcc的开发者都抱怨过这种问题。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值