内存模型引发的思考

昨天在看《深入理解 Java 虚拟机》,本来以为 12 章讲的内存模型指的是类似 C++ 对象模型的概念,可以在一个半小时内搞定,结果看到硬件上的一致性以及内存模型的概念时就发现触及自己的知识盲区了。

CPU 缓存一致性

首先是 CPU 缓存一致性,借一下书上的图。

现在的 CPU 基本上都是多核了,以 Intel i7 系列的处理器来说,每个核都有各自的寄存器组、L1 Cache、L2 Cache,多个核共享 L3 Cache,这里我们把 L1 和 L2 合起来统称为各个核的高速缓存。显然,各个核之间的高速缓存需要同步,否则如果两个核访问同一块内存时,就可能出现错误,因为它们对该内存的读写操作是直接作用于自己的高速缓存,而非主存,这就引入了 CPU 的缓存一致性协议。

我对这块并没有了解过,在网上搜了一下,找到了一篇感觉还不错的文章:缓存一致性(Cache Coherency)入门。里面有提到一个叫做 MESI 的协议(该协议有相应的一些衍生协议)用于保持多核的高速缓存间的一致。按照协议,各个缓存段(Cache Line,即缓存的最小单元)拥有四种状态:

  • 失效(Invalid)缓存段,要么已经不在缓存中,要么它的内容已经过时。为了达到缓存的目的,这种状态的段将会被忽略。一旦缓存段被标记为失效,那效果就等同于它从来没被加载到缓存中。
  • 共享(Shared)缓存段,它是和主内存内容保持一致的一份拷贝,在这种状态下的缓存段只能被读取,不能被写入。多组缓存可以同时拥有针对同一内存地址的共享缓存段,这就是名称的由来。
  • 独占(Exclusive)缓存段,和S状态一样,也是和主内存内容保持一致的一份拷贝。区别在于,如果一个处理器持有了某个E状态的缓存段,那其他处理器就不能同时持有它,所以叫“独占”。这意味着,如果其他处理器原本也持有同一缓存段,那么它会马上变成“失效”状态。
  • 已修改(Modified)缓存段,属于脏段,它们已经被所属的处理器修改了。如果一个段处于已修改状态,那么它在其他处理器缓存中的拷贝马上会变成失效状态,这个规律和E状态一样。此外,已修改缓存段如果被丢弃或标记为失效,那么先要把它的内容回写到内存中——这和回写模式下常规的脏段处理方式一样。

多核情况下,各个 CPU 都需要窥探总线,尤其是想要写缓存段时,必须先获取独占状态,即告知其他核心此时不可修改该缓存段对应的内存,并且在此核完成后,其他核心的高速缓存中如果有对应该内存的缓存段,则需要被标识为失效。类似地情况还有一些,但是作为不研究及实现这块内容的我们只需要明白:多核 CPU 芯片内部有着相应的一致性协议来保证各个处理器的高速缓存对主存中的相同位置保持一致,不会出现核 A 及核 B 针对同一主存在各自的高速缓存中做独立修改而致使最终部分操作丢失的情况。

唉,毕竟我也不是研究这方面的,虽然有兴趣,但是考虑到个人的精力问题,还是只能点到为知。

Java 内存模型

对于这块,我也不是很了解,只能按照自己的理解写些内容。

首先抛开 Java,定义内存模型的目的是什么呢?我觉得是用于指导编译器(这里泛指 C/C++/Java)优化时需要注意的事情。这其实会有比较大的影响,假设内存模型的定义仅设定在单线程上(比如 C++0x),那么编译器的优化边界就限定在单线程上了,所有保证单线程正确的优化都是符合标准的,然而,这些优化放到多线程上可能就会出事了,类似地例子可以看看这篇文章:《C++0x漫谈》系列之:多线程内存模型

所以,Java 内存模型在定义上考虑了多线程的问题,并提出了工作内存的概念来建立多线程内存模型,屏蔽提供不同内存模型的硬件(对于这点我还是有点懵的,未来如果工作有机会的话,可以好好看看)。

关于 C++ 内存模型的话,《C++ 并发编程实战》这本书的 5.1 节有提及相关内容,可以看看。

volatile 关键字

这个关键字在 C/C++ 和 Java 中都有,但是语义不太相同,具体的内容我觉得这篇文章讲的很棒了:C/C++ Volatile关键词深度剖析。先前那篇讲 C++0x 的多线程内存模型的文章也提及了 volatile。

