理解弱内存顺序模型

本文向多核软件程序员介绍弱内存模型,为更多人写好多核同步代码提供帮助。

所谓强内存顺序和弱内存顺序(以下简称强顺序和弱顺序)模型,是一个比较模糊的概念,不同的计算体系结构有不同的深入定义。但我们可以有一个略显模糊的方向性上的理解。

首先我们来理解一下内存顺序模型是什么。

SMP(Shared Memory Processing)计算机的CPU核和内存的关系大概可以表达如下:

图1:SMP架构模型。这里的“CPU”是一个逻辑概念,可以表示ARM构架中的PE或者RISCV架构中的HART,下同。

把设备也考虑在内,其实也是差不多的:

图2:CPU SMP构架(连设备)

所以,我们讨论清楚CPU,基本上一样的概念也可以套用到设备上。后面我们只讨论CPU。

先讨论单个CPU,现代CPU为了提高执行效率,是能偷跑就偷跑的(还美其名曰ILP,指令级别并行化)。比如你写这样一个代码:

a = 1
b = 2
c = a + b

前面两条指令谁先跑,“效果”是一样的。既然如此,CPU就有可能(只是有可能,但有可能就够了。另外编译器也可能参与其中一同作恶,这种变体我们先忽略)让b=2先跑,或者让a=1或者b=2同时跑,对CPU来说,我只保证效果符合语义,实际如何执行,你别管。

这种方法对于每个CPU各玩各的,没有问题,但放到多个CPU共享数据的时候就有问题了。


一个CPU要提供数据给其他CPU,可能它会这样写程序:

data->a1 = data1;
data->a2 = data2;
data->a3 = data3;
...
data->ready = true;

其他CPU就反复读这个data->ready,如果它变成true了,我们就开始读里面的数据。但光从那个写的CPU的角度呢,反正a1, a2, d3, ready也没有啥关系,先写谁不是写啊。这些数据到达内存的顺序就可能是随机的。甚至可能会发生其他的CPU之间看到的顺序都不一样。

这时就需要一个规定了:到底我们看到的内存生效的顺序是怎么样的?这个就叫内存顺序模型。无论是强顺序模型还是弱顺序模型,都指向这个规矩,只是规矩的要求大方向不同而已。有这个规矩,才能称为SMP,或者说是一个Consistent的SMP系统。

SMP的Consistency包括两个问题:内存操作的原子化和内存操作顺序的保证。前者说明的是,我做一个内存操作,什么是原子的?会不会我写入一个寄存器,被别人看到我只写了一半?后者说明的是,我执行了多个原子操作,别人能否按我要求的顺序看见我这些操作?

原子化这个问题要看每个体系结构的定义。每种体系结构都会说明自己哪些内存操作是原子的。更复杂的问题是进行通讯的时候需要做“读-判断-写”这样一个组合原子操作。早期CPU比较简单,比如早期的x86会用LOCK这样的信号来保证一段处理完全独占总线,让内存被原子化地更新。用一个非事实但说明问题的比喻,在上面这个处理过程,如果我在修改的时候把总线锁住,把data的每个域都更新了,然后其他CPU才能进来,这肯定是Consistent的,但这样效率就低了,这段时间其他CPU全都闲着(stall),等你让出总线。考虑到一个内存操作可能几十ns,而一条指令不用一个ns,这个等待没法忍。所以这种方法现在基本上就不用了,现在大部分时候使用LL/SC或者CAS等方案来保证多个动作的原子性。但我们这里不是讨论原子性问题,这个问题我们点到为止,我们就认为我们的CPU有一组针对内存的原子操作,以这个为基础讨论问题。


现在我们重点看顺序问题。

首先我们先建立两个概念:

程序顺序:程序给出的指令的执行顺序,这代表程序员的意欲。

观察顺序:这是各个CPU看到的在内存中发生的顺序,请注意了,这不是内存真的写入数据的顺序,而是所有的“观察者”看到的内存中发生更改的顺序。

