本文是自己对并发编程学习的备忘录,部分内容和观点可能不正确,发现问题我会及时更新。
1、学习并且掌握内存模型有一定难度,我觉得主要是相关的概念容易混淆,因为内存模型和内存序没有明确的定义,不同的硬件体系结构都有自己的内存模型和内存序的描述,看起来相似,但又存在差别,所以越学越迷糊。
我们不能脱离硬件体系结构来明确的定义内存模型和内存序,本文首先介绍基本的内存模型和内存序的知识,目的是引出问题帮助理解,切记不能认为内存模型和内存序都是如此。了解了基础知识之后再具体看看X86的内存序和C++11的内存序。
2、本文讨论的内存模型
C++内存模型分为静态模型和动态模型,静态模型又叫静态布局,指对象在内存中的分布,继承,虚函数,虚继承对内存布局都有影响。动态模型是本文讨论的主要内容,主要是指读写内存的顺序模型。
3、并发程序数据不一致问题
为了提高性能,编译器和CPU都会对程序进行优化,这些优化能保证程序在单核系统上运行结果正确,但是在多核系统上可能会出现问题。
(1)程序并发执行会破坏原子性,最容易想到的例子就是多线程计数器。
(2)编译器的优化会重排序部分语句,甚至优化掉部分语句,破坏顺序性。
(3)CPU的优化会乱序执行部分语句,寄存器的存在以及缓存一致性的负面影响(例如引入store buffer和invalidate queue)会影响顺序性和可见性。
千万不要混淆顺序性和可见性,也不要认为顺序性包含可见性,顺序性是指本线程操作内存的顺序,可见性是指其它线程看到本线程操作内存的顺序。可见性是通过顺序性以及代码逻辑保证的,很多时候我们需要的是逻辑的可见性。没有保证顺序性和可见性都可能导致程序出错。
3、缓存一致性算法
缓存主要为了解决CPU运行速度快但访问内存速度慢的矛盾。目前多核系统一般每个核都有自己的缓存,还有共享的缓存。由于每个核都有自己的缓存,因而有可能一份数据会有多个副本,如何维护多个副本的一致性呢?这就需要缓存一致性算法了。MESI算法一般用于介绍缓存一致性算法,但实际实现中会复杂很多,我也没有具体了解过,这里只是通过MESI简单了解一下缓存一致性算法。缓存一致性算法通过在多核之间传递和处理一致性消息来保证数据的一致性。理论上缓存一致性算法是可以保证数据一致性的。但是由于处理一致性消息需要时间,等待响应也需要时间,如果CPU都是同步阻塞的傻等肯定会影响性能,因而又引入了异步操作,store buffer和invalidate queue登场,看下面load和store的过程。
内核A和内核B共享数据X,内核A store X的过程如下:
(1)内核A store X时发现X处于S(共享)状态,则会将数据保存到store buffer中,然后发送读+使无效请求(invaliate消息)给内核B,然后继续执行其他操作。
(2)内核B收到invaliate消息后会将invaliate消息保存到invalidate queue中,然后回复invaliate response消息给内核A,内核B会在随后异步处理invaliate消息。
(3)内核A收到invaliate response消息后会将X从store buffer写入cache中,并将状态修改为M。
内核A load X的过程如下:
(1)内核A首先查看store buffer中是否存在X,如果存在则读取
(2)内核A查看cache中是否有X,如果存在再检查X的状态,如果是M或E或S则读取
(3)如果内核A的cache中没有X,则发送读消息
需要注意store过程有可能异步先存到store buffer中的,这样就有可能后面的load操作先执行,造成storeload乱序。load过程不会检查invaliate queue,如果数据的invaliate消息在invaliate queue中,直接去读cache里面的数据,那么有可能读取无效的数据,这两种情况会破坏顺序性和可见性。
4、CPU内存屏障
硬件无法知道软件对于无依赖数据的顺序要求,也无法知道单个共享变量在多核中的可见性要求,因而为软件提供了memory barrier语义,通过memory barrier语意,软件和硬件的配合可以满足对数据一致性的不同要求。
写屏障(store memory barrier):保证写屏障之前的store操作先于写屏障之后的store操作完成。有的处理器是通过标记store buffer中的数据来实现的。
读屏障(load memory barrier):保证读屏障之前的load操作先于读屏障之后的load操作完成。有的处理器是通过处理完invalidate queue中的invalidate消息来实现的。
全屏障(full memory barrier):全局一致的顺序保证,类似于多线程程序以任意一种顺序交叉排列后在单线程中运行的效果。
多线程通常是配对使用屏障,写线程用写屏障保证store A和 store B的顺序,读线程用读屏障保证load B和load A的顺序,如果读线程能load new B,则一定能load new A。
5、编译器优化屏障
CPU导致乱序的原因和解决办法前面已经介绍了,还有一种乱序是由编译器优化导致的。如果编译时使用了优化选项,编译器就会对程序进行优化,可能会重新编排程序从而造成乱序,同样,编译器提供了优化屏障。优化屏障会告诉编译器不要进行乱序优化。
6、X86内存模型
X86使用的是TSO(Total Store Order)内存模型,它将会做如下保证:
(1)不会出现loadload,loadstore,storestore乱序,但是有可能出现storeload乱序的情况,storeload乱序指load会比store先执行。
(2)同一地址的storeload不会自反序,通过bypassing-load实现。
这里有一个对可见性的疑问,如果核心A执行store X;store Y。核心B执行load X;load Y,那么有没有可能出现load X的结果是old X,load Y的结果是new Y?肯定会出现,而且即使是Sequentially_consistent也无法保证这一点,因为有可能是load X;store X;store Y;load Y。所以不能简单的认为顺序性就是可见性,虽然最严格的内存序是能保证可见性的,但大多数情况下,仅仅保证顺序性是不能保证可见性的,并发编程需要多加小心,可见性是否正确需要反复推敲。可见性和顺序性很容易混淆,单单原子语意是无法保证可见性的,弱的顺序性也无法保证可见性。需要原子+内存序的配合,单个变量的原子操作配合正确的内存序能实现单个变量的可见性。多个变量的原子操作配合正确的使用内存序才能达到多个变量的顺序可见性。
7、C语言关键字volatile
上文提到编译器会进行优化,读数据可能会从寄存器中读到旧值,也会优化掉部分语句,为了保证编译器不会出现上面的优化,C语言提供了关键字volatile,用volatile修饰的变量不会被编译器优化掉,并且每次读变量的时候都是直接从内存中读取。如果您的并发程序对性能要求极高,那么可以用volatile修饰共享变量,可以防止编译器做多余的优化,但只用volatile修饰是不够的,还需要优化屏障和内存屏障的配合来保证顺序性,通过顺序性和代码逻辑保证可见性。还有一个问题就是优化屏障和内存屏障在各个平台实现不一样,使用中会遇到移植性的问题。还有一种常用的方式就是C++语言提供的内存序。
8、C++11内存序
C++的内存序一般是嵌入在原子操作中的。
GCC提供了一组原子操作,例如__sync_fetch_and_add 等等,提供了Sequentially_consistent顺序性。
C++11也提供了一组原子操作,这些可能是大家今后经常会用到的,从别处转来几个介绍内存序的图,很直观。
有个疑问:Sequentially_consistent和Acquire_release有什么区别呢?
Sequentially_consistent和Acquire_release在单线程中都能够保证执行的先后顺序,但是在多线程场景下,Sequentially_consistent能够保证对所有带此标签的内存操作建立单独全序,比如A线程store X, B线程store Y,那么所有线程(包括其它线程)将看到相同的顺序,比如都看到store X先序于store Y,不会出现部分线程看到store X先序于store Y,其它线程看到store Y先序于store X。
参考: