处理器内存屏障用来解决处理器之间的内存访问乱序问题和处理器访问外围设备的乱序问题。
现代CPU的运算速度比现代内存系统的速度快得多,它们的速度差了几个数量级,那怎么办呢?硬件设计者想到了在内存和CPU之间加入一个速度足够快,但空间不是很大的存储空间,这个就是所谓的缓存。缓存的速度足够快,但是它一般是某个或某些CPU核独享的,而不像计算机的主存,一般认为是系统中所有CPU共享的。
一旦引入了缓存,就会引入多个地方存放同一个数据的问题,就有可能出现数据不一致的问题。假设变量X所在内存同时被两个CPU都缓存了,但是这时候CPU0对变量X的值做出了修改,这之后CPU1如果试图读取变量X的值时,其实读到的是老的值。
这个时候就需要所谓的缓存一致性协议了,一般常用的是MESI协议。MESI代表“Modified”、“Exclusive”、“Shared”和“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之间进行通信,会引入下面几种类型的消息:
通过上面的介绍可以看到,MESI缓存一致性协议可以保证系统中的各个CPU核上的缓存都是一致的。但是也带来了一个很大的问题,由于所有的操作都是“同步”的,必须要等待远端CPU完成指定操作后收到响应消息才能真正执行对应的存储或加载操作,这样会极大降低系统的性能。比如说,如果CPU0和CPU1上同时缓存了同一段数据,如果CPU0想对其进行更改,那么必须先发送使无效消息给CPU1,等到CPU1真的将该缓存的数据段标记成“Invalid”状态后,会向CPU0发送使无效应答消息,理论上只有CPU0收到这个消息后,才可以真的更改数据。但是,从要更改到真的能更改已经经过了好几个阶段了,这时CPU0只能等在那里。
鱼和熊掌都兼得是不可能的,想提高性能,只能稍微放松一下对缓存一致性的要求。具体的,会引入如下两个模块:
处理器的硬件工程师使用存储缓冲区和使无效队列协助缓存和缓存一致性协议实现高性能,引入了处理器之间的内存访问乱序问题。
(1)写操作乱序问题,或者叫存储乱序问题。
假设执行顺序如下。
处理器 0 首先写变量 a,然后写变量 b,可是处理器 1 看到变量 b 的新值时没有看到变量 a 的新值,看到的处理器 0 写的顺序好像是首先写变量 b,然后写变量 a。处理器 0 的存储缓冲区导致出现写操作乱序问题。
(2)读操作乱序问题,或者叫加载乱序问题。
假设处理器 0 和处理器 1 的缓存中都有变量 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 区域之前已经完成缓存一致的内存写操作。