一、内存栅栏
内存栅栏,Memory Barrier。栅栏是干什么的?作为一种界限拦住不应该过去的东西。内存栅栏也是这个意思,就是拦住操作,让相关的数据处理严格按照设定的顺序来。C++11中引入了显式的std::atomic_thread_fence内存栅栏,就是为了达到这个目的。需要说明的是,内存栅栏并不直接操作数据,只是通过内存序的建立来保证数据操作的完整准确性。
不同的内存序的处理情况下,内存栅栏付出的成本也有所不同。所以这就对应用内存栅栏的开发者提出了一个较强的能力需求。既要明白实际场景的功能限制,又要匹配正确的内存栅栏的内存序的设置。所以,内存栅栏不是一种高级的用法,它属于一种低级的同步原语,它对开发者对内存模型的理解有着较高的要求。
二、内存栅栏产生的背景
在前面的分析中,已经明白了一些编译和运行方面乱序的基础内容以及多CPU和缓存与CPU指令运行的关系等等。同时对缓存一致性协议(MESI)也有所了解,这些内容都可以在前面的相关文章中进行查阅。有了这些技术作为基础,再分析内存栅栏产生的背景就很容易理解了。首先,编译器对代码的优化,往往可能产生非预期指令的执行顺序;其次,同样CPU的流水线指令操作也可能产生同样的问题;最后,随着硬件技术的发展,多核或多CPU的出现以及相关缓存的映射处理,也有可能导致并发操作的不一致性。
有没有发现,其实这些操作都是与内存打交道的,也就是说,不管你是编译器乱搞事情还是CPU乱搞事情,最终都得落到内存上。这就好说了,在内存中建立一个“堤坝”,不达到目的不让操作,这就是内存栅栏产生的背景。
内存栅栏提供了两个重要的功能即首先保证了CPU对内存栅栏前后的指令顺序都是(开发者设置的)正确且外部可见的;其次,保证了内存数据的可见性(即可同步到所有CPU缓存)。当然这也意味着它实现了读写间的同步,如果对Java有一定了解的话,可以参考一下happens-before。
三、底层机制
内存栅栏的底层机制,其实是就是一组指令。通过上面的分析可以知道,编译器和硬件都可以影响到内存的控制访问。所以,其底层的实现机制主要分为两层:
- 编译器
在编译层面上使用内存栅栏,可以显式的通知编译器,不要将指定的内存操作进行重排序,让其保持在内存栅栏指定的一侧。以X86平台常见的内存栅栏处理包括:
relaxed:这个和不用内存栅栏没有多大区别,所以其不对性能产生任何影响。所以也没啥应用的地方
acquire:确保内存栅栏后的读操作不会被重排到栅栏之前。其对性能的影响较低,一般在读同步时使用
release:确保内存栅栏前的写操作不会被重排到栅栏后。其对性能影响较低,一般用在写操作同步中
acq_rel:可以简单理解为上面的读写两种情况。其对性能影响较低,一般用在RMW操作(Read-Modify-Write,读-修改-写)中
seq_cst:顺序一致性,保证全局的操作串行。其对性能影响很高(严格串行化),一般用在全局同步中
注意cppreference上的一句话“On x86 (including x86-64), atomic_thread_fence functions issue no CPU instructions and only affect compile-time code motion, except for std::atomic_thread_fence(std::memory_order_seq_cst).”
- 硬件
在X86平台上,内存序的要求比较严格,所以很多开发者往往在开发过程中感觉不到内存序的影响。而在ARM平台上开发者就很可能感觉到这种“爱”的力量 。也就是说,不同的平台上,内存模型是不一致的。这就导致了其对内存栅栏的处理机制有所不同。
X86平台使用的TSO (Total Store Order) 模型,其只有在Store-Load的情况下才可能乱序,其指令包括mfence、lfence和sfence
ARM平台使用的弱内存模型,所有的内存序操作行为都有可能重排,其指令包括DMB、DSB和ISB。在后期的ARM中提供了增强的相关指令
其它的PowerPC使用提更弱的内存模型而RISC-V使用的灵活内存模型,接触的相对较少就不再说明了,有兴趣可以查看相关的资料 。
需要说明的是,内存栅栏只对当前线程的内存操作序施加影响,它只是建立一种设置的内存序的约束控制;所以要想实现线程间同步需要配合原子变量来实现。这也是为什么在内存栅栏应用中总是看到原子变量的身影的原因。而内存栅栏如果配合非原子变量,则无法建立正确的happens-before,导致数据操作的问题。
特别需要注意的是,栅栏和带内存序的原子操作,功能基本是一样的。可以理解为内存栅栏应用上更加灵活而已。
四、问题分析
在实际的应用中,内存栅栏往往让一些初步应用者踩入一些小坑,最常见的就是内存栅栏设置的位置不对:
value = 100;
//std::atomic_thread_fence(std::memory_order_release); // 正确
flag.store(1, std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_release); // 错误
再有就是没有正确的配对(即):
// 错误:没有保证release和 acquire配对
// 线程1
v = 100;
std::atomic_thread_fence(std::memory_order_release);
flag = true; // 缺少原子操作
//flag.store(true, std::memory_order_relaxed);//应该使用这个
// 线程2
while (!flag) {} // 缺少原子读取
//while (!flag.load(std::memory_order_relaxed)) {//Waiting}//应该原子读
//std::atomic_thread_fence(std::memory_order_acquire);//应该有内存栅栏
// 无acquire内存栅栏
int value = v; // 不保证看到100
那么怎么配对呢?就需要看前面刚刚分析的内存栅栏处理的原则,实际需求是在哪一方要读哪一方要写或者说要RMW,再根据说明的情况(如acquire在读同步中使用),根据情况设置即可。一般都可以设置为store设置release而load设置为acquire。
五、具体应用
下面看一个简单的例子:
class SingletonDoubleCheck {
private:
static std::atomic<SingletonDoubleCheck*> ins_;
static std::mutex mt_;
public:
static SingletonDoubleCheck* createInstance() {
SingletonDoubleCheck* insDC = ins_.load(std::memory_order_acquire);
if (insDC == nullptr) {
std::lock_guard<std::mutex> lock(mt_);
insDC = ins_.load(std::memory_order_relaxed);
if (insDC == nullptr) {
insDC = new SingletonDoubleCheck();
std::atomic_thread_fence(std::memory_order_release);
ins_.store(insDC, std::memory_order_relaxed);
}
}
return insDC;
}
};
上面的代码是一种利用内存栅栏对Double check的优化,也是一种很常见的延迟加载的方法。
六、总结
其实很多技术用着用着就到了底层,在不断的刨根问底的情况下,不得不追溯到最底层的基础约束上。所以为什么技术既是灵活的,又是刻板就是这个原因。大多的技术出现,往往都需要一个积累的过程,而这个过程往往屏蔽了后来者对技术出现的了解,也就成为了一种形而上的掌握。


1657

被折叠的 条评论
为什么被折叠?



