Memory Barriers Are Like Source Control Operations

来源:互联网 发布:无锡网络教育高起专 编辑:程序博客网 时间:2024/06/18 14:13

前言:第三篇,老外想象力挺丰富,和代码版本控制联系起来,确实可以帮助人比较直观的理解Memory Barrier。
JUL 10, 2012
http://preshing.com/20120710/memory-barriers-are-like-source-control-operations/

正文开始——>
如果你使用过版本控制工具,你就向着理解memory ordering又前进了一步,它是使用C/C++或者其它语言编写lock-free程序的一个重要考虑点。

前面写的Memory ordering at Compile Time,是memory ordering迷宫的第一部分。这篇是有关另一半的:运行时的memory ordering,有关processor自己。
就像编译器reordering,processor reordering对于单线程程序是不可见的,仅当使用了lock-free技术时才需要关注——也即是,线程之间在没有任何互斥锁的情况下操作共享内存时。然而不像编译器reordering,processor reordering仅在多核和多处理器系统下产生影响(经过前面的分析探索,目前我认同这个观点,在所有CPU应该都是一样的)。
通过使用任何用作memory barrier的指令,你可以保证在processor上得到正确的memory ordering。在某些情况下,这是唯一你需要直到的技术,因为当你使用这些指令时,编译器ordering也会自动的小心处理。用作memory barrier的指令包括(但不仅限于)如下的几个例子:

1 几个GCC中定义的汇编指令,比如PowerPC的 asm volatile(“lwsync” ::: “memory”)
2 任何Win32的Interlocked操作,除了Xbox;
3 C++11的atomic类型,比如load(std::memory_order_acquire)
4 POSIX mutex的操作,比如pthread_mutex_lock

就像很多操作都能用作memory barrier,有多重不同类型的memory barrier需要我们了解。确实,上面的指令并不是都产生同一类型的memory barrier——这又导致了我们在编写lock-free代码时遇到的另一个迷惑之地。为了在某种程度上澄清一下,我愿意奉上一些我发现对理解大部分memory barrier很有用的类比。

作为开始,想象一下一个典型的微机系统的体系结构。比如有两个core,每一个core都有自己的32KiB的L1 cache;还有一个1MiB的共享L2 cache。以及512MiB的主存。
这里写图片描述
一个微机系统有点像一群程序员使用奇怪的代码管理策略来合作一个项目。比如,上面的双核系统对应两个程序员的场景,我们称之为Larry和Sergey。
这里写图片描述

在右边,我们有一个共享的中央仓库——这代表了主存和L2共享缓存。Larry在他的电脑上有一个仓库的完全copy,Sergey也是——这形象的代表了每个core各自的L1 cache。同样的,每个电脑上还有一个暂存区,各自自己的记录和跟踪本地变量。这两个程序员坐在那里,兴奋地编辑着自己的工作copy和暂存区,并且根据他们看到的数据来决定下一步要做什么——就像在core上运行的thread一样。
回到代码控制策略中来,在这个类比中,这个代码控制策略是非常奇怪的。当Larray和Sergey修改各自的copy时,他们的修改会在后台不停的扩散给中央仓库,在完全随机的时间写向中央仓库,或者从中央仓库读取。当Larry编辑文件X时,它的修改将同步到中央仓库,但是却不会保证什么时候发生。可能很快,可能很慢。他可能继续编辑文件Y和Z,这些修改还可能早于X同步到中央仓库。这种行为下,向仓库的stores是重排的。

同样的,在Sergey的电脑,这些修改何时从仓库同步到本地也没有任何时间和次序上的保证。在这种行为下,从仓库的loads也是重排的。

现在,如果每个程序员工作在仓库完全不同的部分,那么他们之间不需要知道彼此。这就类似于两个独立的进程。
当两个程序员工作在仓库的同一个部分,事情就变得有意思了,回想前面有关全局变量X和Y的那个例子。
这里写图片描述

将X和Y想象成文件,它们存在于Larry的仓库copy,Sergey的仓库copy,以及中央仓库。Larry向自己的copy X写入1,同一时间Sergey向自己的copy Y写入1。如果在两个程序员各自查看自己copy的另外一个文件时,这些修改没有同步给仓库,那么它们得到的结果都是r1 = 0,并且r2 = 0。现在在版本控制这个场景中,看起来应该是很自然的结果。
这里写图片描述

