zz http://blog.sina.com.cn/s/blog_6472c4cc0102dvel.html
本章出现的Coherency指Cache Coherency,Consistency指Memory Consistency。许多工程师经常混淆这两个概念,没有建立足够准确的Memory Consistency概念。Consistency与Coherency之间有一定的联系,所关注的对象并不等同。
MemoryConsistency的实现需要考虑处理器系统Cache Coherency使用的协议。除了狭义Cache之外,在处理器系统中存在的广义Cache依然会对Memory Consistency模型产生重大影响。Memory Consistency和Cache Coherency有一定的联系,但是并不对等。这两部分内容相对较为复杂,可以独立成篇。有些学者认为Cache Coherency是Memory Consistency的一部分[55],更为准确的说是Memory Coherency的一部分。
我们首先给出MemoryCoherency的定义。Memory Coherency指处理器系统保证对其存储器子系统访问Correctness。我们并不关注对处理器私有空间的存储器访问,仅考虑共享空间的这种情况,即便在这种情况下定义Correctness依然很困难。
在一个DistributedSystem中,共享存储器空间可能分布在不同的位置,由于广义和狭义Cache的存在,这些数据单元存在多个副本;在Distributed System中,不同处理器访问存储器子系统可以并发进行,使得Memory Coherency层面的Correctness并不容易保证。
我们假设在一个DistributedSystem中含有n个处理器分别为P1~Pn,Pi中有Si个存储器操作,此时从全局上看可能的存储器访问序列有(S1+S2+…+Sn)! /(S1! ×S2!×…×Sn)种组合[56]。为保证Memory Coherency的Correctness,需要按照某种规则选出合适的组合。这个规则被称为Memory Consistency Model,也决定了处理器存储器访问的Correctness。这个规则需要在Correctness的前提下,保证操作友好度的同时,保证多处理器存储器访问较高的并行度。
在不同规则定义之下,Correctness的含义并不相同,这个Correctness是有条件的。在传统的单处理器环境下,Correctness指每次存储器读操作所获得的结果是Most Recent写入的结果。在Distributed System中,单处理器环境下定义的Correctness,因为多个处理器并发的存储器访问而很难保证。在这种环境下,即便定义什么是Most Recent也很困难。
在一个DistributedSystem中,最容易想到的是使用一个Global Time Scale决定存储器访问次序,从而判断Most Recent,这种Memory Consistency Model即为Strict Consistency,也被称为Atomic Consistency。Global Time Scale不容易以较小的代价实现,退而求其次采用每一个处理器的Local Time Scale确定Most Recent的方法被称为Sequential Consistency[56]。
与SequentialConsistency要求不同处理器的写操作对于所有处理器具有一致的Order不同,Causal Consistency要求具有Inter-Process Order的写操作具有一致的Order,是Sequential Consistency的一种弱化形式。Processor Consistency进一步弱化,要求来自同一个处理器的写操作具有一致的Order即可。Slow Memory是最弱化的模型,仅要求同一个处理器对同一地址的写操作具有一致的Order[56]。
以上这些ConsistencyModel针对存储器读写指令展开,还有一类目前使用更为广阔的Model。这些Model需要使用Synchronization指令,这类指令也被称为Barrier指令。在这种模型之下,存储器访问指令被分为Data和Synchronization指令两大类。其中Synchronization指令能够Issue的必要条件是之前的Data指令执行完毕,其他指令在Synchronization指令执行完毕前不能进行Issue。在Synchronization指令之间的存储器访问需要依照处理器的约束,可以Reordered也可以Overlapped。
在这种Model中,Data指令的Order并没有受到关注,所有规则仅针对Synchronization指令起作用,也因此产生了Weak Consistency,Release Consistency和Entry Consistency[55][56]三个主要模型。这些模型将在下文做进一步的说明。
对于不支持NetworkPartitions的Distributed System,可以在实现Strict Data Consistency的同时实现Availability[1]。而对于一个较大规模的Distributed System中,Network Partitions是一个先决条件。例如在一个大型系统中,所使用的Web服务器和数据库系统已经分布在世界上的很多角落,Network Partitions已经是一个事实。
Consistency,Availability和Partition-Tolerance三者不可兼得[57],这使得在一个Network Partitions的Distributed System中,必须在Consistency和Availability之间进行Trade-Off,也引出了Eventually Consistent模型[58]。这种模型是另一种Weak Consistency Model,基于一个数据在较长时间内没有发生更新操作,所有数据副本将最终一致的假设。
EventuallyConsistent在DNS(Domain Name System)系统中得到了较为广泛的应用,也是Distributed Storage领域的用武之地。这些内容在Cloud崭露头角之后迅速成为热点,却不是本篇重点。我依然相信在Cloud相关领域工作的人必然可以在处理器存储器子系统的精彩中获得进一步前进的动力,可能是源动力。
这些内容超出了本书的覆盖范围,我们需要对Cache Coherency做进一步说明。从上文中的描述可以发现Memory Consistency关注对多个地址进行的存储器访问序列;Cache Coherency单纯一些,关注同一个地址多个数据备份的一致性。不难发现Cache Coherency是Memory Coherency的基础。
CacheCoherency要求写操作必须最终广播到参与Cache Coherency的全部处理器中,即Write Propagation;同时要求参与Cache Coherency的处理器所观察到的对同一个地址的写操作,必须按照相同的顺序进行,即Write Serialization。
WritePropagation有Invalidate-Based和Update-Based两种实现策略。Invalidate-Based策略的实现首先是确定一次存储器访问是否在本地Cache Hit,如果Hit而且当前Cache Block状态为广义的Exclusive/Ownership,不需要做进一步的操作;否则或者在Cache Miss时需要获得所访问地址的Exclusive/Ownership。此时进行存储器访问的CPU向参与Coherency的所有CPU发送RFO(Read for Ownership)广播报文,这些CPU需要监听RFO报文并作出回应。
如果RFO报文所携带的地址命中了其他CPU的Cache Block,需要进一步观察这个Cache Block所处的状态,如果这个Cache Block没有被修改,则可以直接Invalidate;否则需要向发出请求的CPU回应当前Cache Block的内容,在多数情况下,被修改的Cache Block只有一个数据副本。这种方法在Share-Bus的处理器系统中得到了最广泛的应用,如果存储器访问连续命中本地Cache,命中的Cache Block多处于Exclusive状态,不需要使用RFO报文,因此不会频繁地向处理器系统发出广播操作,适合Write-Back方式。
Update-based策略的实现通常使用Central Directory维护Cache Block的Ownership,在Cache Block Miss时,需要Write Update其他CPU Cache Block存在的副本,可以视网络拓扑结构同时进行多个副本的同步,即便如此所带来的Bus Traffic仍较严重,适用于使用Directory进行一致性操作的大型系统。如果进一步考虑实现细节中的各类Race Condition,完成这种方式的设计并不容易。
除了Invalidate和Update-Based策略之外,Cache Coherency可以使用Read Snarfing策略。在这种实现方式中,可以在一定程度上避免再次Read被Write-Invalidate的Cache Block时,引发的Miss。当一个CPU读取一个Data Block时,这个读回应除了需要发给这个CPU之外,还需要更新其他CPU刚刚Invalidate的Cache Block。在实现中,其他CPU可以监控这个读回应的地址与数据信息,主动更新刚刚Invalidate的数据拷贝[59]。
在参考文献[59]的模式中,使用Read Snarfing策略可以减少36~60%的Bus Traffic。但是这种方式的实现较为复杂,目前尚不知在商业处理器是否采用过这样的实现方式。在学术领域,Wisconsin Multicube模型机曾经使用过Read Snarfing策略[61]。
WriteSerialization的实现需要使用Cache Coherent Protocol和Bus Transaction。类似RFO这样的广播报文必不可少。在使用Share Bus和Ring-Bus互连时,较易实现Write Serialization。Directory方式在对同一个地址Cache Block的并发写时需要使用额外的逻辑处理ACK Conflict,这些逻辑大多设置在Home Agent/Node中。
[1] Availbility指来自任何一个处理器的读写请求一定可以获得Response。
××××××××××××××××××××××××××××××××××××
zz 为什么程序员需要关心顺序一致性(Sequential Consistency)而不是Cache一致性(Cache Coherence?)
最后一次修改:2010年11月11日
本文所讨论的计算机模型是Shared Memory Multiprocessor,即我们现在常见的共享内存的多核CPU。本文适合的对象是想用C++或者Java进行多线程编程的程序员。本文主要包括对Sequential Consistency和Cache Coherence的概念性介绍并给出了一些相关例子,目的是帮助程序员明白为什么需要在并行编程时关注Sequential Consistency。
Sequential Consistency(下文简称SC)是Java内存模型和即将到来的C++0x内存模型的一个关键概念,它是一个最直观最易理解的多线程程序执行顺序的模型。Cache Coherence(下文简称CC)是多核CPU在硬件中已经实现的一种机制,简单的说,它确保了对在多核CPU的Cache中一个地址的读操作一定会返回那个地址最新的(被写入)的值。
那么为什么程序员需要关心SC呢?因为现在的硬件和编译器出于性能的考虑会对程序作出违反SC的优化,而这种优化会影响多线程程序的正确性,也就是说你用C++编写的多线程程序可能会得到的不是你想要的错误的运行结果。Java从JDK1.5开始加入SC支持,所以Java程序员在进行多线程编程时需要注意使用Java提供的相关机制来确保你程序的SC。程序员之所以不需要关心CC的细节是因为现在它已经被硬件给自动帮你保证了(不是说程序员完全不需要关心CC,实际上对程序员来说理解CC的大致工作原理也是很有帮助的,典型的如避免多线程程序的伪共享问题,即False Sharing)。
那么什么是SC,什么是CC呢?
SC的作者Lamport给的严格定义是:
“… the result of any execution is the same as if the operations of all the processors were executed in some sequential order, and the operations of each individual processor appear in this sequence in the order specified by its program.”
这个概念初次理解起来拗口,不过不要紧,下面我会给出个很直观的例子帮助理解。
假设我们有两个线程(线程1和线程2)分别运行在两个CPU上,有两个初始值为0的全局共享变量x和y,两个线程分别执行下面两条指令:
初始条件: x = y = 0;
线程 1 | 线程 2 |
x = 1; | y=1; |
r1 = y; | r2 = x; |
因为多线程程序是交错执行的,所以程序可能有如下几种执行顺序:
Execution 1 | Execution 2 | Execution 3 |
x = 1;
r1 = y;
y = 1;
r2 = x;
结果:r1==0 and r2 == 1
|
y = 1;
r2 = x;
x = 1;
r1 = y;
结果: r1 == 1 and r2 == 0
|
x = 1;
y = 1;
r1 = y;
r2 = x;
结果: r1 == 1 and r2 == 1
|
当然上面三种情况并没包括所有可能的执行顺序,但是它们已经包括所有可能出现的结果了,所以我们只举上面三个例子。我们注意到这个程序只可能出现上面三种结果,但是不可能出现r1==0 and r2==0的情况。
SC其实就是规定了两件事情:
(1)每个线程内部的指令都是按照程序规定的顺序(program order)执行的(单个线程的视角)
(2)线程执行的交错顺序可以是任意的,但是所有线程所看见的整个程序的总体执行顺序都是一样的(整个程序的视角)
第一点很容易理解,就是说线程1里面的两条语句一定在该线程中一定是x=1先执行,r1=y后执行。第二点就是说线程1和线程2所看见的整个程序的执行顺序都是一样的,举例子就是假设线程1看见整个程序的执行顺序是我们上面例子中的Execution 1,那么线程2看见的整个程序的执行顺序也是Execution 1,不能是Execution 2或者Execution 3。
有一个更形象点的例子。伸出你的双手,掌心面向你,两个手分别代表两个线程,从食指到小拇指的四根手指头分别代表每个线程要依次执行的四条指令。SC的意思就是说:
(1)对每个手来说,它的四条指令的执行顺序必须是从食指执行到小拇指
(2)你两个手的八条指令(八根手指头)可以在满足(1)的条件下任意交错执行(例如可以是左1,左2,右1,右2,右3,左3,左4,右4,也可以是左1,左2,左3,左4,右1,右2,右3,右4,也可以是右1,右2,右3,左1,左2,右4,左3,左4等等等等)
其实说简单点,SC就是我们最容易理解的那个多线程程序执行顺序的模型。
那么CC是干什么用的呢?这个要详细说的话就复杂了,写一本书绰绰有余。简单来说,我们知道现在的多核CPU的Cache是多层结构,一般每个CPU核心都会有一个私有的L1级和L2级Cache,然后多个CPU核心共享一个L3级缓存,这样的设计是出于提高内存访问性能的考虑。但是这样就有一个问题了,每个CPU核心之间的私有L1,L2级缓存之间需要同步啊。比如说,CPU核心1上的线程A对一个共享变量global_counter进行了加1操作,这个被写入的新值存到CPU核心1的L1缓存里了;此时另一个CPU核心2上的线程B要读global_counter了,但是CPU核心2的L1缓存里的global_counter的值还是旧值,最新被写入的值现在还在CPU核心1上呢!怎么把?这个任务就交给CC来完成了!
CC是Cache之间的一种同步协议,它其实保证的就是对某一个地址的读操作返回的值一定是那个地址的最新值,而这个最新值可能是该线程所处的CPU核心刚刚写进去的那个最新值,也可能是另一个CPU核心上的线程刚刚写进去的最新值。举例来说,上例的Execution 3中,r1 = y是对y进行读操作,该读操作一定会返回在它之前已经执行的那条指令y=1对y写入的最新值。可能程序员会说这个不是显而意见的么?r1肯定是1啊,因为y=1已经执行了。其实这个看似简单的”显而易见“在多核processor的硬件实现上是有很多文章的,因为y=1是在另一个CPU上发生的事情,你怎么确保你这个读操作能立刻读到别的CPU核心刚刚写入的值?不过对程序员来讲你不需要关心CC,因为CPU已经帮你搞定这些事情了,不用担心多核CPU上不同Cache之间的同步的问题了(感兴趣的朋友可以看看体系结构的相关书籍,现在的多核CPU一般是以MESI protocol为原型来实现CC)。总结一下,CC和SC其实是相辅相承的,前者保证对单个地址的读写正确性,后者保证整个程序对多个地址读写的正确性,两者共同保证多线程程序执行的正确性。
好,回到SC的话题。为什么说程序员需要关心SC?因为现在的CPU和编译器会对代码做各种各样的优化,有时候它们可能会为了优化性能而把程序员在写程序时规定的代码执行顺序(program order)打乱,导致程序执行结果是错误的。
例如编译器可能会做如下优化,即把线程1的两条语序调换执行顺序:
初始条件: x=y=0;
线程 1 | 线程 2 |
r1 = y; | y=1; |
x = 1; | r2 = x; |
那么这个时候程序如果按如下顺序执行就可能就会出现r1==r2==0这样程序员认为”不正确“的结果:
Execution 4 |
r1 = y; y = 1; r2 = x; x = 1; |
为什么编译器会做这样的优化呢?因为读一个在内存中而不是在cache中的共享变量需要很多周期,所以编译器就”自作聪明“的让读操作先执行,从而隐藏掉一些指令执行的latency,提高程序的性能。实际上这种类似的技术是在单核时代非常普遍的优化方法,但是在进入多核时代后编译器没跟上发展,导致了对多线程程序进行了违反SC的错误优化。为什么编译器很难保证SC?因为对编译器来讲它很难知道多个线程在执行时会按照什么样的交错顺序执行,因为这需要一个整个程序运行时的视角,而只对一份静态的代码做优化的编译器是很难得到这种运行时的上下文的。那么为什么硬件也保证不了呢?因为CPU硬件中的写缓冲区(store buffer)会把要写入memory的值缓存起来,然后当前线程继续往下执行,而这个被缓存的值可能要很晚才会被其他线程“看见”,从而导致多线程程序逻辑出错。其实硬件也提供了一些例如Memory Barrier等解决方案,但是开销是一个比较大的问题,而且很多需要程序员手动添加memory barrier,现在还不能指望CPU或者编译器自动帮你搞定这个问题。(感兴趣的朋友可以在本文的参考文献中发现很多硬件优化造成SC被违反的例子以及Memory Barrier等解决方案)
好了,我们发现为了保证多线程的正确性,我们希望程序能按照SC模型执行;但是SC的对性能的损失太大了,CPU硬件和编译器为了提高性能就必须要做优化啊!为了既保证正确性又保证性能,在经过十几年的研究后一个新的新的模型出炉了:sequential consistency for data race free programs。简单地说这个模型的原理就是对没有data race的程序可以保证它是遵循SC的,这个模型在多线程程序的正确性和性能间找到了一个平衡点。对广大程序员来说,我们依赖高级语言内建的内存模型来帮我们保证多线程程序的正确性。例如,从JDK1.5开始引入的Java内存模型中已经支持data race free的SC了(例如使用volatile关键字,atomic变量等),但是C++程序员就需要等待C++0x中新的内存模型的atomic类型等来帮助保证SC了(因为atomic类型的值具有acquire和release语义,它隐式地调用了memory barrier指令)。什么意思呢?说简单点,就是由程序员用同步原语(例如锁或者atomic的同步变量)来保证你程序是没有data race的,这样CPU和编译器就会保证你程序是按你所想的那样执行的(即SC),是正确的。换句话说,程序员只需要恰当地使用具有acquire和release语义的同步原语标记那些真正需要同步的变量和操作,就等于告诉CPU和编译器你们不要对这些标记出来的操作和变量做违反SC的优化,而其它未被标记的地方你们可以随便优化,这样既保证了正确性又保证了CPU和编译器可以做尽可能多的性能优化。来告诉编译器和CPU这里这里你不能做违反SC的优化,那里那里你不能做违反SC的优化,然后你写的程序就会得到正确的执行结果了。
从根源上来讲,在串行时代,编译器和CPU对代码所进行的乱序执行的优化对程序员都是封装好了的,无痛的,所以程序员不需要关心这些代码在执行时被乱序成什么样子,因为这些都被编译器和CPU封装起来了,你不用担心内部细节,它最终表现出来的行为就是按你想要的那种方式执行的。但是进入多核时代,程序员、编译器、CPU三者之间未能达成一致(例如诸如C/C++之类的编程语言没有引入多线程),所以CPU、编译器就会时不时地给你捣蛋,故作聪明的做一些优化,让你的程序不会按照你想要的方式执行,是错误的。Java作为引入多线程的先驱从1.5开始支持内存模型,等于是帮助程序员达成了与编译器、CPU(以及JVM)之间的契约,程序员只要正确的使用同步原语就可以保证程序最终表现出来的行为跟你所想的一样(即我们最容易理解的SC模型),是正确的。
本文并未详细介绍所有针对SC问题的解决方案(例如X86对SC的支持,Java对它的支持,C++对它的支持等等),如果想了解更多,可以参考本文所指出的参考文献。下一次我会写一篇关于data race free model, weak ordering, x86 memory model等相关概念的文章,敬请期待。
并行编程是非常困难的,在多核时代的程序员不能指望硬件和编译器来帮你搞定所有的事情,努力学习多核多线程编程的一些基础知识是很有必要的,至少你应该知道你的程序到底会以什么样的方式被执行。
参考文献:
[1] Hans Boehm: C++ Memory Model
[2] Bill Pugh: The Java Memory Model
[3] Wiki: Cache Coherence
[4] Wiki: Sequential Consistency
[5] The Memory Model of X86 (中文,从硬件角度讲SC问题)
[6] 《C++0x漫谈》系列之:多线程内存模型