纵然工作再忙也应该要留下自己思考的时间,这次我总结了一下对于内存模型的理解,起因是在公司听了一场关于多线程编程的分享会。首先解释一下,内存模型和对象模型是不同的。对象模型说的是一个对象是如何被设计的,其在内存中是如何布局的。而内存模型说的是,在多核多线程环境下,各种不同的CPU是如何以一种统一的方式来与内存交互的。
背景知识:CPU的高速缓存
总所周知,CPU和内存并不是直接交换数据的,它们之间还隔着一个高速缓存。高速缓存是对程序员透明的,这意味在编程的时候是感知不到CPU的缓存的存在的。一般情况下确实如此,但在,在某些特殊的情形下(多核多线程),就不能忽略缓存的存在了。这其实是和缓存的设计有关系,一般多处理器下的每个CPU都有一个自己的缓存,存储在这个缓存的数据是其它CPU是无法查看的。
引入问题1:内存可见性
问题来了,由于缓存是每个CPU私有的,那么在多线程环境下,某个CPU修改了变量x后保存在本地缓存,对于其它CPU,何时才能发现变量x被修改呢?如何保证其它CPU的缓存中持有的x的值是最新的呢?
由此可见,在多核多线程环境下,读写共享变量要解决的不仅是原子性,还需要保证其内存可见性。更糟的是,现代CPU通常在执行指令时会允许一定程度上的乱序,这使保证在多个CPU缓存的数据一致更是增加了复杂性。通常方法是通过一个协议来保证数据在各个CPU的缓存是一致性,这就是缓存一致性协议。
关于缓存一致性简单的举个列子。CPU-0尝试STORE(更新)变量x,但其发现其它CPU的缓存也持有这个x的copy(x此时为Shared状态,非单个CPU独占),那么当CPU-0在STORE之前,必须通过一个disable消息,告诉其它CPU所持有的变量x已经为脏数据,是不可用状态。其它CPU在收到这个disable消息后必须回应CPU-0一个ack消息,这时候CPU-0才能开始STORE变量x。
通过缓存一致性协议之后,内存可见性问题似乎是得以解决了。但是,这里面还隐藏着另外一个问题:指令乱序!
引入问题2:乱序(memory reorder)
先来解释一下,乱序,指的是程序指令实际上执行的顺序,和我们书写的指令的顺序不一致。乱序分两种,分别是编译器的指令重排和CPU的乱序执行。本意上乱序是为了优化指令执行的速度而产生的。并且为了维护程序原来的语义,编译器和CPU不会对两个有数据依赖的指令重排(reorder)。这种保护在单线程的环境下是可以工作的,但是到了多线程,问题就复杂了。
举个例子,CPU-0将要执行两条指令,分别是:
- STORE x
- LOAD y
当CPU-0执行指令1的时候,发现这个变量x的当前状态为Shared,这意味着其它CPU也持有了x,因此根据缓存一致性协议,CPU-0在修改x之前必须通知其它CPU,直到收到来自其它CPU的ack才会执行真正的修改x。但是,事情没有这么简单。现代CPU缓存通常都有一个Store Buffer,其存在的目的是,先将要Store的变量记下来,注意此时并不真的执行Store操作,然后待时机合适的时候再执行实际的Store。有了这个Store Buffer,CPU-0在向其它CPU发出disable消息之后并不是干等着,而是转而执行指令2(由于指令1和指令2在CPU-0看来并不存在数据依赖)。这样做效率是有了,但是也带来了问题。虽然我们在写程序的时候,是先STORE x再执行LOAD y,但是实际上CPU却是先LOAD y再STORE x,这个便是CPU乱序执行(reorder)的一种情况!
当你的程序要求指令1、2有逻辑上的先后顺序时,CPU这样的优化就是有问题的。但是,CPU并不知道指令之间蕴含着什么样的逻辑顺序,在你告诉它之前,它只是假设指令之间都没有逻辑关联,并且尽最大的努力优化执行速度。因此我们需要一种机制能告诉CPU:这段指令执行的顺序是不可被重排的!做这种事的就是内存屏障(memory barrier)!
内存屏障
还是上面那个例子,如果不想指令1、2被CPU重排,程序应该这么写:
- STORE x
- WMB (Write memory barrier)
- LOAD y
通过在STORE x之后加上这个写内存屏障,就能保证在之后LOAD y指令不会被重排到STORE x之前了。
内存模型是什么
前面讲了那么多,那么内存模型是什么呢?
首先,残酷的现实就是每个CPU设计都是不同的,每个CPU对指令乱序的程度也是不一样的。比较保守的如x86仅会对Store Load乱序,但是一些优化激进的CPU(PS的Power)会允许更多情况的乱序产生。如果目标是写一个跨平台多线程的程序,那么势必要了解每一个CPU的细节,来插入确切的、足够的内存屏障来保证程序的正确性。这是多么的不科学啊!科学的做法应该是,我为一个抽象的机器写一套抽象的程序,然后在不同的平台下让编程语言、编译器来生成合适的内存屏障。因此,我们有了内存模型的概念。不同平台下的实现差别被统一的内存模型所隐藏,只需要根据这个抽象的内存模型来编写程序即可,这便是伟大的抽象...
因此,在C++11里有了内存模型的在之后,我们可以仅通过标准库就实现出跨平台线程安全的lock free程序(这在C++11之前是做不到的,虽然Java早就有了内存模型)。
参考资料