多核计算杂谈--讨论在多核编程时,在CPU和内存层次上应该知道的一些东西。尝试找到协调多核工作的本质上的问题。
这里讨论基本上参考x86体系,然后根据需要简化或修改。
先看看各个缓存:
为了解决访问存储器和CPU操作之间的不平衡,使得存储器访问不拖后退,利用局部性原理,将存储器分级,提升存储器读写性能的方案,称之为缓存。在这里的思考中,先把各个缓存去掉,于是面对的就是若干核,同一个存储器,这样看比较简单。所谓存储系统就变成黑盒,缓存通过自己的协议,保证不会读到脏数据,保证写的有效。(但是实际上在优化中,能立竿见影的方向是缓存,这里将存储视为黑盒不表示缓存不重要)。
x86在读写某些长度的数据,且数据位置满足一定的对齐条件时,由于使用总线资源的竞争关系,使得这些操作被按一定顺序执行,同时也称作为些操作是原子的。为什么某些操作不是原子的呢?因为这些操作需要读,计算,写等,导致多次访问存储器,而在不同的访问之间,可能有其它操作,这时称操作不是原子的。
站在存储器角度来看,接受的其实上是一个个已经排序好的读或写操作。而作为存储器,需要提供的保证就是在写了某个位置之后,后面的读这个位置要保证输出是最后一次写的数据。但是,操作是由总线来控制的,如果有多条总线呢?存储器也应该保证同一个位置上读的是最后一次写的数据。所以在设计存储器时,应该考虑如何保证上述描述的正确性。所以就有了X读Y写存储器:在X读Y写的情况下,对于同一个基本存储单元的操作间是互斥的。
在这里我们对存储器的一些考虑,得到的一个抽象存储器,实际就是一个最简单的并发对象。每个存储单元就是一个并发对象,称之为原子寄存器。该并发对象可能被多个核读写,但是本身会保证互斥读写。更上一层,将这些存储单元组织起来,作为存储器这个整体,也是一个并发对象,每个存储单元的正确性,是否能保证整个存储器的正确性?
这里引入了一个概念,并发对象:提供一些操作,并且可以供多个核同时使用操作。并发对象本身根据自己的抽象,要在多个操作同时进行的情况下保证某种正确性。这样的正确性有:静态一致性、顺序一致性、可线性化。最常见的是顺序一致性,意味着操作互斥排着序。而可线性化更强调部分到整体的组合,可线性化的是顺序一致的。前面提到的在支持X读Y写的存储单元就是一个并发对象。操作的操作是数据的存储和获取。操作的互斥保证了顺序一致性,而且似乎还是可线性化的(按书上说的是)。所以在考虑整个存储器的时候,这个并发对象提供多个存储单元的读写功能,也是正确的。
现在将这里的抽象的并发的存储器和实际的比较。前面说到总线上的操作是互斥的,于是我们得到了一个很NB的存储器,支持同时读写。但是这个福利是总线带来的,于是让存储器本身的设计减轻负担。(omg,之前被忽略的缓存呢?)。还要注意一点的是,存储器的存储单元是字节,总线上传输的是块(貌似是64位来着),而CPU读写的可能是1字节,2字节,4字节,8字节。这样不仅一方面在保证单个存储单元读写正确,而且另一方面保证对齐的多个单元作为一个整体时的读写正确。
[进一步思考,更高级的并发对象呢?]
上文一方面描述了x86体系中对内存的读写,另一方面提出了并发对象,并发对象的正确性的概念,还观察了存储器如何作为并发对象。
考虑一个问题,某个核上已经将某个指令译码,丢到乱序引擎中执行,同时另一个核修改这条指令所在的内存。显然,这个修改(假定修改在瞬间完成)不会导致另一个核的重新读指令。这个问题本身的提法是错误的。两个核上提到"同时"的概念是没有意义的,在共享存储器并发计算中我们假定不同的执行单元各自以不同的速度执行,且在任意时刻可以停止一个不可预测的时间间隔。我们无法去提"同时"。我们为什么会去考虑"同时"呢?因为两个核有数据共享。一个核的指令数据,同时是另一个核需要写的数据。我们在在存储单元的角度只考虑别人读的时候给了什么值,写的时候存储了什么。而站在执行单元的角度来看,只考虑读到的时候是什么值,写的时候写进去了。
如果两个执行单元(在这里和核,线程等价)没有任何共享的东西,在这一层我们没啥可考虑的了,只能在更高的抽象层次上去看看并发、并行程度神马的。但是这是不现实的。多核计算因为共享东西而变得复杂。要通过共享的东西来进行通信,同步,使得多个执行单元协调工作。共享的东西可以是高层的抽象,但是落到底层,只能是一个一个存储单元了。最简单的同步就是使得两个执行单元互斥执行。前文的模型已经支持这样的互斥了,已有的东西已经能保证正确实现peterson锁。(理论上的是这样,实际上还复杂着)
CPU指令分若干个层次,一般认为是我们看到的一条汇编指令。但是指令还会被翻译为微操作。目前的intel CPU上是4个译码单元,3个是单元译码器,1个是复杂指令译码器。这些操作被派发到乱序引擎后,每一个执行port,还需要更细微的操作来完成一条微操作指令。这里就和前面说到的读写存储器的操作有点差距。于是CPU提供一些相对高层一点的原子操作(lock指令),表现为汇编指令。在这些操作时锁定总线,独占存储器,包含读,写计算。所有的这些指令就像在单个执行单元上执行一样,同一时间只有一个执行,后面执行的能观察到前面执行的结果。(至此,实现peterson锁是可以的了)。
现在还有一些问题:CAS,store buffer,乱序执行,memory barrier。
CAS这样的原子操作有点特殊,在指令中有"分支"存在。其意义在于提供无限大的一致数。没有这样的逻辑的时候,前面的那些原子操作,在N个核执行且需要互斥的时候,需要N个存储单元,而有CAS后就没这个问题了。
前面提到的写存储的操作,都是立即写的。store buffer的存在使得可以让写动作先缓缓。
x86的乱序执行使得一个单元的load操作可以提前到另一个不同单元的写操作前。
这就是所谓的神马称作带store buffer的处理器次序来着。这样会带来什么问题?看store buffer的话,写操作可能没立即生效。又看乱序,意味着影响在其它核去操作对应的存储单元时,所理解到的该核的操作顺序。所以引入memory barrier。memory前的操作应该生效,在其它核看到,该核的操作被memory barrier分为两部分前面的一定在后面的之前发生。所以问题本质在于:内部操作顺序对外部的可见性。顺序,产生了因果,多核的协调正是追求某个因果关系。
在多核处理器级别上多核计算,貌似,大概,至少需要理解上述东西。
在更上面一层,比如C++11的多线程执行的内存模型,讨论大半天,一大堆order,其实在解决这样的问题:线程内部的操作顺序,可以被其它线程观察到,进而协调全局的次序。当操作顺序不需要被观察到时,意味着可以按单线程逻辑优化。当操作顺序需要被观察到时,各个线程按这个序协调工作,进而保证程序的正确性。所谓的release-acquire等语义,就是在说在一个线程acquire到期望值时,另一个线程release该值,且release前的动作生效。更细的则还有consumer,而更高层的才是互斥量神马的。而从C++的这层到CPU这层,还隔着个编译器呢。。。
无论你无锁还是有锁,多核编程中,这些都逃不掉。
PS:文章纯属自己YY,不负责后果。
推荐阅读《The Art of Multiprocessor Progrtamming》(多处理器编程的艺术)Maurice Herlihy, Nir Shavit著,金海,胡侃译。
补刀:
lock操作的全序使得多个核观察到的变量(存储位置)集的变化过程是一致的。多核基于这个共识,才能协作。一个核上的lock操作还有个彩蛋,一个核上按某个顺序操作,在其它核观察到对应操作时,顺序必然也是相同的:操作顺序是共识的一部分。但是这样的共识的要求有点强。所以,削弱一点后就是某个核上的操作顺序对其它核的可见性。这是memory barrier的必要性:内部操作顺序对外部可见(写操作的可见就意味着要使写生效)。除此外,在没有memory barrier和lock instruction的情况下,多核读写内存,还要满足一定的一致性:比如intel文档中的Stores Are Transitively Visible,Stores Are Seen in a Consistent Order by Other Processors。
整体看来:
执行模型:大家各自乱序执行,一起读内存,一起带store buffer地写内存。在执行过程中,每个核能观察到一定的变化,观察到的不同结果满足某种相容性,即到某种共识。如果上述共识不够强,则用memory barrier。如果还不能满足要求,还有原子操作。
多核协作的本质是共识,对某些变化达成一致,上面三个层次的共识由弱到强。而共识中最重要的是顺序,强点的顺序是全局的全序,弱点的是让别人知道自己的顺序。
而多核和分布式有两点相同:
并发对象及其正确性。
要协作则要达成共识。
而共识中最重要的是顺序放到分布式中还成立吗?这个就说不准了,多核中有共享存储。但是,我们可以在分布式中引入类似的东西,比如一个协调者,这样,把分布式的问题转换为多核的问题了。