内存屏障与内存排序


引言

void Thread0() {
    for (size_t i = 0; i < ARR_LEN; ++i)
        a[i] = 0;
}

void Thread1() {
    for (size_t i = 0; i < ARR_LEN; ++i)
        a[i] = i;
}

void Thread2() {
    for (size_t i = 0; i < ARR_LEN; ++i)
        printf("%d ", a[i]);
}

设想一下,如果依次执行这三个线程,答案必然是形如 0 1 2 3 4 5 ... 的一串数字。但是在程序运行中,却有概率得到如 0 0 0 0 0 ... 全为 0 的结果。模糊的说,这是由于编译器优化与 CPU 乱序执行导致的。为了解决这个问题,我们引入了内存屏障。

内存屏障

内存屏障 - Wikipedia

内存乱序指的是内存操作出现乱序,CPU缓存、编译器优化、处理器指令优化等都会改变内存顺序,造成内存乱序。

学习内存顺序容易陷入了一个误区,因为内存顺序是和CPU架构、编译器息息相关的,想要去深入理解CPU缓存怎么导致内存乱序的,编译器优化和处理器指令又是怎么导致内存乱序的,很容易陷入一个又一个填不了的坑。要去了解各种编译器优化技术、了解各种CPU的指令集,甚至连ARM的v7和v8区别都去看了一下。

鲁迅说过,学海无涯,回头是岸。内存顺序或者说内存模型其实是语言层面的东西,所以可以直接从语言层面去理解,所谓内存乱序,在语言层面的表现就是虽然你写了A、B、C三行代码,但是最后在线程中执行顺序可能是CBA,而另外一个线程,观察该线程得到的执行顺序可能是ACB。这些是由于CPU缓存、编译器优化、处理器指令优化等共同造成的。

上述各种优化导致的内存乱序并不是随意地乱序,是有底线的,这个底线就是单线程场景下,优化后程序的执行结果要和优化前保持一致,即让写代码的人感觉是没有优化存在的,代码就是按照他写的顺序执行的。

现代CPU基本上都支持流水线(pipeline)上的指令重排,且编译器也会把一些代码在编译期重排,已减少对内存的访问和一些无效的计算,这就意味着我们的代码在真正的载入内存和执行的时候可能和我们写下它的时候长的不太一样。

内存排序是指CPU访问主存时的顺序。可以是编译器在编译时产生,也可以是CPU在运行时产生。反映了内存操作重排序,乱序执行,从而充分利用不同内存的总线带宽。

现代处理器大都是乱序执行。因此需要内存屏障以确保多线程的同步。

x86 Linux 中的内存屏障

#ifdef CONFIG_X86_32
#define mb() asm volatile(ALTERNATIVE("lock; addl $0,-4(%%esp)", "mfence", \
				      X86_FEATURE_XMM2) ::: "memory", "cc")
#define rmb() asm volatile(ALTERNATIVE("lock; addl $0,-4(%%esp)", "lfence", \
				       X86_FEATURE_XMM2) ::: "memory", "cc")
#define wmb() asm volatile(ALTERNATIVE("lock; addl $0,-4(%%esp)", "sfence", \
				       X86_FEATURE_XMM2) ::: "memory", "cc")
#else
#define __mb()	asm volatile("mfence":::"memory")
#define __rmb()	asm volatile("lfence":::"memory")
#define __wmb()	asm volatile("sfence" ::: "memory")
#endif

https://elixir.bootlin.com/linux/v6.1.7/source/arch/x86/include/asm/barrier.h

https://github.com/torvalds/linux/blob/master/arch/x86/include/asm/barrier.h

C++ 内存序

C++ 六种内存序其实就指定了四种一致性模型,即:

  • sequentially-consistent
  • acquire-release
  • consume-release
  • relaxed

分别通过施加不同的内存序来实现不同的一致性模型,且最弱的一致性模型为硬件提供的模型,即使用的一致性模型 = min(内存序指定,硬件指定)。举个例子X86本身就是acquire-release模型,再怎么写其都不可能出现relaxed的语义。

C++ 关键字

C++ 中的 volatile 关键字,std::atomic 变量及手动插入内存屏障指令(Memory Barrier)均是为了避免内存访问过程中出现一些不符合预期的行为。这三者的作用有些相似之处,不过显然它们并不相同,本文就将对这三者的应用场景做一总结。

这三者应用场景的区别可以用一张表来概括:

volatile Memory Barrier atomic
抑制编译器重排 Yes Yes Yes
抑制编译器优化 Yes No Yes
抑制 CPU 乱序 No Yes Yes
保证访问原子性 No No Yes

下面来具体看一下每一条。

