17.7 内存屏障
内存屏障是一种特殊的指令或语句,能够对重排行为进行强约束。如 16 章所述,编译器和硬件有很多在平均情况下提升性能的小手段,包括重排,延迟内存操作,投机加载和分支预测,寄存器缓存变量等等。控制这些行为对于确保代码的执行顺序非常重要,因为其它线程的逻辑依赖于此。
本节会介绍不同类型的内存屏障,让我们对 Intel 64 平台上可能的实现有一个大致了解。
下面是一个内存屏障的例子,这个例子是一条 GCC 伪指令,能够防止编译器重排:
asm volatile("" ::: "memory")
这条汇编伪指令用来直接将汇编代码嵌入到 C 程序中。volatile 关键字组合上 "memory" 参数表示当前这段执行读、写的汇编代码(这里是空的)不能够被优化掉或者被移动到别处。这样会使编译器被强制将所有操作都在内存中执行(e.g. 将寄存器中缓存的局部变量存储下来)。这样做不能阻止处理器执行越过该语句的投机读取,所以对于处理器来说,这并不是一个内存屏障。
显而易见,编译器和 CPU 内存屏障由于会阻止优化,所以成本都比较高。这也是为什么我们不想在每条指令之后都使用内存屏障。
内存屏障有几种类型。我们会讲在 Linux 内核问题中定义过的那些类型,不过这已经能够满足大部分的场景需求了。
1写内存屏障
该屏障会保证所有屏障前的 store 操作都发生在屏障后的 store 操作之前。
GCC 使用 asm volatile("":::"memory") 作为一个通用的内存屏障。Intel 64 使用 sfence 指令。
2读内存屏障
类似的,该屏障保证屏障前的 load 操作发生在屏障后的 load 操作之前。是一种数据依赖屏障的较强形式。
GCC 用 asm volatile(""::: "memory") 作为通用内存屏障。Intel 64 使用 lfence 指令。
3.数据依赖屏障
数据依赖屏障主要考虑读依赖,在 17.4 中已有描述。可以认为是一种读内存屏障的较弱形式。彼此独立读或者任何形式的写是没有任何顺序保证的。
4.通用内存屏障
这是一个终极屏障,会强制该屏障提交前所有内存修改都被执行。且会阻止所有屏障之后的指令被重排到该屏障之前。
GCC 使用 asm volatile(""::: "memory") 作通用内存屏障。Intel 64 使用 mfence 指令。
5.acquire 操作
指一类操作,用 acquire 的语义组织起来。如果一种操作是从共享内存中读取,且不会与之后的读和写顺序交换,那么就认为其拥有这种特性。
换句话说很类似通用内存屏障,不过之后的代码都不会在这个屏障之前被执行。
6.release 操作
release 语义代表有这种属性的一类操作:如果一个操作对共享内存进行了写操作,那么就保证不会与源代码之前的读或者写发生重排。
换句话说和通用内存屏障类似,只是允许 release 之前的操作被重排。
acquire 和 release 操作都是某种形式的单向屏障。下面是一个被 GCC 内联的,单条汇编命令 mfence 例子:
asm ("mfence" )
将其与编译器汇编进行组合,我们既可以阻止编译器重排,又能够得到一个完整的内存屏障。
asm volatile("mfence" ::: "memory")
当前翻译单元的任意不可达函数调用都是一个编译器内存屏障。