go内存模型

重排???

https://blog.csdn.net/FJDJFKDJFKDJFKD/article/details/113179262

------------------------------

编译时的内存序重排

注:Memory Ordering at Compile Time,译文内容有删减。

在源码编写和最终可执行文件在处理器上执行期间,代码中的内存交互行为可能根据相应的规则被重排序。内存重排序可以在编译阶段和运行时(处理器)发生。采用这样做的原因是为了更好的运行性能。

编译器开发人员和 CPU 供应商普遍遵循的内存重排序的基本规则如下:

内存重排序不应当修改单线程程序的行为。

基于上面的规则,内存重排序对编程人员写单线程程序时几乎不察觉。因为采用了各种同步互斥机制 (mutex, semaphore, etc),在多线程程序中也基本不感知。只有在无锁化编程中才会明显地体会到内存重排序的影响。无锁化编程中内存在各个线程间共享而没有任何互斥手段 (mutual exclusion) 加以保护。

编译器指令重排序 Compiler Instruction Reordering

众所周知,编译器的工作就是讲人类易读的源代码转换成机器可识别的机器码。在这个转换期间,编译器有很多自由度进行代码调整以优化性能等。

在遵循上面提到的不改变单线程程序的行为规则下,指令重排序通常在优化选项开启的情况下才会进行。参见下面的代码段:

int A, B;

void foo()
{
    A = B + 1;
    B = 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

如果我们不开启优化选项,gcc 7.3.0 将生成如下的代码。对 B 的赋值在对 A 的赋值之后,这和我们在源代码中看到的一样。

$ gcc -S -masm=intel foo.c
$ cat foo.s
        ...
        mov     eax, DWORD PTR B[rip]
        add     eax, 1
        mov     DWORD PTR A[rip], eax
        mov     DWORD PTR B[rip], 0
        ...
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

如果开启 -O2 优化选项,可以看到编译器把对 B 的赋值操作到对 A 赋值之前了,当然上面提到的规则并没有被破坏,因为单线程程序并不感知这里的区别。

$ gcc -O2 -S -masm=intel foo.c
$ cat foo.s
        ...
        mov     eax, DWORD PTR B[rip]
        mov     DWORD PTR B[rip], 0
        add     eax, 1
        mov     DWORD PTR A[rip], eax
        ...
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

另一方面,编译器的这种行为在无锁化编程中将可能导致问题。下面是一个被经常引用的例子,一个共享的标识变量用来表征某些其他的共享变量被更新。

int Value;
int IsPublished = 0;

void sendValue(int x)
{
    Value = x;
    IsPublished = 1;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

想象一下假如 IsPublished 被提前到 Value 被更新将会导致什么。即使是在单处理器系统上,这也会导致问题:该线程很可能在上面两个赋值间被其他线程抢占,这使得其他线程感知到 Value 已经被更新,但实际上并没有更新。

当然编译器可能不会做上面这么极端的重排序,最终无锁化编程的代码在强内存序 CPU 的多核系统或者任意 CPU 类型的单处理器系统上也不会有问题。其他场景的话我们只能自求多福。不用多说,更好的做法是认识到对共享变量进行内存重新排序的可能性,并确保强制执行正确的排序。

显式的编译器屏障 Explicit Compiler Barriers

阻止编译器重排序的最简单的方法是指定一个特殊的标志,即编译器屏障。

int A, B;

void foo()
{
    A = B + 1;
    asm volatile("" ::: "memory");
    B = 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

有了编译器屏障,我们可以放心使能编译优化选项,得到期望的不重排序效果。

$ gcc -O2 -S -masm=intel foo.c
$ cat foo.s
        ...
        mov     eax, DWORD PTR B[rip]
        add     eax, 1
        mov     DWORD PTR A[rip], eax
        mov     DWORD PTR B[rip], 0
        ...
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

编译器屏障可以保证单处理器系统的内存重排序问题,但是目前多核处理器已是主流。各种设备都在堆核心的路上高歌猛进。如果我们希望代码在各种 CPU 架构的多处理器系统上也能正常工作,光有编译器屏障是不够的。我们还需要处理器屏障指令 (CPU fence instruction)。

Linux 内核通过宏提供了多种处理器的屏障指令,如 smp_rmb,这些宏会在目标架构下展开成不同的底层屏障指令。

隐式的编译器屏障 Implied Compiler Barriers

还有其他方式阻止编译器重排序,前文提到的处理器屏障指令行为和编译器屏障类似。下面是一个 PowerPC 的处理器屏障指令。

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

代码中可以插入上面的宏来阻止编译器重排序,并且还提供了一些处理器重排序的语义。

在 C++11 原子库标准中,每个非 relaxed 的原子操作也均表现出编译器屏障的作用。

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

void sendValue(int x)
{
    Value = x;
    // <-- reordering is prevented here!
    IsPublished.store(1, std::memory_order_release);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

正如你所想的那样,每个包含编译器屏障的函数本身也表现出编译器屏障的作用,即使这个函数被内联。

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

实际上,大多数函数调用本身就有编译器屏障的作用,与它们是否含有编译器屏障指令无关,除了内联函数、函数声明带有 pure 属性或者采用了链接时代码生成技术。除去上面的例外场景,一个外部函数(extern function)调用本身甚至强于编译器屏障,因为此时编译器无法确定函数的副作用,它只能忽略对潜在的对被调函数可见的内存所做的所有假设。

在上面的代码片段中,假设 sendValue 实现在外部的库中。编译器无法确定 sendValue 是否依赖于 foo->bar,也无法确定它不会修改 foo->bar。因此编译器必须遵循源码中指定的内存操作顺序,它不能对 sendValue 周围的操作进行重排序,并且还得从内存中重新加载 foo->bar 赋值给 foo->bar2,而不是假设它的值还是 5,即使此时优化选项已经开启。

$ gcc -O2 -S -masm=intel dosomestuff.c
$ cat dosomestuff.s
        ...
        mov     rbx, rdi
        mov     DWORD PTR [rdi], 5
        mov     edi, 123
        call    sendValue
        mov     eax, DWORD PTR [rbx]
        mov     DWORD PTR [rbx+4], eax
        ...
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

无中生有的 store 指令 Out-Of-Thin-Air Stores

在 C++11 标准之前,实际上技术上没有什么方式阻止编译器采用更激进的小把戏。特别的,编译器甚至可以产生源码中不存在的 store 指令。下面是一个非常简单的例子:

int A, B;

void foo()
{
    if (A) {
        B++;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

虽然实际情况中很少出现,但是并不能阻止编译器在检查 A 的值之前把 B 提升为一个寄存器变量,最终的代码等价于:

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.
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

再一次的,单线程语义未变的规则得到遵守。但是在多线程环境下,此时 foo 可以覆盖掉其他线程并发地对 B 进行的操作,即使 A 为 0。因为多余的 store 指令 B = r 将覆盖掉其他线程对 B 的操作。但是源代码中并无此语义。其他的类似案例可参见 无中生有案例

为什么编译器要重排序 Why Compiler Reordering?

编译器对内存交互进行重排序的原因和处理器进行执行流重排序出于同样的原因——性能优化。这种优化是现代 CPU 复杂性的直接结果。

处理器的发展日新月异,各种新技术引入到现代 CPU 的设计中,如流水线(pipeling),内存预取(memory prefetch),以及最近几年的多核处理器。这些特性的引入使得指令顺序的调整将造成显著的运行性能差异。

原文地址:https://www.cnblogs.com/oxspirt/p/14765349.html