强顺序模型(又叫TSO,Total Store Order),是一种靠向程序顺序的顺序模型。所谓Total,就是说,内存(在写操作上)是有一个全局的顺序的(所有人看到的一样的顺序),就好像在内存上的每个Store动作必须有一个排队,一个弄完才轮到另一个,这个顺序和你的程序顺序直接相关。所有的行为组合只会是所有CPU内存程序顺序的交织,不会发生和程序顺序不一致的地方。x86和SPARK都用了这个内存模型。

这个方式对程序员更友好,但对芯片实现者不友好,因为如果用户没有这个顺序要求,CPU为了TSO的承诺,有执行资源也只能瞪眼看着,这影响效率。

弱内存模型(简称WMO,Weak Memory Ordering),是把是否要求强制顺序这个要求直接交给程序员的方法。换句话说,CPU不去保证这个顺序模型(除非他们在一个CPU上就有依赖),程序员要主动插入内存屏障指令来强化这个“可见性”。也没有一个全局的对所有CPU都是一样的Total Order。

用上面的例子来考虑这个问题,如果我们要data.ready生效的时候,之前的写入都全局可见,那么我可以这样写:

data->a1 = data1;
data->a2 = data2;
data->a3 = data3;
...
write_memory_barriar();
data->ready = true;

这样,其他CPU看见ready变成true的时候,其他的a1, a2肯定都已经赋值了。

但由于没有Total Order,其他CPU也要对应和这个写方同步:

ready = data->ready;
read_memory_barriar();
if (ready) {
  mydata1 = data->a1;
   ...
}

PowerPC,MIPS,ARM都是这种模型。RISCV是个骑墙派(RISCV在什么问题上都是骑墙派。一切学院派都是骑墙派,只要自己求礼,面面俱到,事情成不成就不管了),它两种模型都可以支持。

每种弱内存模型的体系架构都有自己的内存屏障指令,语义也不完全相同。其中RISCV的最好理解,特别适合用来用作第一步学习理解:

RISCV的内存屏障指令叫fence,语法是fence rwio, rwio。它的意思是建立一个内存屏障,第一个参数说明什么东西必须发生在它之前,后一个参数说明什么动作必须发生在它之后。所以,fence w, r,就建立一个写读屏障,fence之前的写指令必须发生在fence之后的读指令之前。fence在前后两个程序顺序的序列上,构造了一个强制的“观察顺序”。这样,顺序这个问题,就全部交给程序员自己控制了。这高度灵活,硬件实现起来效率高,但当然了,对程序员不那么友好。

RISCV的文档是让大家写程序的时候按WMO来写,这样写完以后,内存屏障大部分都会变成空操作,程序就两头都能跑了。这同样是骑墙,骑墙的结果是你得到的永远是最短那块板。


然后我们看看程序员应该怎么干。其实大部分时候,程序员不用想那么多的。除非你本来就是写底层的锁啊,调度啊,无锁框架之类的代码。但写这种代码的人不多,能写这种代码也不会搞不清楚这个问题。

而一般的程序员,只要直接使用线程库来写程序,就不用想这个问题。如果你的同步是通过mutex, cond 这些东西来实现的,这个同步操作自然就会有人搞定,这不会有问题。你要保证两个行为有先后关系,用mutex围起来,然后等一方对另一方cond_singal才去用就可以了。不要依赖内存本身来做通讯。

但如果你的程序直接放个变量,然后就自己在那里读读写写的,这就需要对内存模型有清醒的认识了。

而且前面说了,其实编译器后端也会重排指令的,如果你按线程库的语义来写代码,编译器也必须维持这个语义,这仍是安全的,否则你就要考虑编译器的影响。

当然,我们知道,部分程序员是靠“试试能跑,反正老大不懂,我性能高就可以了”来写程序的。这种就会产生平台绑定(这种情况在商业软件中还不少)。我们最后来讨论一下处理这种问题的策略。


