原文地址:http://www.cs.nmsu.edu/~pfeiffer/classes/573/notes/consistency.html
内存一致性模型 (Memory Consistency Models)
本文对最近几年出现的几种比较重要的内存一致性模型进行了描述。最基本的想法是去阐述清楚:试图去实现我们头脑中对“内存一致性”概念最直觉的理解,是如此的困难并且特别的昂贵,同时也是没有必要的(and isn’t necessary to get a properly written parallel program to run correctly.)。因此我们试图给出比直觉理解的“内存一致性”要弱化一些的其它的内存一致性模型,使得它更容易去实现,同时仍然允许我们来编写并行的程序,并且会程序按照我们的预期来正确的运行。
记号(Notation)
在描述这些内存模型时,我们只对共享内存(也就是线程或进程间的共享变量)感兴趣 — 而不是任何其它与程序有关的东西。我们不涉及程序的控制流、数据的操作、局部变量(非共享的)。我们用一个标准的记号来描述内存模型,我们下面将用到它。
在这个记号中,用一条直线来表示系统中每一个处理器的运行,时间从左边开始到右边。每一个共享内存的操作我们把它写在处理器的直线上。两个主要的操作为“读”和“写”,用下面的表达式表示:
W(var) value
该表达式的含义为:将值value写到共享变量var中,而
R(var) value
该表达式的含义为:读取共享变量var,获取它的值value.
比如:W(x)1 表示:将1写到x中,而R(y)3表示:读取变量y的值3.
更多的操作(特别是同步操作)在需要的时候我们再来定义他的记号。为简单起见,假设所有的变量都初始化为0.
我们要特别注意一点,在高级语言中的一条语句(比如x = x + 1;)通常将会涉及到几次内存操作。如果x之前为0,则那条高级语言中的语句将变成(不考虑其它处理器):
P1:R(x)0 W(x)1
------------------------------
在一个RISC类型的处理器中,那条C语句很可能变成3条指令:一条加载指令(译注:加载变量x到寄存器)、一条加1指令(译注:将寄存器中的x加1)、一条存储指令(译注:将寄存器中的x放回内存),如图所示对内存进行了两次操作。
在一个CISC类型的处理器中,那条语句很可能转换成一条指令,即内存add指令。即使如此,处理器仍然会按照“读内存”、“加1”、“写内存”的方式来执行那条指令。所以它仍然涉及到两次内存操作。
注意到实际的内存操作的执行可能很好的等价于一些完全不同的高级语言的代码的执行;也许一条if-then-else语句将会测试一个标志flag,然后设置标志flag。如果我问到内存操作,而你的答案中有一些像是转化或者一些数据什么的之类的东西,那么就不太妙了。(Notice that the actual memory operations performed could equally well have been performed by some completely different high level language code; maybe an if-then-else statement that checked and then set a flag. If I ask for memory operations and there is anything in your answer that looks like a transformation or something of the data, then something is wrong!)
严格一致性内存模型 / 内存严格一致性模型 (Strict Consistency)
对“内存一致性”概念的最直觉的理解,我们得到就是“严格一致性的内存模型”。在这种严格一致性内存模式下:任何对一个内存位置X的读操作,将返回最最近的一次对该内存位置的写操作所写入的值。如果我们有很多处理器,没有缓存,通过一条总线来访问内存,那么就我们得到(或者符合)了“严格一致性内存模型”。这里最关键的是精确地序列化所有对内存的访问。
我们用一个例子来说明什么是“严格一致性内存模型”,什么不是,同时也给出一个关于如何用记号来表示内存操作的例子。就像我们前面说过的,我们假定所有的变量有一个为0的初始值。下面的例子就是一个符合“严格一致性内存模型”的场景。
P1:W(x)1
----------------------------------------------
P2: R(x)1 R(x)1
这表示,“处理器P1将1写到变量x中;一段时间之后处理器P2读取x的值1。然后再读取一次,获得相同的值。
我们再给出一个符合”内存严格一致性模型”的场景:
P1: W(x)1
----------------------------------------------
P2:R(x)0 R(x)1
这一次,处理器P2先执行,它先读取x的值0,当它第二次读取x时却获得了处理器P1写入的x的值1。注意这两个场景能够通过将同一个程序在同一个处理器上执行两次而获得。
我们给出一个不符合“内存严格一致性模型”的场景:
P1: W(x)1
--------------------------------------------
P2: R(x)0 R(x)1
这个例子中,当处理器P2第一次读取x的值时,它并没读到处理器P1在之前对x所写入的值1,但是它最终还是读取到了他的值。
我们称这种模型为“内存原子一致性”(atomic consistency)。
顺序一致性 / 内存顺序一致性模型 / 顺序一致性内存模型(Sequential Consistency)
顺序一致性内存模型是一个比严格一致性内存模型稍微弱化一点的模型。它被Lamport定义为:
“(并发程序在多处理器上的)任何一次执行结果都相同,就像所有处理器的操作按照某个顺序执行,各个微处理器的操作按照其程序指定的顺序进行。”
本质上,程序在“严格一致性内存模型”中产生的任何执行顺序在“顺序一致性内存模型”也都是合法的执行顺序,当然不考虑处理器的速度。这里的想法是,通过从“实际发生的读写集合恰好是可能发生的读写集合”进行扩展开来,我们能够更有效的对该程序进行各种推理(因为我们能够询问更加有用的问题,“这个程序曾经会可能被broken过吗?”)。我们能够对程序本身进行推理,而几乎不受运行我们程序的硬件的细节的干扰。可以很公平的说,如果我们有一台真正采用严格一致性内存模型的计算机系统,那么我们将可以推理出它使用顺序一致性内存模型是的各种情况。(It’s probably fair to say that if we have a computer system that really uses strict consistency, we’ll want to reason about it using sequential consistency.)
上面的第三个场景在顺序一致性内存模型中是合法的。下面是另一个合法的顺序一致性内存模型的执行场景:
P1: W(x)1
-----------------------
P2: R(x)1 R(x)2
-----------------------
P3: R(x)1 R(x)2
-----------------------
P4: W(x)2
这个场景是合法的顺序一致性内存模型的原因是,下面的交替操作在严格一致性内存模型中将会是合法的:
P1: W(x)1
-------------------------------------
P2: R(x)1 R(x)2
-------------------------------------
P3: R(x)1 R(x)2
-------------------------------------
P4: W(x)2
下面是一个不符合顺序一致性内存模型的场景:
P1: W(x)1
--------------------------------------------------------
P2: R(x)1 R(x)2
--------------------------------------------------------
P3: R(x)2 R(x)1
--------------------------------------------------------
P4: W(x)2
很奇怪,Lamport给出的精确定义,甚至没有要求维持普通的应果关系的观念。在一个写操作发生之前看到该写操作的结果是可能的,比如:(Oddly enough, the precise definition, as given by Lamport, doesn’t even require that ordinary notions of causality be maintained; it’s possible to see the result of a write before the write itself takes place, as in:)
P1: W(x)1
-------------------------------
P2: R(x)1
这是合法的,因为存在一个在严格一致性内存模型中的执行顺序,可能会挂起(yield)处理器P2直到它的值为1。这并不是该模型的一个缺点;如果你的程序确实可以违背这样的应果关系,那么在你的程序中漏掉了一些同步操作(if your program can indeed violate causality like this, you’re missing some synchronization operations in your program.)。直到现在我们还没有谈论到同步操作;不过马上就到了。
缓存一致性(Cache Coherence)
许多研究者几乎将缓存一致性(Cache Coherence)看作是顺序一致性的同义词;但是它们不是的,这也许让人感到惊讶。顺序一致性要求一个从全局(也就是所以内存)一致性的角度看待内存操作,缓存一致性仅仅要求一个局部的(也就是单个内存地址)一致性。这里有一个例子,它给出的场景符合缓存一致性,但是不符合顺序一致性:
P1: W(x)1 W(y)2
-----------------------------------------------
P2: R(x)0 R(x)2 R(x)1 R(y)1
-----------------------------------------------
P3: R(y)0 R(y)1 R(x)0 R(x)1
-----------------------------------------------
P4: W(x)2 W(y)1
处理器P2和处理器P3都看到了处理器P1对x的写操作发生在处理器P4对x的写操作之后(实际上处理器P3更本没有看到处理器P4对x的写操作),看到处理器P4对y的写操作发生在处理器P1对y的写操作之后(这一次,处理器P3更本没有看到处理器P1对y的写操作。)但是处理器P2看到处理器P4对y的写操作发生在处理器P1对x的写操作之后,而处理器P3却看到处理器P1对x的写操作发生在处理器P4对y的写操作之后。
这在一个基于总线侦听缓存一致性协议(snoopy-cache based)的系统中是不可能发生的。但是它确实会发生在一个基于目录(directory-based)的缓存一致性协议的系统中。
我们真的需要一个如此强的内存模型吗?(Do We Really Need Such a Strong Model?)
考虑下面的发生在一个共享内存的多处理器中的情况:进程运行在两个处理器上,每一个进程改变共享变量x的值,就像这样:
P1 P2
x = x + 1; x = x + 2;
会发生什么呢?没有任何附加的信息,存在4种不同的执行顺序,会导致3中不同的结果:
P1先执行 — x获得的值会是3。
P2先执行 — x获得的值会是3。
P1和P2都读取了x的值,P1先执行对x的写操作 — x获得的值会是2。
P1和P2都读取了x的值,P2先执行对x的写操作 — x获得的值会是1。
我们可以相当容易而简洁的描述这样的程序:它有bug。更加准确一点,我们说它有一个“数据竞争”(data race):一个变量被多个进程(线程)修改,而结果决定与那个进程(线程)先执行。为了让这个程序变得可靠,我们必须使用锁来保证,其中的一个进程(线程)在另一个进程(线程)开始之前先执行完整个操作过程。
那么,如果我们的程序存在“数据竞争”,并且程序的行为是不可预测的,但是如果所有的处理器以相同的顺序看到所有的修改,这真的有关系吗(重要吗)?试图去达到“严格一致性”或者“顺序一致性”也许会被看作是试图去支持有bug的程序的语义 — 因为程序的结果是随机的,为什么我们要关心它是否会获得正确的随机值呢?但是它会变得更糟,就像我们下一节所要描述的。(Attempting to achieve strict or sequential consistency might be regarded as trying to support the semantics of buggy programs – since the result of the program is random anyway, why should we care whether it results in the right random value? But it gets worse, as we consider in the next sections…)
优化与一致性(Optimizations and Consistency)
即使我们写的程序没有bug,但是编译器大体上实际并不支持顺序一致性(编译器大体上并不知道其它处理器的存在,更不要说什么一致性内存模型了。我们可以争论说,也许这说明了语言对并行语义的需要,只要程序员要去用C和Java来写并行程序,那么我们将必须去支持它们。)大多数的语言支持一种语义,在这种语义中程序的执行顺序是通过每一个内存地址来维护的,但是不能跨内存地址;这就给了编译器重排代码顺序的自由。因此,比如,如果一个程序写两个变量x和y,这两个变量相互之间没有依赖关系,那么编译器就有自由(权利)去以任意的顺序来执行对这两个变量的写操作而不影响程序的正确性。然而,在一个并行的环境中,很可能一个在其它一些处理器上运行的程序确实依赖于x和y变量的写操作发生的顺序。
两个互相排斥的进程是一个很好的例子。进入临界区的代码如下所示:
flag[i] = true;
trun = 1 - i;
while(flag[1 - i] && (turn == (1 - i))) ;
如果编译器决定(不管以什么理由)交换对flag[i]和trun的写操作的顺序,这在单进程环境中是完全正确的代码,但是在多进程环境中却会导致失败(这是有关系的情况)。
更糟糕的是,因为处理器支持乱序执行,就不会保证程序的机器码将会按照指定的顺序来执行对内存的访问!更糟糕的是,因为处理器和缓存的紧密耦合,所以处理器会进行更加具有进取性(更加严重)的指令重排,这种类型的优化几乎没有办法阻止或者进行控制(很容易想到,处理器已经完成了对turn的更新,但是它仍然在设置上面的flag[i]的值,因为访问flag[i]涉及到了对数组的访问)。
但是情况还不至于特别糟糕,因为我们能够要求我们的编译器按照程序指定的顺序来完成对共享内存的访问(关键字volatile专门用于此)。以Intel处理器为例,我们也能够通过带有lock前缀的指令来强制指定内存的访问顺序。但是要注意到我们只在我们关心代码的精确执行顺序的地方使用这些关键字和前置。下面的内存模型将扩展这一点。
处理器一致性(Processor Consistency)
该模型也被称为PRAM(流水线随机存取内存的缩写,而不是并行随机存取机器模型可计算理论)一致性。该模型的定义为:在单一一个处理器上完成的所有写操作,将会被以它实际发生的顺序通知给所有其它的处理器,但是在不同处理器上完成的写操作也许会被其它不同的处理器以不同于它实际执行的顺序所看到。基本的想法是“处理器一致性“可以更好的反映真实的网络 — 网络中不同节点的延迟可能是不相同的。
在顺序一致性一节中最后的一个场景(如下所示),它不是合法的顺序一致性,但是是合法的处理器一致性:
P1: W(x)1
--------------------------------------------------------
P2: R(x)1 R(x)2
--------------------------------------------------------
P3: R(x)2 R(x)1
--------------------------------------------------------
P4: W(x)2
下面说明它是如何产生的,在一个用比总线更复杂的东西连接的多处理器的机器中:
1. 处理器以线性数组的方式连接: P1 — P2 — P3 — P4
2. 在第一次循环中,P1和P4进行写操作,并将它们的操作通知给其它的处理器。
3. 在第二次循环中,P1写入的值1通知到了P2,P4写入的值2通知到了P3。然后P2和P3进行读操作,所以P2看到x=1, 而P3看到x=2。
4.在第三次循环中,P1写入的值1通知到了P3,P4写入的值2通知到了P2。然后P2和P3进行读操作,所以P2看到x=2, 而P3看到x=1。(所以我们就得到上图所示的情况。)
因此,你可以看到我们符合了“处理器一致性”定义的关键部分(要求所有其它处理器有序的看到某个单处理器上的写操作):P1和P4各自进行了一次写操作,P2和P3分别有序的看到了P1和P4的写操作(译注:因为P2离P1更近,所以它先看到P1的写操作,然后才看到P4的写操作;同理,P3离P4更近,所以P3先看到P4的写操作,然后才看到P1的写操作)。然而这个例子的关键点是,它说明了“处理器一致性”的定义违反了我们直觉:P2和P3所看到的P1和P4的写操作发生的顺序是不想同的。(译注:所以说它违反了“顺序一致性”。)
下面给出一个违反“处理器一致性”的场景:
P1:W(x)1 W(x)2
-------------------------------------------
P2: R(x)2 R(x)1
处理器P2所看到的P1对x的两次写操作的顺序,与P1对x的两次写操作的实际顺序不同!
因此我们得到了一个结论:两个进程(线程)之间的互斥代码(临界区代码)会被“处理器一致性”所破坏掉!
最后关于“处理器一致性”和“PARAM一致性”要说明的一点是:一些研究者试图通过要求PC同时符合“PARAM一致性“和”缓存一致性“,来得到一个比“PARAM一致性”稍微强一点的“处理器一致性”。
同步内存访问与普通内存访问(Synchronization Access vs. Ordinary Access)
一种正确编写具有共享内存变量的并行程序的方法是使用互斥的方法来保护要访问的共享内存变量。在上面第一个有bug的例子中,我们通过加锁能够得到确定性的行为代码,我们用S表示相关的同步操作。
P1 P2
x = x + 1;
S; S;
x = x + 2;
一般来说,在正确的并行程序中,我们先获得对一系列共享变量的互斥的访问,然后就可以按照我们的要求来进行处理,之后退出互斥访问,向系统其它的部分分发修改之后的共享变量的值。其它的处理器没有必要看到值得中间结果;它们仅仅只要看到最终的结果就可以了。
有了这样的想法之后,让我们更加仔细的审视各种不同类型的共享内存模型。下图给出了各种内存访问模型的分类图[Gharachorloo]:
共享访问
|
------------------------------------------
竞争 无竞争
|
--------------------------
同步 非同步
|
---------------------
获得锁 释放锁
各种内存访问的定义如下:
共享访问(Shared Access)
实际上,除了对变量的共享访问方式之外,还存在私有访问方式。但是私有访问方式与我们要现在讨论的问题无关,所以我们仅仅考虑共享访问。
竞争与非竞争(Competing vs. Non-Competing)
如果我们有两个不同的处理器要访问同一个变量,其中至少有一个是写操作,那么这就是竞争访问。因为最终的结果取决于哪一个处理器先访问该变量(如果两个处理器都是对该变量进行读操作,那么谁先访问,谁后访问是没有关系的)。
同步与非同步(Synchronizing vs. Non-Synchroning)
普通的竞争访问,比如变量访问,都是非同步的访问。当然在同步进程之间的访问肯定是同步的访问。
获得锁与释放锁(Acquire vs. Release)
最后,我们把同步访问分为两步:获得锁和释放锁。
记住比起竞争访问来,同步访问应该是很少见的(如果你所有的时间都花在同步访问上,那么一定是你的程序有问题!),因此通过区别对待同步访问和其它的访问形式,我们可以进一步弱化我们的内存访问模型。
弱一致性(Weak Consistency)
如果我们仅仅将竞争访问分为同步与非同步访问,并且同时要求符合下列条件,那么我们就得到了“弱一致性”:
1.对同步变量的访问是“顺序一致性”的
2.直到之前对所有同步变量的写操作完成之后,才允许访问这些同步变量。
3.直到之前对同步变量的访问完成之后,才允许我们访问(读或写)这些同步变量。
下面给出一个符合“弱一致性”的场景,显示了“弱一致性”的真正用途:
P1: W(x)1 W(x)2
------------------------------------------------------
P2: R(x)0 R(x)2 S R(x)2
------------------------------------------------------
P3: R(x)1 S R(x)2
换一句话说,根本没有要求一个处理器广播它对变量的修改,直到一个同步访问的发生。在一个基于网络而不是总线的分布式系统中,这能够极大的减少信息的互通(注意到,在现实中没有人会故意写一个具有这样行为的程序;你绝对不想去读一个别人正在更新的变量。读操作必须发生在S之后。我提到过一些同步算法,比如松弛算法,它不要求内存一致性的概念。这些算法在“弱一致性”系统中不能工作,因为在“弱一致性”系统中推迟了数据的交流直到同步点。)
释放一致性(Release Consistency)
单一的同步访问类型要求做到:当一个同步发生时,我们必须更新所有的内存 — 我们自己局部的对共享变量修改需要通知给其它的处理器,通过复制的方式,同时我们必须要获得其它处理器的修改。“释放一致性”仅仅关注被锁住的共享内存内存变量,仅仅只需要将对被锁住的共享变量的修改通知给其它的处理器。它的定义如下:
1.在对一个共享变量进行普通访问之前,进程在之前所有的获得锁而进行的操作必须成功的完成。
2.在释放一个锁操作之前,进程的所有之前的读和写操作必须已经完成。
3.获得和释放锁的操作必须符合“顺序一致性”。
结语(One Last Point)
相当明显,一个同步访问是相当重量级的操作,因为它要求整个内存同步。但是为什么会产生这么多的内存模型呢?那是因为一个基本的事实:这些内存模型下的同步操作的代价毕竟要比要求每一次内存访问(不管是共享变量还是局部变量,不管是读还是写操作)都符合“顺序一致性”的代价要小。(But where the strength of these memory models comes is that the cost of these sync operations isn’t any worse than the cost of every memory access in a sequentially consistent system.)
参考资料:(References)
Gharachorloo, K., D. Lenoski, J. Ludon, P. Gibbons, A. Gupta, and J. Hennessy, “Memory consistency and event ordering in scalable shared-memory multiprocessors,” in Proceedings of the 17th International Symposium on Computer Architecture (1990) 15-26