浅析内存模型
更新日期:
问题的背景
内存模型(Memory Model)是编程中比较深入的一个问题,它与编程语言有关、与编译器有关、与并发有关、与处理器也有关。但是一旦发生与内存模型相关的问题,总是出现在并发的场景下,多数情况下,我们搞不清楚内存模型和并发有什么关系,似乎紧密相关,又似乎找不到必然的联系。本文试图尽量浅显明白的说清楚内存模型这个问题,行文中参考了一些文章 1 2 3 4 5 6 。
线程级重排序
先说说什么是重排序。重排序有很多种,最常见的是编译优化重排序,就是说,你写的代码,可能你自己已经觉得很优化了,但是计算机不见得觉得足够优化,因此它会调整你的代码中某些行的执行顺序(甚至以等价的方式重写代码),以达到效率上的提升。这个在写代码的时候已经见过很多了,比如,在 Windows 上写代码,开发的时候会用 Debug 模式构建,但是发布的程序会使用 Release 模式构建;Linux 上也类似,开发的时候用 gcc -O
,发布的时候用 gcc -O3
。这其中的差异,部分就在代码的重排序优化上,例如,循环的展开:如果编译器认为这个循环比较小,那么展开循环可以省去跳转指令(JUMP),对性能会有提升。当然,这个例子可能不是特别恰当,总之想说明白的就是,你写的代码和计算机执行的代码几乎必然是两回事,虽然结果一样。
还有一种重排序,指令并行重排序。现代的 CPU 都是流水线的,同时在执行的指令有多条(在执行的不同阶段,这与 CPU 架构有关),如果 CPU 认为,你代码中的这些指令不存在相关性,那么它就会选择并行执行。并行执行后,代码的顺序进一步被打乱。
其实,我个人认为,重排序只有这两种,但是也有认为7还有第三种重排序,内存系统重排序,我认为这应该属于 CPU 缓存。
这两种重排序,可以称为“线程级”(这是我造的名词),因为这两种重排序都是发生在单个线程内部,以不改变该线程的计算结果为依据进行的改进,也就是说,如果碰到多线程相互作用的情况,可能有问题。
CPU 缓存
再说说 CPU 缓存,学计算机的都知道,CPU 和内存之间还隔着 N 层缓存,以现代的 CPU 来看,一般有 3 层缓存,L1、L2、L3、再下面就是内存了,每一层比下一层速度更快,容量更小。如下图(这是只有一层缓存的示意图),
为了提升程序运行效率,CPU 在从内存取数据之后,会把数据存在缓存中,下次取数据的时候,直接从缓存中拿。当然,缓存数据最终会写入内存,但是在程序运行的过程中,可能内存中的数据不是最新的,最新数据在缓存中。在现代 CPU 中,一般有多个核,每个 CPU 核都会有自己的缓存,但是内存只有一块,是共享的。何时从缓存中读取数据,CPU 的优化有个标准,就是“线程级”结果正确,也就是说,当 CPU 认为,对于当前线程(不考虑其他线程的修改),如果内存和缓存中的数据一致,那么会用缓存中的数据。当然,如果有其他线程对共享的内存做了修改,就会导致 CPU 没有意识到内存已经变化,而仍然取缓存中的数据,导致执行错误。
内存模型解决了什么问题
上文中大致介绍了内存模型与编译器有关(编译优化重排序)、与处理器有关(指令并行重排序、CPU 缓存)、与并发有关(CPU 缓存),那么内存模型究竟解决了什么问题呢?
先看一个例子,假设有两个线程,线程的代码和初始值如下,
线程1 | 线程2 |
---|---|
r2=A; //1 |
r1=B; //3 |
B=1; //2 |
A=2; //4 |
初始值:A=B=0; |
问题是,程序运行完后,r1
和 r2
的值是多少?如果你的答案是不确定,那么答对了,那么有几种可能的结果呢?我们来分析一下,
- 执行顺序假设是:1->2->3->4,答案是
r1=1
r2=0
; - 执行顺序假设是:1->3->2->4,1->3->4->2,3->1->4->2,3->1->2->4,答案是
r1=0
r2=0
; - 执行顺序假设是:3->4->1->2,答案是
r1=0
r2=2
;
因此,总共有3个不同的结果。可是,这个答案对么?不对,因为这个解答没有考虑到刚才说到的重排序和 CPU 缓存。
我们先分析,如果有 CPU 缓存会发生什么。有缓存之后,A
B
的值都可能缓存在内存中,因此,缓存中可能存有 A=0
A=2
B=0
B=1
这样的中间结果,而另一个线程会读到什么值是不确定的。假设执行顺序是 1->2->3->4,但是,1、2完成的时候,只修改了线程1所在 CPU 的缓存,并未更新内存中的值,这样,线程2就无法读取到 B=1
,结果还是 r1=0
r2=0
。同样的逻辑也适用于执行顺序是 3->4->1->2 的情况,看起来程序多了很多 r1=0
r2=0
的情况。那么,虽然引发的原因不同,但是,程序的结果应该是只有这 3 种了,对么?不对,别忘了还有重排序。
不管是编译器优化重排序,还是指令并行重排序,单看本线程内,如果语句的顺序调整不会引发结果错误,那么这种重排序就是可能发生的。也就是说,虽然线程1中的代码是 1->2 这样的执行顺序,实际上 2->1 这样的执行顺序也是可能发生的,同理,4->3 也可能发生。情况似乎复杂了,我们需要考虑另外的3种情况:1. 线程1有重排序,线程2没有;2. 线程1没有重排序,线程2有;3. 线程1和线程2都有重排序。而且每一种情况,都需要考虑缓存的影响。
到这里,我们已经感到无所适从了,当然,你可以仔细分析所有的情况,最终得出所有的结果,但这并不是我们想要的,在此只举一个例子。例如,结果是 r1=1
r2=2
是否可能?尽管这个结果看上去很不可思议,但确实是可能的,比如,执行顺序是 2->4->1->3,即线程1和线程2都发生了重排序,但没有缓存的影响。
以上例子描述了内存模型需要解决的问题。问题的背景是多线程并发,问题的内容,一是重排序优化问题,即如何限制编译器优化和指令并行优化;二是可见性问题,即如何将一个线程修改的结果传递给其他线程(或者叫发布),最终的目标是,程序计算结果正确。一句话,如何在多线程并发的情况下,限制编译器优化、指令并行优化,控制线程间的变量传递,使得程序计算结果正确。当然,这是我自己的总结,不严谨,领会其大意即可。注意,内存模型面对的问题中,似乎并没有出现锁,其实并非如此,在“控制线程间变量传递”的过程中,锁是基本的底层工具之一,但内存模型所面对的问题远比锁要来的复杂。
如何解决并发问题
以上已经分析了内存模型问题的复杂性,有没有解决办法呢?有,我的解法非常简单直接:1. 废除编译器优化和流水线的指令并行优化;2. 废除 CPU 缓存,直接从内存中读取。做到这两点,基本不再会有多线程并发的问题了,另一个连带效应是,你的多线程程序,可能比优化过的单线程程序还慢。
当然,这是玩笑话,我们不可能因为要多线程并发,而放弃这么多年来积累的编译器优化技术、CPU 优化技术,这是因噎废食的。现代编程语言(如C++、Java)对这个问题的解法主要包括两个,限制编译器优化,以及内存屏障。
限制编译器优化
这个很好理解,对于一些需要在线程间同步的变量,我们可以限制编译器对含这些变量语句的重排序优化,禁止某些情况下的语句重排序,至于那些不需要同步的变量,那么自然是允许编译器尽可能的优化,以提升性能。这些限制重排序优化的规则中,比较有名的是 happens-before
规则,这个规则在不同的语言中包含的内容也不太一样,但基本上是符合常识的。这里先不介入 happens-before
的细节内容,但是其中的一些内容可能你在写代码的时候已经用到了,例如,Java 的 synchronized
原语,C++11 中锁的 memory order。
内存屏障
其实,内存模型面对的问题,并不是分开逐个解决的,而是在一些语言特性中统一的解决,因此,上文提到的 happens-before
规则中也有很多关于内存屏障的内容。所谓内存屏障,就是用来限制流水线的指令并行优化,并将缓存中的结果同步到内存。这是我对内存屏障的认识,并不严谨。最著名的内存屏障的应用就是锁了,还有其他的表现,例如,刚才提到的 Java 的 synchronized
原语,C++11 中锁的 memory order,也包含了对内存屏障的描述。内存屏障是与特定硬件体系有关的,例如,在 x86 体系的 CPU 中,有 mfence
、sfence
、lfence
这样的指令,显示的指明内存屏障。
总结
本文描述了内存模型面对的问题,并简介了解决问题的方法,这些方法在不同的编程语言中有不同的实现,需要进一步细致讨论。多线程编程有多种模式,主要有两大类,共享内存和消息传递,使用消息传递模式的语言主要有 Scala、Erlang、Go;使用共享内存模式的语言有 Java、C++等。粗略分一下的话,函数式编程语言多数会使用消息传递模式,命令式编程语言大多使用共享内存模式,例外也有,比如 Go。本文提到的内存模型实际上是共享内存模式的一种实现,当然,多线程编程模式是一个更加宏大的问题,在此无法详述。