说的明白点:在 C/C++ 中,volatile 就是让编译器访问此变量时,一律从内存中访问,并且要把结果即时写入内存,从而保证了多线程能够及时得知变化,而且,绝对不能因为优化而把和这个变量相关的语句给抹掉了。

看完这两篇文章后,我是真觉得 C/C++ 里的这个关键字作用真的不大,纯粹是为了解决一些历史问题引入的(和 I/O 设备相关,本章开头说的那篇文章里有提及)。它在 C/C++ 中的鸡肋之处主要体现在只使用它无法提供我们经常需要的同步语义(Java 为它加了 Acquire 和 Relase 语义后使得它能表达一些基本的同步语义:如 happen-before),而如果引入锁这些东西来表达同步语义的话,加不加 volatile 的话其实没差。

后面这点我在 VS2013 下做了点实验,我们知道编译器是可以利用寄存器进行一些优化的,比如下面的代码:

int g_i = 0;
int g_input;

int main()
{
    for (int j = 0; j < g_input; ++j)
    {
        g_i += j;
    }
}

在 VS2013 下开 Release 的话,g_i += j 这句会被优化成类似 add ecx eax 这样的代码,即将两个操作数放入各自的寄存器中,然后在循环结束后才将最终值写回内存。

看到这儿,自然而然地想到如果这段代码在多线程下会怎么样?在多线程这种情景下,我们一般都会利用临界区之类的机制来保证同步,所以代码会变成下面这样:

for (int j = 0; j < g_input; ++j)
{
    Lock();
    g_i += j;
    Unlock();
}

到这里就发现问题了,如果编译器依旧把 g_i += j 编译成 add ecx eax 这样的代码的话,即使我们利用了同步机制,依旧会得到不正确的结果。这很明显,因为多线程可能会运行在多个 CPU 上,操作的结果都存到了各 CPU 的寄存器上,最终写回内存的时候必然会丢失部分操作。

所以,编译结果是 add dword ptr [address], ecx 这样的代码,这个指令好像是 Read-Add-Write,也就是从内存读、相加、写回内存,这个和加了 volatile 关键字的编译结果是一致的。因此,这样地编译结果配合上多核处理器中的 CPU 缓存一致性协议,保证了结果,最终得到了我们所能理解的多线程累加结果。

还剩一个问题,编译器怎么区分这两种情况的?即何时允许优化成使用寄存器?我在实验中把代码中的 Lock() 替换成了一个随意的方法调用,在方法里打印了一句话,编译的结果和 Lock() 的情况一致。但是如果方法是空的话,就回到了第一种情况。

对于这点,我不确定是否有标准定出的规则来告知编译器实现者哪些情况下可以使用寄存器优化,但是基本原则很显然:如果该操作周围的指令在编译时就能够断定不会影响到值的话(包括多线程),那么可以利用寄存器优化,但是遇到像 call 指令这样的存在,编译器是没法断定的,所以一律采用第二种方式生成机器码。

C/C++ 多线程模型

上面提及的那篇讲 C++0x 的文章中说 C++0x 的标准中定义的内存模型是单线程的,从而导致很多那个时候的优化在多线程时可能会出现问题。

对 C++ 新标准中的这类型具体细节不是很清楚,但是 C++11 既然提供了自身的线程库,我想应该是已经把这块的定义理的比较清楚了吧。

Lock-Free

本部分属于更新内容。

今天看了下关于 Lock-Free Programming 的一篇博客,里面提及了内存模型的问题,加深了我对它的理解。在前文中我有提到说 C++ 编译器在代码中遇到 Lock 之类的操作时就会取消掉寄存器读的优化,而强制去访问内存,这实际上是和内存模型相关的。

借一张博客中描述 .Net 内存读写操作的表:

倒数第二行对于 Lock Acquire 是定义了必须得在访问前刷线程缓存的,实质上就是要求 CPU 必须到内存中重新去访问该值,以避免访问 CPU Cache 中的值是旧的,这和 C++ 编译器进行的优化原则是差不多的,可能是因为 C++11 也遵循了这样的内存模型。

再注意一点,表中第三行提及了 Volatile Read,可以看到,它也有和 Lock Acquire 同样的要求,因为这个变量被声明为了 volatile,所以编译器不能认为它在 CPU 缓存中的内容是可靠的,从而需要访问内存刷新缓存。

有啥想说的就留个言呗~

comments powered by Disqus