我们前面已经看到了,在哪里需要加入内存屏障(以下简称mb),这个是程序员才知道的事情,要不就强行都加入,这是TSO了。所以,如果你的算法强绑定TSO,这看来是没有什么办法的。

但是,如果我知道哪些内存被共享呢?这样,另一个角色,就冒出来了——编译器。

部分论文,比如Hiding Relaxed Memory Consistency with Compilers走的是这条路:你的变量不都是我编译器给的吗?你让几个线程同时访问它这件事情咱知道啊,这样我就有了一张依赖图,通过优化这张图,我就可以帮助你根据需要插入mb指令到所有同步点上,这虽然也有可能误插了(不影响功能),但也有效降低了成本啊。

但这个前提是,你编译器必须静态决定所有这些共享行为,如果我的数据是动态产生了,你可能就会误判。这样导致了程序员没法有效建立严密的逻辑,这种方法,除非修改编译器的语义(比如禁止动态分配内存),否则是很不可靠的。当然,这也有利于那些本来就不可靠的程序员骗领导了。

我们还可以往前走一步,发明一种新的语言,要求用户严格暴露共享变量。但如果我们这样做,和要求用户用pthread的方法来保证同步不是一回事吗?如果你用Python,Java等虚拟机写程序,这个问题就更加根本就不存在。

所以,总的来说,我是不看好编译器自动解决依赖问题这种方向的。按我的意思,这类型的程序啊,能帮忙移植就帮忙移植,不能帮忙的,Let it play go吧。至于在CPU设计上,我个人是不看好TSO这个方向的,因为竞争力永远来自讨论最终客户,而不是程序员。当然,决定方向的还取决于资本,也不见得完全就是客户:)


最后特别声明一下:本文的观点只是就技术谈技术,和任何真实的产品决策无关。


补充1:为什么SMP的设计者这么在乎这个顺序问题?

很多软件工程师可能无法理解为什么芯片工程师这么在乎这个顺序的成本,我把这个问题简单打开一下,帮助理解。

在前面图一中,我们给了一个CPU和内存互联的模型。那是一个软件语义的概念,软件人员从那个图上看,会觉得让谁先看到哪个操作生效,不过是一句话的事。但从芯片的角度,也许你得这样看:

图3 CPU微架构

你就认为每个CPU和内存控制器,其实都是台计算机(Cache就是他们本地的内存),中间通过网络连起来,没有一个中心控制器,然后你要在每个CPU上都“感觉”大家访问数据的顺序是一样的,你可以怎么做?

甚至这个总线网络还不是全互联的,你得像真的的网络那样,要通过多跳路由过去的,你发出的每个包,到达其他节点的顺序不见得是一样的。

所以一旦你要开始做原子操作了,或者要求每个CPU的行为都要满足那个顺序语义,你就要经常做广播和等待对方回应,一旦你做广播,把别人的Cache操作打断,别人的性能立即就掉下来了:一级Cache一个Cycle就可以完成一次读写,DDR可要几十个cycle,一旦你发一个广播出来,跟大家说你又改了某个内存了,大家的cache无效了,你想要拖慢多少进度?你自己等待所有的CPU回应你他们完成了,又要拖慢多少进度?特别是其实我现在还根本不关心你的修改的时候。

从这个角度,也许程序员就好受一点了,用WMO还是带来很大优势的。

CPU微架构通常靠Cache上的标记来记住谁和自己共享了某个数据的,所以,尽量别和别人共享数据,每个CPU放一个自己的版本的数据,让不同CPU的数据在不同的Cacheline上,你的性能就是杠杠的,这是优化多核程序的第一考虑,不二法门。别吃饱没事搞那么多精巧的设计,什么精巧设计都比不上——多实例。什么多核,NUMA,这些问题都可以通过这种方法解决,简单粗暴效果好。

编辑于 2020-04-09

文章被以下专栏收录