说说 Memory Model

Memory Model可以简单的理解为内存的可见性,在多线程程序中是一个非常重要的概念,这次想系统的总结一下相关的知识。

背景

在并发编程中,一般有两种编程模型,一种是通过shared memory,一种是通过交换message。两种模型中语言的代表前者比如c++,后者比如go。在共享内存的模型中就有cpu与内存读写的动作,一般的情况是某一个线程往内存中的某个位置写了一个值,另外一个线程在同一位置读到这个值,这样两个线程间就有了通信。那这里的问题在于我们并不能假设多线程的读写顺序会按照我们预想的那样完成。

编译器或者cpu在操作的时候往往为了性能的优化会交换我们程序中读写操作的顺序,这是在单核单线程时代就一直有的优化手段,在单线程程序中,编译器或cpu会保证这样的优化不会影响程序的正确性,比如说他们不会交换有依赖关系的读写操作。

但是当随着技术的发展,摩尔定律由于功耗墙等一系列的原因失效后,各个cpu厂商开始堆砌多核的时代。当多线程的程序运行的时候,编译器或者cpu没法保证优化的正确性了,因为一个核并不能知道另外一个核上在跑着哪段代码。出于这样的现状,我们必须引入内存模型的概念,通过定义不同的内存模型,好让多线程程序运行的结果符合我们的预期。

不同的Memory Model会对编译器和cpu有着不同的优化限制,在较弱的模型中,编译器和cpu有着比较大的优化自由度,而在强一些的模型中,编译器和cpu就必须准守模型的约束,禁用某些优化的手段,甚至不能优化。

编译器乱序

Memory Model描述了cpu读取或写入内存的顺序,编译器可以在编译期交换我们程序读写的顺序,cpu在执行期也有可能乱序执行。所以在没有设定内存模型的语言中,比如c语言,下面的代码可能会出乎你的预料:

1
2
3
int num_a=0, num_b=0;
num_a=num_b+1;
num_b=0;

上面的代码如果gcc开启O2的优化,查看汇编代码你就会发现num_a和num_b的操作顺序被交换的,这就是程序的乱序执行,虽然顺被被交换,但是程序在单线程下的语义的正确性并没有被改变,所以这样的优化是可行的。

如果我们想要禁用编译器的优化带来的乱序效果,我们可以使用GNU内联汇编 asm volatile(“” ::: “memory”) 来消去编译器乱序。不过这样的设置只会影响编译器,并不会转化成程序的汇编指令,所以对cpu的乱序执行是没有效果的。

CPU乱序

cpu由于硬件优化的原因也有可能乱序执行程序,不过不同的cpu有不同的内存模型,也就是对乱序的种类有不同的限制。比如我们常见的intel的X86 cpu就是强内存模型的,它只有可能执行storeload乱序,也就是读操作也许会和不同变量的写操作交换顺序,但是不会和同一个变量的写交换顺序,那样就会影响程序语义的正确性,X86 cpu会遵循程序的因果性。

另外像嵌入式产品中常用的Arm cpu就是弱内存模型,它允许更多种类的乱序。这里我插入一张参考链接中的图片来说明乱序的种类:

可以看到对于X86而言,一个核的写操作在其他核看起来顺序是一样的,不同核的写操作顺序是没有保证的。之所以X86只允许这一种乱序,是因为写操作比较费时间,所以在架构上会将写的值直接放进一个叫store buffer的地方,这样就会导致其他核可能不能马上看见这次写操作。

对于cpu的乱序我们也有办法加以改变,对于cpu乱序,我们可以用内存屏障指令防止某些或者全部种类的乱序执行。对于X86而言,lfence代表load barrier,rfence代表store barrier,mfence代表full barrier。

在程序中我们可以写到 asm volatile(“mfence” ::: “memory”),这样即使禁止编译器乱序又是禁止cpu乱序。

其实上面禁止乱序的指令我们在平时的程序中很难见到,主要是因为锁的语义中已经带有禁止乱序概念。

以spin_lock为例,锁要完成的任务有两点,1) 在同一时刻只让一个线程进入临界区 2) 防止临界区中的代码被乱序到临界区外去执行。这第二点就是所得acquire和release语义,acquire语义指的是acquire之后的所有内存读写操作不能被提前到acquire之前,realease语义指的是realease之前的所有内存读写操作不能被放到realease之后。这两个语义对编译器和cpu的乱序执行做出了限制。从而保证临界区中的代码在锁操作的范围内执行。

这两点中的第二点很容易被忽略,第一点我们都知道是通过CAS去实现,那第二点其实在加锁和释放锁的时候通过lock前缀指令和memory指令实现的。后面有时间的话,我想写一篇如何写一个性能不那么差的自旋锁的实现。

但是要注意的是,锁没有对临界区内的操作的顺序有任何限制,只能靠语言的内存模型来限制。这就是为什么在C++11之前不加内存屏障的DCLP是不行的原因,不过这一点在C++11中已经修复了。

另外,参考链接中的第二条对内存模型有一系列的文章描述,说的都很好,非常值的推荐阅读。

Reference

Memory Model WIKI)

Preshing on programming