多核编程杂谈
尝试找到协调多核工作的本质上的问题。
这里讨论基本上參考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。
假设还不能满足要求,还有原子操作。
多核协作的本质是共识。对某些变化达成一致,上面三个层次的共识由弱到强。
而共识中最重要的是顺序。强点的顺序是全局的全序。弱点的是让别人知道自己的顺序。
并发对象及其正确性。
要协作则要达成共识。
而共识中最重要的是顺序放到分布式中还成立吗?这个就说不准了,多核中有共享存储。可是。我们能够在分布式中引入类似的东西。比方一个协调者,这样,把分布式的问题转换为多核的问题了。