无论程序语言如何千变万化,他们都深深地根植于目前的计算机体系结构。
左图是intel CPU的三级高速缓存设计,由于高速缓存对程序员基本不可见,因此可以抽象为右图。
缓存的设计
首先还是先谈谈左图。
L1-cache分为两部分,i-cache存储指令(只读),d-cache存储数据(可读可写)
CPU只能和寄存器以及L1-cache进行直接交互,数据不能隔层传递,只能一层一层往上读,一层一层往下写
访问L1需要至少4个时钟周期,L2需要至少10个,L3需要至少30个。即便是速度最快的L1,也低于运算单元的执行速度,何况存在缓存未命中的情况,因此在L1和运算单元之间加上了Writebuffer和Readbuffer(合称Memory Ordering Buffer,MOB),数据准备好的时候再完成相关指令,这就是CPU指令乱序——顺序执行乱序完成。乱序完成的结果放入到Writebuffer中,按照原有的执行顺序,刷到缓存中
缓存由多个缓存行组成。每个缓存行结构如图所示(以64位机器为例)
CPU读取缓存的时候找到对应的缓存行,如果前面的有效位为零,就从下一级缓存加载到这一级缓存
相关的问题
明白缓存的设计之后,再看右图来分析其中的问题
缓存导致的内存可见性:已知线程A运行在core 0上,线程B运行在core 1上,两者都对同一个内存地址进行读取,这个内存地址的内容会被加载到cache,然后CPU读取,这时候线程A对内容进行了修改,但是线程B却可能一直从本核心的cache读取,无法感知到该地址的内容已被修改。
多核导致的自增操作原子性:自增操作分为三步:从内存读取变量到寄存器;寄存器中的值加1;写回到内存。已知线程A运行在core 0上,线程B运行在core 1上,两者都对变量执行加一操作。A执行完一二两步时,B执行完第一步,A将加一后的值写入到内存,B执行完二三两步也将加一后的值写入到内存,结果变量只加了一,而不是加二
MOB导致的cache可见性:a=1.0; a=a/2; a=a-1.0;按照正常的逻辑,a最后的结果为-0.5;但是因为除法的执行时钟周期大于减法,第三句执行时,a/2的结果存放在writebuffer中还没写入到缓存,a-1.0中a的值已经从缓存中加载到readbuffer,也就是a-1.0=1.0-1.0=0 (高级语言不会出现这个问题,因为编译器已经做了处理,前面的伪代码仅表示逻辑)
相关的实现
为了解决这些问题,CPU提供了一些指令,其中比如lock和cmpxchg。
lock 汇编前缀,在Intel奔腾系列之前,这个指令前缀能够锁定总线,禁止其他CPU核心操作内存,执行完后边的指令后释放总线,在这个过程中其他CPU核心会监听总线,发现某个内存地址内容被修改,就会将本核心下的对应cache行有效位置0。因为锁总线会禁止所有内存操作,降低效率,因此在奔腾之后,这个指令前缀不锁总线而是锁定相关的cache行,对某个地址修改后直接让相关cache失效。这样解决了问题一
cmpxchg 将寄存器a中的值与内存中比较如果一样,将寄存器c中的值和内存中的值交换,如果不一样就设置异常位并将内存中的值读取到寄存器a。代入到问题二,从内存读取值到寄存器a,加一后保存到寄存器c,然后执行cmpxchg,执行完后如果有异常,就重新加一,再尝试写回,直到成功。这样解决了问题二
cmpxchg和lock 在执行完以后会将writebuffer刷到cache并清空readbuffer,这样解决了问题三。另外X86_64引入了内存屏障指令 lfence、sfence、mfence。lfence前面的读取操作完成,也就是readbuffer中的内容全部被cpu读取后,才能执行lfence之后的读取操作;sfence前面的写入操作完成,也就是writebuffer全部刷到缓存中,才能执行sfence之后的写入操作;mfence之前的读写操作全部完成,才能进行mfence之后的操作。