单例设计模式延伸-什么是Memory Barrier?

发表于2019-04-04
评论5 6.6k浏览

单例设计模式延伸-什么是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又叫membarmemory 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帕丁顿熊,期待和您的交流

 

iOIWBCwZkEenjYhorLAF.jpg
  • 允许他人重新传播作品,但他人重新传播时必须在所使用作品的正文开头的显著位置,注明用户的姓名、来源及其采用的知识共享协议,并与该作品在磨坊上的原发地址建立链接
  • 可对作品重新编排、修改、节选或者以作品为基础进行创作和发布
  • 可将作品进行商业性使用

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引