抑制编译器重排

所谓编译器重排,这里是指编译器在生成目标代码的过程中交换没有依赖关系的内存访问顺序的行为。

比如以下代码:

*p_a = a;
b = *p_b;

编译器不保证在最终生成的汇编代码中对 p_a 内存的写入在对 p_b 内存的读取之前。

如果这个顺序是有意义的,就需要用一些手段来保证编译器不会进行错误的优化。具体来说可以通过以下三种方式来实现:

  • 把对应的变量声明为 volatile 的,C++ 标准保证对 volatile 变量间的访问编译器不会进行重排,不过仅仅是 volatile 变量之间, volatile 变量和其他变量间还是有可能会重排的;
  • 在需要的地方手动添加合适的 Memory Barrier 指令,Memory Barrier 指令的语义保证了编译器不会进行错误的重排操作;
  • 把对应变量声明为 atomic 的, 与 volatile 类似,C++ 标准也保证 atomic 变量间的访问编译器不会进行重排。不过 C++ 中不存在所谓的 “atomic pointer” 这种东西,如果需要对某个确定的地址进行 atomic 操作,需要靠一些技巧性的手段来实现,比如在那个地址上进行 placement new 操作强制生成一个 atomic 等;

抑制编译器优化

此处的编译器优化特指编译器不生成其认为无意义的内存访问代码的优化行为,比如如下代码:

void f() {
  int a = 0;
  for (int i = 0; i < 1000; ++i) {
    a += i;
  }
}

在较高优化级别下对变量 a 的内存访问基本都会被优化掉,f() 生成的汇编代码和一个空函数基本差不多。然而如果对 a 循环若干次的内存访问是有意义的,则需要做一些修改来抑制编译器的此优化行为。可以把对应变量声明为 volatileatomic 的来实现此目的,C++ 标准保证对 volatileatomic 内存的访问肯定会发生,不会被优化掉。

不过需要注意的是,这时候手动添加内存屏障指令是没有意义的,在上述代码的 for 循环中加入 mfence 指令后,仅仅是让循环没有被优化掉,然而每次循环中对变量 a 的赋值依然会被优化掉,结果就是连续执行了 1000 次 mfence

抑制 CPU 乱序

上面说到了编译器重排,那没有了编译器重排内存访问就会严格按照我们代码中的顺序执行了么?非也!现代 CPU 中的诸多特性均会影响这一行为。对于不同架构的 CPU 来说,其保证的内存存储模型是不一样的,比如 x86_64 就是所谓的 TSO(完全存储定序)模型,而很多 ARM 则是 RMO(宽松存储模型)。再加上多核间 Cache 一致性问题,多线程编程时会面临更多的挑战。

为了解决这些问题,从根本上来说只有通过插入所谓的 Memory Barrier 内存屏障指令来解决,这些指令会使得 CPU 保证特定的内存访问序及内存写入操作在多核间的可见性。然而由于不同处理器架构间的内存模型和具体 Memory Barrier 指令均不相同,需要在什么位置添加哪条指令并不具有通用性,因此 C++ 11 在此基础上做了一层抽象,引入了 atomic 类型及 Memory Order 的概念,有助于写出更通用的代码。从本质上看就是靠编译器来根据代码中指定的高层次 Memory Order 来自动选择是否需要插入特定处理器架构上低层次的内存屏障指令。

关于 Memory Order,内存模型,内存屏障等东西的原理和具体使用方法网上已经有很多写得不错的文章了,可以参考文末的几篇参考资料。

保证访问原子性

所谓访问原子性就是 Read,Write 操作是否存在中间状态,具体如何实现原子性的访问与处理器指令集有很大关系,如果处理器本身就支持某些原子操作指令,如 Atomic Store, Atomic Load,Atomic Fetch Add,Atomic Compare And Swap(CAS)等,那只需要在代码生成时选择合适的指令即可,否则需要依赖锁来实现。C++ 中提供的可移植通用方法就是 std::atomicvolatile 及 Memory Barrier 均与此完全无关。

AiO

从上面的比较中可以看出,volatileatomic 及 Memory Barrier 的适用范围还是比较好区分的。

  • 如果需要原子性的访问支持,只能选择 atomic
  • 如果仅仅只是需要保证内存访问不会被编译器优化掉,优先考虑 volatile
  • 如果需要保证 Memory Order,也优先考虑 atomic,只有当不需要保证原子性,而且很明确要在哪插入内存屏障时才考虑手动插入 Memory Barrier。

参考


文章作者: sfc9982
版权声明: 本博客所有文章除特別声明外,均采用 CC BY-NC-ND 4.0 许可协议。转载请注明来源 sfc9982 !
  目录