(spark:为什么会这样呢,因为这里界定了,修改没有立刻同步给主存。这个时候读取另一个文件,意味着load重排序到了write之前。)

Types of Memory Barrier

幸运的是,Larry和Sergey并不是完全受这种在后台随机的,不可预测的同步支配。他们也有能力发起特殊的指令,称为fence指令,它们担当了memory barrier。对于这个类比,定义4种memory barrier就足够了,也就是4种不同的fence指令。每一种memory barrier都以它要阻止的memory reordering的类型来命名,比如#StoreLoad就是防止后面的load重排到store的前面。
这里写图片描述
如同Doug Lea所指出的,这4种策略可以很好的映射到真实CPU的指令——尽管并不是精确的。大多数时间里,一个CPU指令用作几种以上的barrier功能的组合,可能还有其它的作用。无论如何,当你理解了在版本控制的这4种memory barrier之后,再去理解CPU的许多指令就容易多了,以及几个高层语言的指令。

#LoadLoad

一个LoadLoad barrier有效的阻止了barrier之前的load和barrier之后的load之间的重排序。在我们的类比中,#LoadLoad fence指令本质上等同于从中央仓库的pull操作。想象一下:git pull, hg pull, p4 sync, svn up 或者cvs up,都对整个仓库起作用。如果和本地改动有冲突,我们简单的说它们被随机的解决了。
这里写图片描述
提醒你的是,#LoadLoad并不保证会拉取整个仓库的最新版本!它也可能拉取到一个相对旧的版本,只要这个版本至少和已经从中央仓库同步到本地电脑的版本一样新。

这看起来是一个很弱的保证,但是实际上这是一个防止看到旧数据的好方法。考虑经典的场景,Sergey检查一个共享标记拉看是否有数据被Larry提交了。如果flag是true,他就在读取数据之前发起一个#LoadLoad barrier:

