一.CPU高速缓存
1.为什么需要高速缓存
现代CPU的速度比现代内存系统的速度快得多,比如在一个主频为1.8GHZ的CPU上,每秒有1.8*10^9个时钟周期,假设每条指令平均需要数个周期,那每秒可执行的指令数亦是相当惊人的,而在这样一个始终周期内,光在真空钟只能传播8cm,在一个5GHZ的时钟周期内更是降低到了3cm,更糟糕的是,电子在硅钟的传播速度是真空钟光速的1/30到1/3,而一个1cm的距离对于一个现代计算机系统的体积来说还是太小了一点。
如此一来,若每次cpu执行读取内存的指令后,都等待数十乃至数百个周期去访问内存,将会大大降低CPU的执行效率,因此为了匹配CPU与内存访问的速率,引入了高速缓存。
2.高速缓存结构
CPU高速缓存和内存之间的数据流是固定长度的块,称之为“缓存行“,其大小一般为2的N次方,范围一般为16到256字节。CPU高速缓存的结构为多路缓存行,所谓多路要先从高速缓存结构是一个由硬件实现的简单hash表,其中其中每个桶所能容纳的元素(缓存行)的数量称之为”路“,如下图所示便是一个16sets的2路高速缓存,以及本机的CPU高速缓存信息,内存与缓存行的映射方式为地址hash
3.缓存缺失
- 首次缓存行缺失:当一个特定的数据项初次被CPU访问时,它还不存在缓存行中。
- 容量缺失:当CPU缓存被填满后,需要替换出现有缓存行数据。
- 写缺失:当要访问的数据存在于缓存行中,但却是只读的,造成的cache miss
- 通信缺失:当某个CPU要写数据前,会首先发送”使无效“消息,使其它CPU高速缓存中对应的缓存行无效,这会造成其它cpu访问该内存时的cache miss。
二.缓存一致性协议
1.缓存一致性协议作用
缓存一致性协议用于管理缓存行的状态,以防止数据的不一致或数据丢失。完整的缓存一致性协议可能十分复杂,本文只讨论4种状态,MESI。
2.MESI状态
MESI代表了四种状态:modified,exclusive,shared和invalid。每个缓存行都会被标记为其中一种状态。
modified | 处于改状态的缓存行拥有对应CPU最近写入的数据,并且保证对应的数据没有在其它CPU缓存重出现。即该缓存行的数据是被该CPU独占的,且拥有最新的数据,为此,该缓冲行有责任将数据最终写回内存,回转移到其它高速缓存。 |
exclusive | 类似于”modified“状态,此时缓存行也是该CPU独占的,但不同的是没有被CPU修改,因此不用负责写会内存,亦不用改缓存行转移给其它CPU,可以直接丢弃,亦可随时修改,转为”modified“状态,而不用考虑其它cpu。 |
shared | 处于此状态的缓存行同样拥有最新的数据,但至少有一个其它CPU高速缓存亦保存了对应的数据,因此在未得到其它CPU许可之前,不能向缓存行存储数据。 |
invalid | 处于该状态的缓存行未持有任何有效数据。当将新数据放入缓存行时,会首选处于invalid状态的缓存行。 |
3.MESI状态转换
缓存行在MESI四种状态间的转换,是通过MESI协议消息实现的。以下为6种MESI消息:
- 读消息:包含要读取的数据的物理地址
- 读响应消息:包含之前读消息所请求的数据,读响应消息可能由内存提供,亦可能由其它CPU高速缓存提供(当目标数据处于一个”modified“状态的缓存行时,必须由该缓存行响应读消息)。
- 使无效消息:该消息包含要使无效的缓存行数据的物理地址,所有其它CPU高速缓存都将从缓存种移出该数据,并响应该消息。
- 使无效应答消息:当收到一个使无效消息,则必须在移除指定数据后响应一个”使无效应答“消息。
- 读使无效消息:该消息是一个复合消息,是读消息与使无效消息的组合,一般在本CPU cache miss,并要写入数据时。该消息包含要读取数据的物理地址,并使其它CPU cache种该数据无效(以便该CPU进行更改)。
- 写回消息:包含要写回到物理内存的数据和目标地址,该消息可以保证modified状态的缓存行换出时数据不丢失。
三.存储缓冲及内存屏障
1.存储缓冲
在不引入存储缓存前,若某个CPU要写内存,则需先向其它CPU发送使无效消息(或读使无效消息),在收到使无效应答后才能将数据写入缓存行,这造成了CPU之间的停等。然而不论其它CPU发送给他的数据是什么,本CPU都会无条件覆盖,因此没有等的必要性。其CPU缓存结构及停等示意图如下:
为了避免上述的停等,而在CPU和高速缓存间添加了一个存储缓冲,这样CPU便可将要存储的数据直接写入存储缓冲,之后可以继续执行,从而避免了停等,提高了连续写的能力。其结构如下:
在引入存储缓冲后带来了一个问题,即违反了自身一致性,因为当CPU要写数据时,可能发现cache miss,从而先将数据写入存储缓冲,并向其它CPU发送读使无效消息,之后若再读取数据,那么若只从高速缓存读取,将可能读到旧的数据,因为此时存储缓存中的数据还未写入高速缓存中。为了解决这个问题,每个CPU在执行加载操作时,将考虑自己的存储缓冲,即直接从存储缓冲中读取数据,而不一定经过高速缓存。
2.存储缓冲将影响全局内存序
所谓全局内存序即各个CPU看到的对内存修改的顺序,考虑如下情况:CPU0拥有变量b,CPU1拥有变量a,CPU0执行函数void foo(){ a = 1; b = 1;},CPU1执行函数void bar() { while(b==0) continue; assert(a == 1); },其执行顺序如下图所示:
·
3.使用内存屏障解决全局内存序失序
内存屏障可以解决全局内存序的失序问题,内存屏障保证在将后续的存储数据写入到缓存行之前,会先将存储缓冲的数据刷新至缓存行,这便避免了上一小节的问题,即只有当CPU0将a写入缓存行后,才会将b写入缓存行,这样cpu1在读到a的新值前绝不会读到b的新值。void foo() { a = 1; smp_mb(); b = 1; }
【注】:在内存屏障之后的所有存储操作指令,都必须等待之前的数据从存储缓冲刷新到缓存行(否则就可能出现先修改的数据还在存储缓冲,而后修改的数据却已进了缓存行),而不管这些后续存储操作是否存在cache miss(即使拥有缓存行也不能直接写入缓存行)。
【注】:若不使用内存屏障,那么可能会造成其它CPU先看懂本CPU后修改的值,因为先修改的值还在存储缓存中,而后修改的值以及刷新到了存储缓存。即所有在内存屏障之前的写操作将在所有内存屏障之后的写操作之前被所有其它CPU感知。
【问】:此处CPU1后读到使无效消息是因为放入使无效队列吗?
四.使无效队列及内存屏障
1.使无效队列
”使无效消息“的处理比较耗时,其原因是,CPU需要确定相应的缓存行确实已无效,而当频繁操作缓存行时使无效操作可能被延迟,比如:各CPU密集的装载或存储数据,并且这些数据都在缓存行中,这将导致大量的使无效消息与读消息。而大量的使无效消息将会导致某个CPU忙于处理,此时其它CPU的存储操作只能写入存储缓冲,而无法写入高速缓存中的缓存行,又由于存储缓冲容量有限,当存储缓存写满后便会使CPU陷入停顿。
为了解决而引入了使无效队列,一个带使无效队列的CPU只要把使无效消息放入队列,便可直接回复使无效消息。将一个条目放进使无效队列,CPU实际上便保证在发送任何与该缓存行相关的MESI状态前,先处理该使无效消息。
2.使无效队列影响全局内存序
使无效队列会导致使无效消息暂留在队列中,从而造成本地缓存行未及时无效化。如下图所示便是一种情况:
3.使用内存屏障解决内存序失序
如上一小节,使用无效队列加速使无效响应会使内存序失序(这是由于只是将使无效消息放入队列,但未及时处理导致的访问了本该无效的缓存行内的旧值)。而内存屏障可以与使无效队列交互,当CPU执行一个内存屏障时,会标记使无效队列中的所有条目,并强制所有后续装在操作必须等待这些条目处理完毕。加入内存屏障后,过程如下:
【注】:内存屏障可以保证从缓存行读取数据时不会读到本该无效的缓存行中的数据,所谓本该无效指的是,以收到使无效消息,亦进行了回复,但
五.读与写内存屏障
在前几个例子中可以看到,在foo函数中没必要标记使无效队列,而bar函数中没必要标记存储缓存。因此,很多CPU体系结构中提供了更弱的内存屏障指令:读内存屏障与写内存屏障。“读内存屏障”仅仅标记它的使无效队列,这样可以避免读取到本该无效但“使无效消息”待处理缓存行内的旧数据。“写内存屏障”仅仅标记存储缓冲,避免写造成的全局内存序失序。而完整的内存屏障同时保证写与读的顺序。以上代码可以改为:
void foo() {
a = 1;
smp_wmb();
b = 1;
}
void bar() {
while (b == 0) cointinue;
smp_rmb();
assert(a == 1);
}
六.X86的内存屏障
x86CPU提供“process ording(过程排序)”,因此所有CPU都会一致性看到某个特定CPU对内存的写操作,也因而将“写内存屏障”smp_wmp实现为一个空操作。但x86CPU传统上不保证装载顺序,即仍然需要“读内存屏障”。
七.再谈C++中的volatile(一个被误解颇深的关键字)
在知乎上看到了这篇对volatile的讨论https://zhuanlan.zhihu.com/p/33074506,感觉收获良多,且深觉与本篇前文所述内容息息相关,故在末尾追加了这一小节。
这篇文章指出了volatile并不能保可见性,或者换句话说,volatile只能禁止编译器对volatile变量的优化,保证每次从内存种读取且不优化掉于其相关的语句,但并不能保证按正确的顺序被访问,这主要由两个原因造成:1)编译器的优化;2)CPU的乱序访问(如前文所述)。
假设有如下这段程序:
bool flag = false;
int a = 1;
void thread1_Fun() {
flag = false;
while(true) {
if(flag == true) break;
}
assert(a != 1);
}
void thread2_Fun() {
a = 2;
flag = true;
}
这段代码问题有两处:1)编译器可能认为在thread1_Fun中从flag = false起到while循环中都没有对flag进行过修改,因此优化掉了if(flag == true) break; 2)在thread2_Fun中,尽管逻辑上a = 2需要发生在flag之前,但编译器与CPU并不知道,因此flag = true可能发生在a = 2之前(由于CPU优化和CPU乱序访问)。
那么将flag定义为volatile的呢?这样虽然可以保证flag每次都从内存读取,并保证编译器不会优化掉thread1_Fun中的 if(flag == true) break;但不能保证不优化a = 2到flag = true之前。
如果再将a定义为volatile的呢?很遗憾,这依然不可以保证其正确性,虽然这样避免了编译器对flag与a的优化,但由于CPU仍可能造成store-store乱序,即前文中第三节所述的情况,此时需要写内存屏障。此时可能有人会说,在如X86架构这种强内存序的CPU下,不会出现store-store乱序,只允许 sotre-load 乱序,因而,在这些条件下,这段代码是安全的。尽管如此,使用 volatile
会禁止编译器优化相关变量,从而降低性能,所以也不建议依赖 volatile
在这种情况下做线程同步。另一方面,这严重依赖具体的硬件规范,超出了本文的约定讨论范围。
上述问题可以通过原子操作解决,因为其保证了操作的原子性,同时构建了良好的内存屏障,因此整个代码的行为在标准下是良定义的。