处理器内存屏障用来解决处理器之间的内存访问乱序问题和处理器访问外围设备的乱序问题。
现代CPU的运算速度比现代内存系统的速度快得多,它们的速度差了几个数量级,那怎么办呢?硬件设计者想到了在内存和CPU之间加入一个速度足够快,但空间不是很大的存储空间,这个就是所谓的缓存。缓存的速度足够快,但是它一般是某个或某些CPU核独享的,而不像计算机的主存,一般认为是系统中所有CPU共享的。
一旦引入了缓存,就会引入多个地方存放同一个数据的问题,就有可能出现数据不一致的问题。假设变量X所在内存同时被两个CPU都缓存了,但是这时候CPU0对变量X的值做出了修改,这之后CPU1如果试图读取变量X的值时,其实读到的是老的值。
这个时候就需要所谓的缓存一致性协议了,一般常用的是MESI协议。MESI代表“Modified”、“Exclusive”、“Shared”和“Invalid”四种状态的缩写,特定缓存行可以处在该协议采用的这四种状态上:
- 处于“Modified”状态的缓存行:当前CPU已经对缓存行的数据进行了修改,但是该缓存行的内容并没有在其它CPU的缓存中出现。因此,处于该状态的缓存行可以认为被当前CPU所“拥有”。这就是所谓的“脏”行,它的内容和内存中的内容不一样。由于只有当前CPU的缓存持有最新的数据,因此要么将“脏”数据写回到内存,要么将该数据“转移”给其它缓存。
- 处于“Exclusive”状态的缓存行:该状态非常类似于“Modified”状态,缓存的内容确保没有在其它CPU的缓存中出现。唯一的差别是,该缓存行还没有被当前的CPU修改,也就是说缓存行内容和内存中的是一样,是对内存数据的最新复制。但是,由于当前CPU能够在任何时刻将数据存储到该缓存行而不考虑其它CPU,因此处于“Exclusive”状态的缓存行也可以认为被当前CPU所“拥有”。
- 处于“Shared”状态的缓存行:表示缓存行的数据和主存中的一样,并且可能已经被复制到至少一个其它CPU的缓存中。但是,在没有得到其他CPU“许可”的情况下,任何CPU不能向该缓存行存储数据。与“Exclusive”状态相同,由于内存中的值是最新的,因此当需要丢弃该缓存行时,可以不用向内存回写。
- 处于“Invalid”状态的缓存行:表示该缓存行已经失效了,不能再被继续使用了。当有新数据进入缓存时,它可以直接放置到一个处于“Invalid”状态的缓存行上,不需要做其它的任何处理。
对于给定的两个缓存,以下是允许共同存在的状态:
M | E | S | I | |
M | ✘ | ✘ | ✘ | ✔ |
E | ✘ | ✘ | ✘ | ✔ |
S | ✘ | ✘ | ✔ | ✔ |
I | ✔ | ✔ | ✔ | ✔ |
EMSI状态转移图:
local read和local write分别代表本地CPU读写。remote read和remote write分别代表其他CPU读写。MESI协议在总线上的操作分成本地读写和总线操作。
上面这些操作,就是MESI协议规定的操作。初始状态下,当cache line中没有加载任何数据时,状态为I。本地读写指的是本地CPU读写自己私有的cache line,这是一个私有操作。总线读写指的是有总线的事务(bus transaction),因为实现的是总线监听协议,所以CPU可以发送请求到总线上,所有的CPU都可以收到这个请求。总之,总线读写的目标对象是远端CPU的高速缓存行,而本地读写的目标对象是本地CPU的高速缓存行。这些操作对我们理解 MESI状态的转换非常重要。
当前状态 | 事件 | 行为 | 下一个状态 |
I(invalid) | local read | 1.如果其他处理器中没有这份数据,本缓存从内存中取该数据,状态变为E 2.如果其他处理器中有这份数据,且缓存行状态为M,则先把缓存行中的内容写回到内存。本地cache再从内存读取数据,这时两个cache的状态都变为S 3.如果其他缓存行中有这份数据,并且其他缓存行的状态为S或E,则本地cache从内存中取数据,并且这些缓存行的状态变为S | E或S |
local write | 1.先从内存中取数据,如果其他缓存中有这份数据,且状态为M,则先将数据更新到内存再读取(个人认为顺序是这样的,其他CPU的缓存内容更新到内存中并且被本地cache读取时,两个cache状态都变为S,然后再写时把其他CPU的状态变为I,自己的变为M) 2.如果其他缓存中有这份数据,且状态为E或S,那么其他缓存行的状态变为I | M | |
remote read | remote read不影响本地cache的状态 | I | |
remote write | remote read不影响本地cache的状态 | I | |
E(exclusive) | local read | 状态不变 | E |
local write | 状态变为M | M | |
remote read | 数据和其他核共享,状态变为S | S | |
remote write | 其他CPU修改了数据,状态变为I | I | |
S(shared) | local read | 不影响状态 | S |
local write | 其他CPU的cache状态变为I,本地cache状态变为M | M | |
remote read | 不影响状态 | S | |
remote write | 本地cache状态变为I,修改内容的CPU的cache状态变为M | I | |
M(modified) | local read | 状态不变 | M |
local write | 状态不变 | M | |
remote read | 先把cache中的数据写到内存中,其他CPU的cache再读取,状态都变为S | S | |
remote write | 先把cache中的数据写到内存中,其他CPU的cache再读取并修改后,本地cache状态变为I。修改的那个cache状态变为M | I |
为了维护这个状态机,需要各个CPU之间进行通信,会引入下面几种类型的消息:
- 读消息:该消息包含要读取的缓存行的物理地址。
- 读响应消息:该消息包含较早前的读消息所请求的数据,这个读响应消息要么由物理内存提供,要么由某一个其它CPU上的缓存提供。例如,如果某一个CPU上的缓存拥有处于“Modified”状态的目标数据,那么该CPU上的缓存必须提供读响应消息。
- 使无效消息:该消息包含要使无效的缓存行的物理地址,所有其它CPU上的缓存必须移除相应的数据并且响应此消息。
- 使无效应答消息:一个接收到使无效消息的CPU必须在移除指定数据后响应一个使无效应答消息。
- 读使无效消息:该消息包含要被读取的缓存行的物理地址,同时指示其它CPU上的缓存移除对应的数据。因此,正如名字所示,它将读消息和使无效消息合并成了一条消息。读使无效消息同时需要一个读响应消息及一组使无效应答消息进行应答。
- 写回消息:该包含要回写到物理内存的地址和数据。这个消息允许缓存在必要时换出处于“Modified”状态的数据,以便为其它数据腾出空间。
通过上面的介绍可以看到,MESI缓存一致性协议可以保证系统中的各个CPU核上的缓存都是一致的。但是也带来了一个很大的问题,由于所有的操作都是“同步”的,必须要等待远端CPU完成指定操作后收到响应消息才能真正执行对应的存储或加载操作,这样会极大降低系统的性能。比如说,如果CPU0和CPU1上同时缓存了同一段数据,如果CPU0想对其进行更改,那么必须先发送使无效消息给CPU1,等到CPU1真的将该缓存的数据段标记成“Invalid”状态后,会向CPU0发送使无效应答消息,理论上只有CPU0收到这个消息后,才可以真的更改数据。但是,从要更改到真的能更改已经经过了好几个阶段了,这时CPU0只能等在那里。
鱼和熊掌都兼得是不可能的,想提高性能,只能稍微放松一下对缓存一致性的要求。具体的,会引入如下两个模块:
- 存储缓冲:cpu写数据之前先要得到缓存段的独占权,如果当前CPU没有独占权,要先让系统中别的CPU上缓存的同一段数据都变成无效状态。为了提高性能,可以引入一个叫做存储缓冲(Store Buffer)的模块,将其放置在每个CPU和它的缓存之间。当前CPU发起写操作,如果发现没有独占权,可以先将要写入的数据放在存储缓冲中,并继续运行,仿佛独占权瞬间就得到了一样。当然,存储缓冲中的数据最后还是会被同步到缓存中的,但就相当于是异步执行了,不会让CPU等了。并且,当前CPU在读取数据的时候应该首先检查其是否存在于存储缓冲中。
- 无效队列:如果当前CPU上收到一条消息,要使某个缓存段失效,但是此时缓存正在处理其它事情,那这个消息可能无法在当前的指令周期中得到处理,而会将其放入所谓的无效队列(Invalidation Queue)中,同时立即发送使无效应答消息。那个待处理的使无效消息将保存在队列中,直到缓存有空为止。
处理器的硬件工程师使用存储缓冲区和使无效队列协助缓存和缓存一致性协议实现高性能,引入了处理器之间的内存访问乱序问题。
(1)写操作乱序问题,或者叫存储乱序问题。
假设执行顺序如下。
-
- 处理器 0 写的变量 a 不在本地缓存中,发送“读并且使无效”消息,然后把变量a 的新值写到存储缓冲区中,接着继续执行指令。
- 处理器 0 写的变量 b 在本地缓存中,假设处理器 1 的缓存中不包含变量 b。
- 处理器 1 读变量 b,因为变量 b 的新值在处理器 0 的缓存中,处理器 1 可以看到变量 b 的新值。
- 处理器 1 读变量 a,因为变量 a 的新值在处理器 0 的存储缓冲区中,处理器 1 看不到变量 a 的新值,要等处理器 0 把存储缓冲区中的数据冲刷到缓存之后才能看到变量 a 的新值。
处理器 0 首先写变量 a,然后写变量 b,可是处理器 1 看到变量 b 的新值时没有看到变量 a 的新值,看到的处理器 0 写的顺序好像是首先写变量 b,然后写变量 a。处理器 0 的存储缓冲区导致出现写操作乱序问题。
(2)读操作乱序问题,或者叫加载乱序问题。
假设处理器 0 和处理器 1 的缓存中都有变量 a,执行顺序如下。
-
- 处理器 0 写变量 a,发送使无效消息。
- 处理器 1 把使无效消息存放到使无效队列中,立即发送使无效确认消息,没有执行使包含变量 a 的缓存行无效的操作。
- 处理器 0 写的变量 b 在本地缓存中,假设处理器 1 的本地缓存中没有变量 b。
- 处理器 1 读变量 b,发送读消息,收到处理器 0 的读响应消息,读到变量 b 的新值。
- 处理器 1 读变量 a,从缓存中读到变量 a 的旧值。
处理器 0 首先写变量 a,然后写变量 b,可是处理器 1 看到变量 b 的新值时没有看到变量 a 的新值,处理器 1 的使无效队列导致出现读操作乱序问题。
外围设备控制器的寄存器和物理内存使用统一的物理地址空间,把外围设备控制器的寄存器的物理地址映射到内核的虚拟地址空间,像访问内存一样访问外围设备控制器的寄存器,称为内存映射 I/O。访问外围设备控制器的寄存器时,顺序很重要。例如,假设一个以太网卡有多个内部寄存器,如果想要读取一个内存寄部器的值,首先往地址端口寄存器写入内部寄存器的索引,然后从数据端口寄存器读取值。假设地址端口寄存器映射到虚拟地址 A,数据端口寄存器映射到虚拟地址 D,读取内部寄存器 5 的值,其代码如下:
*A = 5;
x = *D;
编译器和处理器不能识别出这种依赖关系,编译器可能重新排列这两行代码的顺序。采用超标量体系结构和乱序执行技术的处理器,可能不会按照程序顺序执行这两行代码。
内核有 8 种基本的处理器内存屏障,如下表所示:
内存屏障类型 | 强制性的内存屏障 | SMP 内存屏障 |
通用内存屏障 | mb() | smp_mb() |
写内存屏障 | wmb() | smp_wmb() |
读内存屏障 | rmb() | smp_rmb() |
数据依赖屏障 | read_barrier_depends() | smp_read_barrier_depends() |
除了数据依赖屏障以外,所有的处理器内存屏障隐含编译器优化屏障。
SMP 内存屏障只在 SMP 系统中生效,解决处理器之间的内存访问乱序问题,在单处理器系统中退化为编译器优化屏障。
强制性的内存屏障在单处理器系统和 SMP 系统中都生效,在 SMP 系统中用来解决处理器之间的内存访问乱序问题和处理器访问外围设备的乱序问题,在单处理器系统中用来解决处理器访问外围设备的乱序问题。
写内存屏障解决写操作乱序问题,保证屏障前面的写操作看起来在屏障后面的写操作之前发生,也就是屏障前面的写操作必须在屏障后面的写操作之前被观察到,处理器之间的写操作乱序问题是由存储缓冲区引入的。
读内存屏障解决读操作乱序问题,保证屏障前面的读操作看起来在屏障后面的读操作之前发生,也就是屏障前面的读操作必须在屏障后面的读操作之前被观察到,处理器之间的读操作乱序问题是由使无效队列引入的。
通用内存屏障是写内存屏障和读内存屏障的组合,保证屏障前面的读和写操作看起来在屏障后面的读和写操作之前发生,也就是屏障前面的读和写操作必须在屏障后面的读和写操作之前被观察到。
解决处理器之间的内存访问乱序问题时,内存屏障必须配对使用:写者执行写内存屏障或通用内存屏障,读者执行读内存屏障或通用内存屏障,如下。
为什么内存屏障必须配对使用?因为处理器 1 读变量 a 的时候, 两种情况都可能出现。
(1)变量 a 的最新值在处理器 0 的存储缓冲区中,处理器 0 需要执行写内存屏障。
(2)处理器 1 的使无效队列包含使包含变量 a 的缓存行无效的消息,处理器 1 需要执行读内存屏障。
数据依赖屏障是更弱的读内存屏障,使用场合是第二个读操作依赖第一个读操作的结果,比如第一个读操作读指针的值,第二个读操作读指针指向的变量的值。
数据依赖屏障只在阿尔法(Alpha)处理器上生效,在其他处理器上是空操作。内核定义数据依赖屏障, 不直接使用读内存屏障, 目的是避免在除了阿尔法以外的处理器上产生额外的开销。
为什么阿尔法处理器需要数据依赖屏障?因为阿尔法处理器使用分区缓存,可以并行访问缓存的不同分区。假设下面的程序:
假设变量 B 的缓存行由缓存分区 0 处理,指针 P 的缓存行由缓存分区 1 处理。处理器1 执行“ B = 4”的时候,发送使无效消息,处理器 2 把使无效消息存放到使无效队列中,立即发送使无效确认消息。如果处理器 2 的缓存分区 0 很忙,缓存分区 1 空闲,缓存分区0 没有处理针对变量 B 的使无效消息,那么处理器 2 可能看见指针 P 的新值和变量 B 的旧值: P 的值是 B 的地址, B 的值是 2。
使用数据依赖屏障可以解决问题,其代码如下:
处理器 2 的缓存分区 0 执行数据依赖屏障,处理使无效队列中的消息,然后执行“ D =*Q”,如果看到指针 P 的值是变量 B 的地址,那么一定看到变量 B 的新值 4。
除了基本的内存屏障,内核还提供了以下高级的屏障函数。
(1) smp_store_mb(var, value)
给变量赋值,然后执行通用内存屏障。
(2) smp_mb__before_atomic()
放在原子操作函数的前面,执行通用内存屏障。例如:
*A = 5;
x = *D;obj->dead = 1;
smp_mb__before_atomic();
atomic_dec(&obj->ref_count);
(3) smp_mb__after_atomic()
放在原子操作函数的后面,执行通用内存屏障。
(4) lockless_dereference(p)
读取指针的值,里面封装了数据依赖屏障“ smp_read_barrier_depends()”。
(5) dma_wmb()和 dma_rmb()
保证访问处理器和支持 DMA 能力的设备共享的内存时写或读有序。
例如,假设设备驱动和设备共享内存,使用一个描述符状态值指示描述符属于设备或处理器,使用一个门铃在新的描述符可用时通知设备:
if (desc->status != DEVICE_OWN) {
/* 拥有描述符后才读数据 */
dma_rmb();
/* 读/修改数据 */
read_data = desc->data;
desc->data = write_data;
/* 在更新状态之前冲刷修改 */
dma_wmb();
/* 分配所有权 */
desc->status = DEVICE_OWN;
/* 在通过内存映射I/O通知设备之前强制同步内存 */
wmb();
/* 把新的描述符通告给设备 */
writel(DESC_NOTIFY, doorbell);
}
dma_rmb()保证处理器从描述符读数据之前设备释放了所有权。 dma_wmb()保证在设备看到它得到所有权之前把数据写到描述符。 wmb()保证在写到缓存不一致的内存映射 I/O 区域之前已经完成缓存一致的内存写操作。