目录
1 一些基本概念
1.1 现代处理器的存储体系
现代处理器为了提升并行性能通常采用多核结构,同时考虑到CPU访问寄存器的速度远高于访问内存的速度,因此引入了缓存以提高访存效率,且通常都有多级缓存,比较常见的缓存参数是:独享的1级数据缓存、1级指令缓存、2级缓存,以及共享的3级缓存。结构图如下:
一般缓存是可以失能(关闭)的,但除了操作系统内核启动初期等一些特殊的时刻会暂时关闭缓存,一般都会开始缓存,因为有/无缓存的性能差异实在是太大了。而在缓存使能的状态下,CPU在访存时通常是绕不开缓存的,以ARM体系结构的一条访存指令为例:
ldr r3, [sp, #4]
这条指令的作用是从sp + 4指向的栈内存(通常是一个局部变量)中读取数据,读到r3寄存器。执行这条指令使,CPU首先会检查缓存,若缓存命中(缓存中保存了要读取的值),则直接从缓存中取数;若缓存未命中,则去内存中将sp + 4指向的内存及其附近的数据一并填充到一个缓存行(cache line),此时数据已经在缓存中,于是CPU将该数加载到r3寄存器。
1.2 内存与I/O统一编址
对于CPU如何访问外设,不同的体系结构有不同的设计。比如x86设计了专门的访问外设的指令IN、OUT,而ARM则没有这样的指令,其采用了内存与I/O统一编址的设计,访问外设在形式上和访问内存是一样的。通俗的解释这种技术:
访问外设
通常需要特定的时序,软件来实现比较困难,因此集成电路工程师专门设计了外设控制器
来控制相应的外设,外设控制器一方面提供外设所需的接口和时序;一方面向CPU提供出一系列的寄存器,CPU只需要合理的设置外设控制器的寄存器就可以访问相应的外设了。比如外设控制器提供了控制寄存器、数据寄存器,CPU只要找到控制寄存器并向其中写1就可以打开外设,写0就可以关闭外设;而向数据寄存器写数据就可以实现向外设发送数据,从数据寄存器读取数据就可以实现从外设接收数据。现在只剩下一个问题:CPU如何找到外设控制器的一系列寄存器?
答案很简单:向寻址内存一样即可
。换句话说,外设控制器的寄存器被编址到了内存地址空间,举个例子就更清楚了:
#define control_register (*(volatile unsigned long *)0x66666666)
control_register = 0; /* 关闭外设 */
control_register = 1; /* 打开外设 */
再用一幅图来说明内存地址空间的情况:
如此一来,CPU访问程序、数据和外设的方式得到了统一,有点一切皆访存 的意味。
1.3 修改内存单元的两条路子
有了上述铺垫,我们就不难获知有哪些修改内存单元的途径,注意这里的内存单元并不是指狭义的SDRAM存储单元,而是指内存地址空间所有可以访问的单元。假设计算机系统的CPU采用内存与I/O统一编,那么修改内存单元有两条途径,如下图所示:
分别做简单的解释:
- 从CPU出发
CPU执行类似str r3, [sp, #4]
的写内存单元指令时,会将寄存器中的数据写到Cache中,并在时机合适的时候将Cache中的数据回写到内存。 - 从外设出发
外设在某些时候,会修改相应外设控制器的寄存器,比如收到数据的时候,修改某个寄存器的某个位,标志已经收到数据。
1.4 缓存带来的数据一致性问题
对于多线程的程序来说,多核CPU可以做到真正意义上的并行,即每个核心同时运行一个线程,通常这种并行能够带来性能的提升,但由于每个核心有自己独享的Cache,因此这种并行也带来了数据一致性的问题。试想这样一种场景,有两个线程分别在CPU的两个核心上运行,其中一个线程会修改全局变量flag,另外一个线程根据读到的flag的值做出相应的反应。假设负责修改flag的线程做出了修改,根据上文,修改的值会先送到该线程所在核心的Cache,而此时另一个核心的Cache以及内存中保留的还是flag的旧值,那么flag的值就不一致了,进而影响程序的运行,如下图:
幸运的是,设计Cache的工程师们早就意识到了这个问题,并给出了解决方案:缓存一致性协议(MESI)
。MESI的内容比较复杂,这里我仅参考网上的资料[1]简单介绍一下遇到上图所示的问题时,MESI如何工作。先给出一副图:
接下来分别介绍core1、core2各自的动作:
core1修改flag为1
第1步:给core2发送Invalid消息。
第2步:把1写入到自己的Store Buffer中。
第3步:异步地在某个时刻将Store Buffer中的1写入Cache,进而触发失效操作,完成Cache同步。
core2接收到Invalid消息
第1步:把收到的消息写入自身的Invalidate Queue中。
第2步:异步地将自身的Invalidate Queue设为Invalid状态。
由于CPU核如果要读Cache中的数据,需要先扫描自身的Store Buffer,之后再读取Cache。因此core1在执行完写操作,即完成第2步之后(第三步是异步的),如果再读flag,则总能读到最新的值。又因为CPU核在读Cache时不扫描自身的Invalidate Queue,且core2无法扫描core1的Store Buffer,所以core2在Cache完成同步之前读到的都是flag的旧值。不难看出,这种不一致持续的时间是core1完成第2步到完成第3之间的间隔。
到这里就不难得出结论,MESI协议可以保证缓存的一致性,但是无法保证实时性。所以可能会有极短时间的脏读问题。
1.5 访存优化带来的数据一致性问题
上文分析了缓存是如何造成数据一致性问题的,并且介绍了问题的解决方案——MESI(缓存一致性协议)。虽然MESI不算完美(存在极短时间的脏读),但无伤大雅,在CPU核确实写Cache的前提下,比如执行指令str r3, [sp, #4]
,那么Cache早晚都会得到同步的,而且需要的时间并不长。这样岂不是说,如果我们可以容忍那极短时间的脏读,就可以用上文所说的全局变量flag在两个线程之间做同步了(严格保证一个线程只读,一个线程可读可写)。果真如此吗?如果CPU根本没有写Cache呢,哪怕我们在C语言中写了flag = 1
;同时,执行读flag的线程中也可能根本没有从Cache读,即便Cache同步能实时完成,上文中Thread2可能读到的仍然是旧值。为什么会这样呢?原因在于编译器的访存优化!
访存是比较耗时间的,不管是读还是写,因此CPU弄出了Cache,但Cache还不够快,论速度,寄存器才是CPU的真爱。编译器处于优化性能的考虑,在它看来,只要不影响程序的正确性(注意是在编译器看来),可能会将访存操作推后,先把变量的值保存在寄存器中,对变量的修改也暂时只修改寄存器:
c程序 | 对应汇编 |
---|---|
flag = 1 | mov r3, #1 @ flag暂存在r3寄存器中,暂时只对r3赋1 |
这样操作在Thread1来说没有问题,比如后面要读flag的时候,直接用r3就好了,r3里面装的就是flag的最新值。可还有Thread2呢!遗憾的是编译器并没有全局观,它不会意识到Thread2和Thread1之间的微妙关联。出于同样的原因,Thread2也可能在优化下,暂存flag变量,每次读flag时只是读一个寄存器,而不会读Cache。就这样,在访存优化的推动下,数据不一致的问题产生了。
1.6 内存与IO统一编址带来的数据一致性问题
在上文1.3节已经介绍过,外设也可能修改内存单元,而且这种修改悄然无声,缓存一致性协议也没有办法捕获这种修改,也就是说,如果我们读外设控制器的寄存器,不管有无访存优化,都可能无法获得相应寄存器的最新值。反过来,从CPU一侧来看,如果我们要写外设控制器的寄存器,也无法实现即时的写入,这是因为,即便不进行访存优化而直接写缓存,但缓存回写内存的动作仍然不是即时的。
或许1.4节中提到的短时的脏读问题我们尚且能够忍受,但对硬件的访问通常都要求时效性,若迟迟不能得到硬件的最新状态,或不能即时的操作硬件做出反应,在某些工业场景下,这可能造成灾难性的后果。
2 volatile的作用
2.1 volatile关键字
volatile关键字修饰变量时能够影响变量的访问属性,具体的说,对于被volatile修饰的变量,每次对其进行读写时都要直接前往内存(通常是SDRAM),而不能由Cache或寄存器进行缓存。好奇心泛滥的我不禁会想,编译器是怎么实现volatile的这项语义的呢?简单的写了一段程序(带有volatile修饰的变量),并做优化编译(-O3)及反汇编,发现反汇编中对volatile修饰的变量的访问总会生成访存的指令,比如:
ldr r3, [sp, #4]
str r3, [sp, #4]
这些指令并不特殊,虽然肯定可以绕开寄存器,但怎么保证一定能绕开缓存呢?我想到了一种可能,内存管理中,页表的表项里有一些描述页面属性或状态的位,其中通常有一位用于描述是否禁止缓存。至于到底是不是这样子实现的,我也不清楚,网上查了一些资料,但没有找到介绍volatile语义实现的相关资料(讲java的volatile语义实现的比较多)。因此先将这个疑问记录下来,后面发现相关资料了,再来更新。当然,如果有路过的大神知道的,还请不吝赐教,不胜感激^_^!!
关于volatile对编译器优化行为的影响,这里再多说一点。现有如下程序:
volatile int a = 0;
for (int i = 0; i < 100; ++i)
++a;
假如没有volatile修饰,则编译器可能将变量a暂存到寄存器,然后在做循环时每次访问的都是寄存器。甚至编译器会更激进的将整个循环都优化掉,直接向暂存a的寄存器加上100,如:
add r3, r3, #100 @ a暂存在r3中
而有了volatile之后,哪怕开启最高级别的优化,编译器也只能老老实实的做循环,并且每次访问a都要生产访存的指令,如:
for循环:
ldr r3, [sp, #4]
add r3, r3, #1
str r3, [sp, #4]
挺久以前我在学习总线时序的时候,使用I/O口模拟时序,并用循环实现了一个简单的延时,但测试发现时序不对,延时时间出乎意料的短,检查了反汇编之后才发现,编译器做了优化,延时时间就达不到了,用volatile修饰循环中的变量后,问题解决。当然,除了少数资源比较少的单片机,很少有用I/O口模拟时序、用循环实现延时的需求。
注:volatile关键字在GCC对C语言的扩展中,还被用在内联汇编中,以禁止编译器对内联汇编的优化,这部分内容可以看我的另一篇博客:浅析c程序中的屏障,其中有所介绍。
2.2 volatile解决数据一致性问题
volatile的语义使得其修饰的变量在存储系统中的一致性得到了充分的保证。但它对编译器优化的影响导致了效率的降低,因此除非不得已,我们应当避免使用volatile。那么,什么时候应该使用volatile呢?个人认为,除非有什么万不得已的理由,应当仅在内存与I/O统一编址的场景下,需要对硬件进行访问时使用volatile(这也是volatile被发明的初衷)。
存储系统中,MESI已经解决了一部分数据不一致的问题,尽管不能够做到实时,但那一丢丢脏读的时间似乎没什么不能容忍的理由。至于多线程并发访问共享资源时,由于访存优化避开了MESI,进而造成的数据不一致,在我看来,这个问题本就不应该用volatile来解决。为什么这么说?请看下文。
2.3 volatile能作为屏障吗?
答案是不能,这不是我说的,这是GCC手册说的:
Accesses to non-volatile objects are not ordered with respect to volatile accesses. You cannot use a volatile object as a memory barrier to order a sequence of writes to non-volatile memory.
2.4 volatile具有原子性吗?
答案是不具有,不难实验验证,如下程序:
volatile int a = 0;
++a;
反汇编为:
ldr r3, [sp]
add r3, r3, #1
str r3, [sp]
2.5 volatile适合线程间的同步吗?
volatile不能保证指令执行的顺序,也不具有原子性,它能做的只是保证数据的一致性,可见volatile缺少同步所需的大部分要素。不难想象,强行用volatile来做同步,一定很蹩脚吧!关于这个问题的更多讨论,可以看参考文献[2]。那么,完成线程间同步的正确姿势是什么呢?答曰:操作系统提供的同步原语。
3 总结
volatile关键字能够用来解决内存与I/O统一编址时,对硬件的即时访问。虽然volatile能解决数据一致性的问题,但它能做的也只有这个,因此将其用于多线程间的同步是不太合适的。
参考文献
[1] 知乎用户林林在问题有了缓存一致性协议为什么还需要多线程同步?中的回答
[2] 也来说说C/C++里的volatile关键字
[3] GCC用户手册