单例设计模式延伸-什么是Memory Barrier?
单例设计模式延伸-什么是Memory Barrier?
https://en.wikipedia.org/wiki/Memory_barrier
在Singleton单例设计模式的章节中,对Memory Barrier有过一定的介绍
存在Memory Barrier的原因是和”内存“有着直接的关系,这里面要介绍的内容会比之前
Singleton模式中介绍的部分要深入一些
在现代的CPU中,为了性能优化(优化发生在CPU和Complier两个阶段),会对内存的操作(loads and stores)顺序进行重排序(reordering of memory operations),这样就导致了乱序执行(out-of-order execution)
换一句话,代码的编写顺序(program order)和实际内存的访问顺序(order of memory operations),不一定是一致的。
在单线程的环境下,乱序执行(out of order execution)并不会影响结果,因为即便方法内的内存读写操作是out of order execution,但方法与方法的调用顺序是一致的,可以保证结果的正确性。
但在多线程环境下,多个线程共享同一块内存,且并发执行,那么out of order execution可能会影响程序的行为,产生无法预期的结果。
所以,很明显,多线程环境下,我们要去解决这种可能产生”无法预期“结果的情况。
在开始之前,先举一个例子说明,在多线程环境下,out of order execution,会引起哪些问题?
int _answer;
bool _complete;
void A()
{
_answer = 123;
_complete = true;
}
void B()
{
if (_complete)
{
Console.WriteLine(_answer);
}
}
如果方法A和方法B,在不同的线程上并行,B方法输出的结果_answer有没有可能是0?
答案是肯定的。
out of order execution可以是loads,也可以是stores,即读和写均可以是out of order execution.
A和B两个方法并行,执行A的时候,_answer=123;和_complete=true; 因为
out of order execution,可能_complete=true会先于_answer=123;执行。
这样,B方法,就有可能通过_compete为true的判断,而输出还未初始化的_answer,这在Java,C#这些语言中会输出int的默认值0,在C/C++中,没有默认值,则会输出不可预期的结果。
out of order execution只是在多线程环境下,会引起的问题之一。
还有一种情况来自于CPU和内存之间的通信,CPU的读写速度要远远高于内存,因为这种悬殊的差异,每次CPU读写都访问内存的话,效率会很慢,这是不现实的,所以,在CPU和内存之间,还有一层缓存(速度极快),通过缓存(L1,L2,L3)来提高数据的读写效率。
当CPU要读取一个变量的值时,会先从缓存cache中寻找,如果存在,则直接从cache获取,如果不存在,则发生cache miss,就会去主存读取,写入的操作也是一样的,也要经由缓存,并不是每次都将新的值,立即刷新到内存中。
这样就会导致在多线程环境下,你无法保证新的值,可以立即被其它的线程看到,因为线程之间是并行的。这也是为何未做任何处理的单例设计模式,实例会被创建多次的奇怪情况。
所以,多线程环境下,引出的两个问题:
1.乱序访问 out of order execution
2.内存可见性 memory visibility
为了解决多线程环境的副作用,我们需要添加Memory Barrier(内存栅栏),来避免CPU和编译器进行指令重排(reordering)。关闭out of order execution,并保证新的数据,总是可以被其它的线程可见。
在wikipedia中,Memory Barrier又叫membar, memory fence or fence instruction.
理解barrier或是fence,有助于我们更好的理解它的意图。
barrier译为障碍,fence 译为栅栏,围栏(如果想知道某些单词的常用解释,最准确的方法是,打开google 图片搜索:)
所以,他具有”限定,约束”的作用。
我们可以想象一下,地铁进站或者在路上开车,我们假设从A点到达B点,怎么合理的利用”系统资源“,可以让目标更快的到达终点?
其实在不限流,不管控的情况下,你会发现,一切的进行都是”乱序的,无序的“,开车有快有慢,开的快的可以并线超车,有的就开80迈,有的开100迈,有的甚至超速,地铁进站,有的人不紧不慢,你着急赶时间,你可以一路小跑着进去,这种乱序的,无序的,充分的利用了”系统资源“,在总体上,就可以更快的达到终点,也就提高了效率。
相反,如果大家都排着队走,一个跟着一个,浪费了系统的资源不说,速度也会大打折扣,但有的时候,这种”限流,管控“是必要的,比如我需要按着我想要的顺序去执行等等,这个不难理解,以现代的计算机性能来看,这种限制带来的性能差异变化几乎是无感知的。
Memory Barrier就是一条CPU指令,他可以确保操作执行的顺序和数据的可见性。我们在指定的代码处,插入Memory Barrier(fence)
"相当于告诉 CPU 和Complier先于这个命令的必须”先“执行,后于这个命令的必须”后“执行。
内存屏障也会强制更新一次不同CPU的缓存,会将屏障前的写入操作,刷到到缓存中,这样试图读取该数据的线程,会得到新值。确保了同步。"
添加了Memory Barrier的代码:
int _answer;
bool _complete;
void A()
{
_answer = 123;
Thread.MemoryBarrier(); // 屏障 1
_complete = true;
Thread.MemoryBarrier(); // 屏障 2
}
void B()
{
Thread.MemoryBarrier(); // 屏障 3
if (_complete)
{
Thread.MemoryBarrier(); // 屏障 4
Console.WriteLine (_answer);
}
}
屏障 1 和 4 可以使这个例子不会打印 “ 0 “。屏障 2 和 3 提供了一个“最新(freshness)”保证:它们确保如果B在A后运行,读取_complete的值会是true。(乱序访问和内存不可见性,均得到了解决)
在实际的应用过程中发现,Thread.MemoryBarrier()在使用起来还是比较繁琐的,不够方便,那么,有没有更为简单的实现?
使用lock代码块,锁住需要”约束“的代码。lock隐式的执行了Memory Barrier(fence)
lock可以保证,当前只有一个线程能够进入到代码块,并且代码块中的会按program order的顺序执行,对内存的Loads and stores操作都会及时的进行更新,保证始新的值始终都可以被其它的线程可见。
Memory Barrier(lock)会关闭CPU或编译器所做的性能优化,如果频繁的调用,会有性能损耗,这也是为什么会出现double checked locking和Lazy Initialization 两种效率更高的实现方式。
在Singleton一章中也有讨论过,如果你的需求不得不在Update中频繁的调用单例,为何不定成一个引用对象呢?
我会在Barrier的后面加上(fence),强调说明他们是一个概念:)
最近在看Tiny Mode方面的资料,TinyMode Scripting是基于ECS Pattern,这些Script之间的执行顺序默认也是无序的,但如果某些Script可以按照你希望的顺序执行,那么你需要进行”约束,限定“,这里的概念就叫fence,所以,Tiny Mode fence和上面讲到的Memory Barrier,本质是一样的。
到此为止,明天是清明节三天小假期,祝大家玩得好,休息好。
感谢您的阅读, 如文中有误,欢迎指正,共同提高
欢迎关注我的技术分享的微信公众号,Paddtoning帕丁顿熊,期待和您的交流