内存一致性模型

内存一致性模型(memory consistency model)是用来描述多线程对共享存储器的访问行为,在不同的内存一致性模型里,多线程对共享存储器的访问行为有非常大的差别。这些差别会严重影响程序的执行逻辑,甚至会造成软件逻辑问题。在后面的介绍中,我们将分析不同的一致性模型里,多线程的内存访问乱序问题。

PS:内存一致性模型实际上是由硬件带来的,当软件人员思考这种模型时就“跨层”了。而计算机科学中最重要的思想之一就是:分层。只有当我们不满足于当前层提供的能力或性能时,才会考虑”跨层”。多线程程序的出现本身就是为了提高程序性能。

目前有多种内存一致性模型:

  • 顺序一致模型(sequential consistency model,SC)
  • 完全存储定序(total store order,TSO)
  • 部分存储定序(part store order,PSO)
  • 宽松存储模型(relax memory order,RMO)

SC:顺序一致性

顺序一致性是最简单的内存模型,也称为强定序模型。CPU会按照程序顺序来执行所有的load与store动作,即按照它们在程序中出现的次序来执行。从主存储器和CPU的角度来看,load和store是顺序地对主存储器进行访问。PS:可以想象成只有共享存储,没有独立存储。

顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:

  • 一个线程中的所有操作必须按照程序的顺序来执行。
  • (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。

在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。顺序一致性内存模型为程序员提供的视图如下:

img

在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读 /写操作。从上面的示意图我们可以看出,在任意时间点最多只能有一个线程可以 连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读 /写操作串行化(即在顺序一致性模型中,所有操作之间具有全序关系)。

为了更好的理解,下面我们通过一个示意图来对顺序一致性模型的特性做进一步的说明。

假设有两个线程 A 和 B 并发执行。其中 A 线程有三个操作,它们在程序中的顺序是:A1->A2->A3。B 线程也有三个操作,它们在程序中的顺序是:B1->B2- >B3。

img

未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程 A 和 B 看到的执行顺序都是:B1- >A1->A2->B2->A3->B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。

TSO:完全存储定序

为了提高CPU的性能,芯片设计人员在CPU中包含了一个存储缓存区(store buffer),它的作用是为store指令提供缓冲,使得CPU不用等待存储器的响应。所以对于写而言,只要store buffer里还有空间,写就只需要1个时钟周期(哪怕是ARM-A76的L1 cache,访问一次也需要3个cycles,所以store buffer的存在可以很好的减少写开销),但这也引入了一个访问乱序的问题。

相比于以前的内存模型而言,store的时候数据会先被放到store buffer里面,然后再被写到L1 cache里。

image-20200217113525865

首先我们思考单核上的3条指令:

1
2
3
S1:store flag= set
S2:load r1=data
S3:store b=set

如果在顺序存储模型中,S1肯定会比S2先执行。但是如果在加入了store buffer之后,S1将指令放到了store buffer后会立刻返回,这个时候会立刻执行S2。S2是read指令,CPU必须等到数据读取到r1后才会继续执行。这样很可能S1的store flag=set指令还在store buffer上,而S2的load指令可能已经执行完(特别是data在cache上存在,而flag没在cache中的时候。这个时候CPU往往会先执行S2,这样可以减少等待时间)

这里就可以看出再加入了store buffer之后,内存一致性模型就发生了改变。引入了store-load乱序

如果我们定义store buffer必须严格按照FIFO的次序将数据发送到主存(所谓的FIFO表示先进入store buffer的指令数据必须先于后面的指令数据写到存储器中),这样S3必须要在S1之后执行,CPU能够保证store指令的存储顺序,这种内存模型就叫做完全存储定序(TSO)。

经典的例子:

image-20200217114212933

在SC模型里,C1与C2是严格按照顺序执行的,代码可能的执行顺序如下:

1
2
3
4
5
6
S1 S2 L1 L2
S1 L1 S2 L2
S1 L1 L2 S2
L1 L2 S1 S2
L1 S1 S2 L2
L1 S1 L2 S2

由于SC会严格按照顺序进行,最终我们看到的结果是至少有一个CORE的r1值为NEW,或者都为NEW。

在TSO模型里,由于store buffer的存在,L1和S1的store指令会被先放到store buffer里面,然后CPU会继续执行后面的load指令。Store buffer中的数据可能还没有来得及往存储器中写,这个时候我们可能看到C1和C2的r1都为0的情况。

所以,我们可以看到,在store buffer被引入之后,内存一致性模型已经发生了变化(从SC模型变为了TSO模型),会出现store-load乱序的情况,这就造成了代码执行逻辑与我们预先设想不相同的情况。而且随着内存一致性模型越宽松(通过允许更多形式的乱序读写访问),这种情况会越剧烈,会给多线程编程带来很大的挑战。

PSO:部分存储定序

芯片设计人员并不满足TSO带来的性能提升,于是他们在TSO模型的基础上继续放宽内存访问限制,允许CPU以非FIFO来处理store buffer缓冲区中的指令。CPU只保证地址相关指令在store buffer中才会以FIFO的形式进行处理,而其他的则可以乱序处理,所以这被称为部分存储定序(PSO)。

image-20200217114651549

S1与S2是地址无关的store指令,cpu执行的时候都会将其推到store buffer中。如果这个时候flag在C1的cahe中不存在,那么CPU会优先将S2的store执行完,然后等flag缓存到C1的cache之后,再执行store data=NEW指令。

这个时候可能的执行顺序:

1
S2 L1 L2 S1

这样在C1将data设置为NEW之前,C2已经执行完,r2最终的结果会为0,而不是我们期望的NEW,这样PSO带来的store-store乱序将会对我们的代码逻辑造成致命影响。

从这里可以看到,store-store乱序的时候就会将我们的多线程代码完全击溃。所以在PSO内存模型的架构上编程的时候,要特别注意这些问题。

RMO:宽松内存模型

丧心病狂的芯片研发人员为了榨取更多的性能,在PSO模型基础上,更进一步的放宽了内存一致性模型,不仅允许store-load,store-store乱序。还进一步允许load-load,load-store乱序, 只要是地址无关的指令,在读写访问的时候都可以打乱所有load/store的顺序,这就是宽松内存模型(RMO)。

内存屏障

芯片设计人员为了尽可能的榨取CPU的性能,引入了乱序的内存一致性模型,这些内存模型在多线程的情况下很可能引起软件逻辑问题。为了解决在有些一致性模型上可能出现的内存访问乱序问题,芯片设计人员提供给了内存屏障指令,用来禁止指令处的乱序。

内存屏障的最根本的作用就是提供一个机制,要求CPU在这个时候必须以某种顺序一致性模型的方式来处理load与store指令,这样才不会出现内存访问不一致的情况。

对于TSO和PSO模型,内存屏障只需要在store-load/store-store时需要(写内存屏障),最简单的一种方式就是内存屏障指令必须保证store buffer数据全部被清空的时候才继续往后面执行,这样就能保证其与SC模型的执行顺序一致。

而对于RMO,在PSO的基础上又引入了load-load与load-store乱序。RMO的读内存屏障就要保证前面的load指令必须先于后面的load/store指令先执行,不允许将其访问提前执行。

参考

------ 本文结束------
赞赏此文?求鼓励,求支持!
0%