if (IsPublished) {                 // Load and check shared flag    LOADLOAD_FENCE();              // Prevent reordering of loads    return Value;                  // Load published value}

显然,这个例子依赖于将IsPublished标记同步到Sergey的工作copy。它并不关心具体是怎么发生的。一旦观察到flag,他就发起一个#LoadLoad fence,来防止读到的value的数据比flag还要旧。

#StoreStore

一个StoreStore barrier有效的防止barrier之前的store和之后的store之间的重排序。在我们的类比中,#StoreStore fence指令对应于从中央仓库的push。想一下git push,hg push,p4 commit,svn commit或者cvs commit,都作用在整个仓库。
这里写图片描述
作为额外的一段分析,让我们假设#StoreStore指令并不是立刻生效的,它们将会以异步的方式延迟执行的。这样的话,即使Larry执行了#StoreStore,我们也不能对他的上一次修改何时对中央仓库可见做任何的假设。
这看起来也是一个弱假设,但是它依然可以有效的阻止Sergey读取到Larry发布的旧数据。回到上面的例子上来,Larry只需要将数据发布到共享内存,发起一个#StoreStore barrier,然后将共享flag设置为true:

Value = x;                         // Publish some dataSTORESTORE_FENCE();IsPublished = 1;                   // Set shared flag to indicate availability of data

再一次,我们依赖于IsPublished的值将Larry的copy传给Sergey。一旦Sergey发现IsPublished为true,他就可以确信他将能看到正确的Value。有意思的是,以这种方式工作时,Value不需要是atomic的,它可以是一个包含很多元素的大structure。

#LoadStore

不像#LoadLoad和#StoreStore,在版本控制操作中并没有和#LoadStore相对应的术语。理解#LoadStore的最佳方式就是以指令重排序的术语来看。
假设Larry有一个指令集需要执行,一些指令需要使他从自己的工作copy load数据到register,一些使他从register store数据到工作copy。Larry有能力打乱指令,不过仅是在特定的场景中。每次他遇到一个load,他就向后查找store,如果store和当前的load是完全不相关的,他就可以先跳过load,先执行store,然后在完成当前的load。在这种情况下,有关memory ordering的基本原则——永远不能修改单线程程序的行为——依然被遵守。

在实际的CPU上,在几个processor上有可能发生这样的指令重排序,如果说load发生了cache miss,而后面的store是cache hit的。但是在理解这个类比时,这些硬件细节并不重要。我们假设说Larry的工作很boring,有一些机会他可以做一些创造性的工作。他是否选择这样去做是完全不可预测的。幸运的是,阻止这种重排序的代价相对来说并不昂贵。当Larry每遇到一个#LoadStore barrier时,他就简单的停止重排序。

在我们的类比中,即使已经有了#LoadLoad和#StoreStore,Larry依然可以做这种LoadStore 的重排序。但是,在真实的CPU,用作#LoadStore barrier的指令至少也具有另外两种barrier的功能。

#StoreLoad

一个StoreLoad barrier确保了barrier之前的所有store都是对其它processor可见的,并且barrier之后的所有load都能读取到在barrier时可见的最新数据。换句话说,它有效的防止了barrier之后的所有loads和barrier之前的所有stores重排序。一个sequentially consistent的multi-processor会执行这种操作。

#StoreLoad是唯一的,它是唯一一种可以阻止前面例子中r1=r2=0这种结果的memory barrier。

如果你你一直关注我的blog,你可能在想:#StoreLoad和 #StoreStore + #LoadLoad有什么区别呢?毕竟,一个#StoreStore将变化推送给中央仓库,#LoadLoad从中央仓库拉取变化。然而,这两种barrier是不充分的,记住,push操作可能会被delay到好几个指令之后执行,而且pull操作可能不会从最新的version开始拉取。这也是为什么PowerPC的lwsync指令——它同时用作#LoadLoad,#LoadStore和StoreStore,但不是#StoreLoad——依然不能避免r1=r2=0这种结果。

在我们的类比中,一个#StoreLoad barrier可以这样完成:将所有的本地改变push到中央仓库,等待操作完成,然后从中央仓库将最新的版本pull到本地。在大多数processor上,相比用作其他类型barrier的指令,用作#StoreLoad barrier的指令通常代价更昂贵。
这里写图片描述
如果我们在操作中使用了#StoreLoad,这也不是什么大问题,我们得到了一个全内存fence——一次用作所有4中barrier。就像Doug Lea指出的,巧的是,在所有的当前processor上,每一个用作#StoreLoad barrier的指令都充当了全内存fence。

How Far Does This Analogy Get You?

就像我前面提到的,在memory ordering上,每种processor都有不同的习性。特别的,x86/64有一个强内存模型。它将memory reordering保持到最低。PowerPC和ARM有弱内存模型。幸运的是,本文描述的类比对应的是一个弱内存模型。如果你能从头看完,并且使用这里给出的fence指令保证正确的memory ordering,你就可以处理大多数的CPU了。

这个类比也很好的对应了C++11和C11中的虚拟机器(abstract machine)。因此当你使用这些语言的标准库编写lock-free代码时,如果将上面的类比记在心里,就能在各种平台上写出正确的代码。

在这个类比中,每一个程序员代表了在一个单独的core上运行的单个线程。在实际的操作系统中,线程在它们的生存周期中是在不同的core之间切换的,但是我们的类比依然正确。我也曾纠结过例子使用机器语言还是C/C++语言描述。显然我们更倾向于使用C/C++,或者其他的高级语言。因为,每一个用作memory barrier的操作同时也阻止了compiler reordering。

我没有写出所有的memory barrier类型。比如还有data dependency barrier。后面我会写写它们。这里写的4种是大头。

如果你对CPU底层的运行机制感兴趣——比如store buffer,cache coherency协议,以及其它的硬件实现细节——以及为什么它们要实现memory reordering,我推荐Paul McKenney和David Howells的作品,我想对于大多数可以正确写出lock-free代码的程序员来说,对这些硬件细节至少还是能熟悉一二的。
这里是他们的文章链接:
http://www.kernel.org/doc/Documentation/memory-barriers.txt
http://www.rdrop.com/users/paulmck/scalability/paper/whymb.2010.07.23a.pdf

0 0
原创粉丝点击