目录:
(*) 内存访问抽象模型.
- 操作设备.
- 保证.
(*) 什么是内存屏障?
- 各式各样的内存屏障.
- 关于内存屏障, 不能假定什么?
- 数据依赖屏障.
- 控制依赖.
- SMP内存屏障的配对使用.
- 内存屏障举例.
- 读内存屏障与内存预取.
(*) 内核中显式的内存屏障.
- 编译优化屏障.
- CPU内存屏障.
- MMIO写屏障.
(*) 内核中隐式的内存屏障.
- 锁相关函数.
- 禁止中断函数.
- 睡眠唤醒函数.
- 其他函数.
(*) 跨CPU的锁的屏障作用.
- 锁与内存访问.
- 锁与IO访问.
(*) 什么地方需要内存屏障?
- 处理器间交互.
- 原子操作.
- 访问设备.
- 中断.
(*) 内核中I/O屏障的作用.
(*) 最小限度有序的假想模型.
(*) CPU cache的影响.
- Cache一致性.
- Cache一致性与DMA.
- Cache一致性与MMIO.
(*) CPU所能做到的.
- 特别值得一提的Alpha处理器.
(*) 使用示例.
- 环型缓冲区.
(*) 引用.
================
内存访问抽象模型
================
考虑如下抽象系统模型:
: :
+-------+ : +--------+ : +-------+
| | : | | : | |
| CPU 1|<----->| 内存 |<----->| CPU 2 |
| | : | | : | |
+-------+ : +--------+ : +-------+
^ : ^ : ^
| : | : |
| : v : |
| : +--------+ : |
| : | | : |
+---------->| 设备 |<----------+
: | | :
: +--------+ :
: :
假设每个CPU都分别运行着一个会触发内存访问操作的程序. 对于这样一个CPU, 其内存访问顺序是非常松散的, 在保证程序上下文逻辑关系的前提下, CPU可以按它所喜欢的顺序来执行内存操作. 类似的, 编译器也可以将它输出的指令安排成任何它喜欢的顺序, 只要保证不影响程序表面的执行逻辑.
(译注: 内存屏障是为应付内存访问操作的乱序执行而生的. 那么, 内存访问为什么会乱序呢? 这里先简要介绍一下:
现在的CPU一般采用流水线来执行指令. 一个指令的执行被分成: 取指, 译码, 访存, 执行,
写回, 等若干个阶段.
指令流水线并不是串行化的, 并不会因为一个耗时很长的指令在"执行"阶段呆很长时间, 而
导致后续的指令都卡在"执行"之前的阶段上.
相反, 流水线中的多个指令是可以同时处于一个阶段的, 只要CPU内部相应的处理部件未被占满. 比如说CPU有一个加法器和一个除法器, 那么一条加法指令和一条除法指令就可能同时处于"执行"阶段, 而两条加法指令在"执行"阶段就只能串行工作.
这样一来, 乱序可能就产生了. 比如一条加法指令出现在一条除法指令的后面, 但是由于除
法的执行时间很长, 在它执行完之前, 加法可能先执行完了. 再比如两条访存指令, 可能由
于第二条指令命中了cache(或其他原因)而导致它先于第一条指令完成.
一般情况下, 指令乱序并不是CPU在执行指令之前刻意去调整顺序. CPU总是顺序的去内存里面取指令, 然后将其顺序的放入指令流水线. 但是指令执行时的各种条件, 指令与指令之间的相互影响, 可能导致顺序放入流水线的指令, 最终乱序执行完成. 这就是所谓的"顺序流入, 乱序流出".
指令流水线除了在资源不足的情况下会卡住之外(如前所述的一个加法器应付两条加法指令), 指令之间的相关性才是导致流水线阻塞的主要原因.
下文中也会多次提到, CPU的乱序执行并不是任意的乱序, 而必须保证上下文依赖逻辑的正
确性. 比如: a++; b=f(a); 由于b=f(a)这条指令依赖于第一条指令(a++)的执行结果, 所以
b=f(a)将在"执行"阶段之前被阻塞, 直到a++的执行结果被生成出来.
如果两条像这样有依赖关系的指令挨得很近, 后一条指令必定会因为等待前一条执行的结果, 而在流水线中阻塞很久. 而编译器的乱序, 作为编译优化的一种手段, 则试图通过指令重排将这样的两条指令拉开距离, 以至于后一条指令执行的时候前一条指令结果已经得到了, 那么也就不再需要阻塞等待了.
相比于CPU的乱序, 编译器的乱序才是真正对指令顺序做了调整. 但是编译器的乱序也必须保证程序上下文的依赖逻辑.
由于指令执行存在这样的乱序, 那么自然, 由指令执行而引发的内存访问势必也可能乱序.
)
在上面的图示中, 一个CPU执行内存操作所产生的影响, 一直要到该操作穿越该CPU与系统中其他部分的界面(见图中的虚线)之后, 才能被其他部分所感知.
举例来说, 考虑如下的操作序列:
CPU 1 CPU 2
=============== ===============
{ A == 1; B == 2 }
A = 3; x = A;
B = 4; y = B;
这一组访问指令在内存系统(见上图的中间部分)上生效的顺序, 可以有24种不同的组合:
STORE A=3, STORE B=4, x=LOAD A->3, y=LOAD B->4
STORE A=3, STORE B=4, y=LOAD B->4, x=LOAD A->3
STORE A=3, x=LOAD A->3, STORE B=4, y=LOAD B->4
STORE A=3, x=LOAD A->3, y=LOAD B->2, STORE B=4
STORE A=3, y=LOAD B->2, STORE B=4, x=LOAD A->3
STORE A=3, y=LOAD B->2, x=LOAD A->3, STORE B=4
STORE B=4, STORE A=3, x=LOAD A->3, y=LOAD B->4
STORE B=4, ...
...
然后这就产生四种不同组合的结果值:
x == 1, y == 2
x == 1, y == 4
x == 3, y == 2
x == 3, y == 4
甚至于, 一个CPU在内存系统上提交的STORE操作还可能不会以相同的顺序被其他CPU所执行的LOAD操作所感知.
进一步举例说明, 考虑如下的操作序列:
CPU 1 CPU 2
======= =======
{ A == 1, B == 2, C = 3, P == &A, Q== &C }
B = 4; Q = P;
P = &B D = *Q;
这里有一处明显的数据依赖, 因为在CPU2上, LOAD到D里面的值依赖于从P获取到的地址. 在操作序列的最后, 下面的几种结果都是有可能出现的:
(Q == &A) 且 (D == 1)
(Q == &B) 且 (D == 2)
(Q == &B) 且 (D == 4)
注意, CPU2决不会将C的值LOAD到D, 因为CPU保证在将P的值装载到Q之后才会执行对*Q的LOAD操作(译注: 因为存在数据依赖).
操作设备
对于一些设备, 其控制寄存器被映射到一组内存地址集合上, 而这些控制寄存器被访问的顺序是至关重要的. 假设, 一个以太网卡拥有一些内部寄存器, 通过一个地址端口寄存器(A)和一个数据端口寄存器(D)来访问它们. 要读取编号为5的内部寄存器, 可能使用如下代码:
*A = 5;
x = *D;
但是这可能会表现为以下两个序列之一(译注: 因为从程序表面看, A和D是不存在依赖的):
STORE *A = 5, x = LOAD *D
x = LOAD *D, STORE *A = 5
其中的第二种几乎肯定会导致错误, 因为它在读取寄存器之后才设置寄存器的编号.
保证
对于一个CPU, 它最低限度会提供如下的保证:
(*) 对于一个CPU, 在它上面出现的有上下文依赖关系的内存访问将被按顺序执行. 这意味着:
Q = P; D = *Q;
CPU会顺序执行以下访存:
Q = LOAD P, D = LOAD *Q
并且总是按这样的顺序.
(*) 对于一个CPU, 重叠的LOAD和STORE操作将被按顺序执行. 这意味着:
a = *X; *X = b;
CPU只会按以下顺序执行访存:
a = LOAD *X, STORE *X = b
同样, 对于:
*X = c; d = *X;
CPU只会按以下顺序执行访存:
STORE *X = c, d = LOAD *X
(如果LOAD和STORE的目标指向同一块内存地址, 则认为是重叠).
还有一些事情是必须被假定或者必须不被假定的:
(*) 必须不能假定无关的LOAD和STORE会按给定的顺序被执行. 这意味着:
X = *A; Y = *B; *D = Z;
可能会得到如下几种执行序列之一:
X = LOAD *A, Y = LOAD *B, STORE *D = Z
X = LOAD *A, STORE *D = Z, Y = LOAD*B
Y = LOAD *B, X = LOAD *A, STORE *D = Z
Y = LOAD *B, STORE *D = Z, X = LOAD*A
STORE *D = Z, X = LOAD *A, Y = LOAD*B
STORE *D = Z, Y = LOAD *B, X = LOAD*A
(*) 必须假定重叠内存访问可能被合并或丢弃. 这意味着:
X = *A; Y = *(A + 4);
可能会得到如下几种执行序列之一:
X = LOAD *A; Y = LOAD *(A + 4);
Y = LOAD *(A + 4); X = LOAD *A;
{X, Y} = LOAD {*A, *(A + 4) };
同样, 对于:
*A = X; Y = *A;
可能会得到如下几种执行序列之一:
STORE *A = X; Y = LOAD *A;
STORE *A = Y = X;
===============
什么是内存屏障?
正如上面所说, 无关的内存操作会被按随机顺序有效的得到执行, 但是在CPU与CPU交互时或CPU与IO设备交互时, 这可能会成为问题. 我们需要一些手段来干预编译器和CPU, 使其限制指令顺序.
内存屏障就是这样的干预手段. 他们能保证处于内存屏障两边的内存操作满足部分有序. (
译注: 这里"部分有序"的意思是, 内存屏障之前的操作都会先于屏障之后的操作, 但是如果
几个操作出现在屏障的同一边, 则不保证它们的顺序. 这一点下文将多次提到.)
这样的强制措施是非常重要的, 因为系统中的CPU和其他设备可以使用各种各样的策略来提高性能, 包括对内存操作的乱序, 延迟和合并执行; 预取; 投机性的分支预测和各种缓存. 内存屏障用于禁用或抑制这些策略, 使代码能够清楚的控制多个CPU和/或设备的交互.
各式各样的内存屏障
内存屏障有四种基本类型:
(1) 写(STORE)内存屏障.
写内存屏障提供这样的保证: 所有出现在屏障之前的STORE操作都将先于所有出现在屏障之后的STORE操作被系统中的其他组件所感知.
写屏障仅保证针对STORE操作的部分有序; 不要求对LOAD操作产生影响.
随着时间的推移, 一个CPU提交的STORE操作序列将被存储系统所感知. 所有在写屏障之前的STORE操作将先于所有在写屏障之后的STORE操作出现在被感知的序列中.
[!] 注意, 写屏障一般需要与读屏障或数据依赖屏障配对使用; 参阅"SMP内存屏障配
对"章节. (译注: 因为写屏障只保证自己提交的顺序, 而无法干预其他代码读内
存的顺序. 所以配对使用很重要. 其他类型的屏障亦是同理.)
(2) 数据依赖屏障.
数据依赖屏障是读屏障的弱化版本. 假设有两个LOAD操作的场景, 其中第二个LOAD操作的结果依赖于第一个操作(比如, 第一个LOAD获取地址, 而第二个LOAD使用该地址去取数据), 数据依赖屏障确保在第一个LOAD获取的地址被用于访问之前, 第二个LOAD的目标内存已经更新.(译注: 因为第二个LOAD要使用第一个LOAD的结果来作为LOAD的目标, 这里存在着数据依赖. 由前面的"保证"章节可知, 第一个LOAD必定会在第二个LOAD之前执行, 不需要使用读屏障来保证顺序, 只需要使用数据依赖屏障来保证内存已刷新.)
数据依赖屏障仅保证针对相互依赖的LOAD操作的部分有序; 不要求对STORE操作,
独立的LOAD操作, 或重叠的LOAD操作产生影响.
正如(1)中所提到的, 在一个CPU看来, 系统中的其他CPU提交到内存系统的STORE操作序列在某一时刻可以被其感知到. 而在该CPU上触发的数据依赖屏障将保证, 对于在屏障之前发生的LOAD操作, 如果一个LOAD操作的目标被其他CPU的STORE操作所修改, 那么在屏障完成之时, 这个对应的STORE操作之前的所有STORE操作所产生的影响, 将被数据依赖屏障之后执行的LOAD操作所感知.
参阅"内存屏障举例"章节所描述的时序图.
[!] 注意, 对第一个LOAD的依赖的确是一个数据依赖而不是控制依赖. 而如果第二个
LOAD的地址依赖于第一个LOAD, 但并不是通过实际加载的地址本身这样的依赖条件, 那么这就是控制依赖, 需要一个完整的读屏障或更强的屏障. 参阅"控制依赖"相关章节.
[!] 注意, 数据依赖屏障一般要跟写屏障配对使用; 参阅"SMP内存屏障的配对使用"章
节.
(3) 读(LOAD)内存屏障.
读屏障包含数据依赖屏障的功能, 并且保证所有出现在屏障之前的LOAD操作都将先于所有出现在屏障之后的LOAD操作被系统中的其他组件所感知.
读屏障仅保证针对LOAD操作的部分有序; 不要求对STORE操作产生影响.
读内存屏障隐含了数据依赖屏障, 因此可以用于替代它们.
[!] 注意, 读屏障一般要跟写屏障配对使用; 参阅"SMP内存屏障的配对使用"章节.
(4) 通用内存屏障.
通用内存屏障保证所有出现在屏障之前的LOAD和STORE操作都将先于所有出现在屏障之后的LOAD和STORE操作被系统中的其他组件所感知.
通用内存屏障是针对LOAD和STORE操作的部分有序.
通用内存屏障隐含了读屏障和写屏障, 因此可以用于替代它们.
内存屏障还有两种隐式类型:
(5) LOCK操作.
它的作用相当于一个单向渗透屏障. 它保证所有出现在LOCK之后的内存操作都将在
LOCK操作被系统中的其他组件所感知之后才能发生.
出现在LOCK之前的内存操作可能在LOCK完成之后才发生.
LOCK操作总是跟UNLOCK操作配对出现的.
(6) UNLOCK操作.
它的作用也相当于一个单向渗透屏障. 它保证所有出现在UNLOCK之前的内存操作都将在UNLOCK操作被系统中的其他组件所感知之前发生.
出现在UNLOCK之后的内存操作可能在UNLOCK完成之前就发生了.
需要保证LOCK和UNLOCK操作严格按照相互影响的正确顺序出现.
(译注: LOCK和UNLOCK的这种单向屏障作用, 确保临界区内的访存操作不能跑到临界区外, 否则就起不到"保护"作用了.)
使用LOCK和UNLOCK之后, 一般就不再需要其他内存屏障了(但是注意"MMIO写屏障"章节中所提到的例外).
只有在存在多CPU交互或CPU与设备交互的情况下才可能需要用到内存屏障. 如果可以确保某段代码中不存在这样的交互, 那么这段代码就不需要使用内存屏障. (译注: CPU乱序执行指令, 同样会导致寄存器的存取顺序被打乱, 但是为什么不需要寄存器屏障呢? 就是因为寄存器是CPU私有的, 不存在跟其他CPU或设备的交互.)
注意, 对于前面提到的最低限度保证. 不同的体系结构可能提供更多的保证, 但是在特定体
系结构的代码之外, 不能依赖于这些额外的保证.
关于内存屏障, 不能假定什么?
Linux内核的内存屏障不保证下面这些事情:
(*) 在内存屏障之前出现的内存访问不保证在内存屏障指令完成之前完成; 内存屏障相当
于在该CPU的访问队列中画一条线, 使得相关访存类型的请求不能相互跨越. (译注:
用于实现内存屏障的指令, 其本身并不作为参考对象, 其两边的访存操作才被当作参
考对象. 所以屏障指令执行完成并不表示出现在屏障之前的访存操作已经完成. 而如
果屏障之后的某一个访存操作已经完成, 则屏障之前的所有访存操作必定都已经完成
了.)
(*) 在一个CPU上执行的内存屏障不保证会直接影响其他系统中的CPU或硬件设备. 只会间接影响到第二个CPU感知第一个CPU产生访存效果的顺序, 不过请看下一点:
(*) 不能保证一个CPU能够按顺序看到另一个CPU的访存效果, 即使另一个CPU使用了内存屏障, 除非这个CPU也使用了与之配对的内存屏障(参阅"SMP内存屏障的配对使用"章节).
(*) 不保证一些与CPU相关的硬件不会乱序访存. CPU cache一致性机构会在CPU之间传播内存屏障所带来的间接影响, 但是可能不是按顺序的.
[*] 更多关于总线主控DMA和一致性的问题请参阅:
Documentation/PCI/pci.txt
Documentation/PCI/PCI-DMA-mapping.txt
Documentation/DMA-API.txt
数据依赖屏障
数据依赖屏障的使用需求有点微妙, 并不总是很明显就能看出需要他们. 为了说明这一点,
考虑如下的操作序列:
CPU 1 CPU 2
=============== ===============
{ A == 1, B == 2, C = 3, P == &A, Q== &C }
B = 4;
<写屏障>
P = &B
Q = P;
D = *Q;
这里有明显的数据依赖, 在序列执行完之后, Q的值一定是&A和&B之一, 也就是:
(Q == &A) 那么 (D == 1)
(Q == &B) 那么 (D == 4)
但是! CPU 2可能在看到P被更新之后, 才看到B被更新, 这就导致下面的情况:
(Q == &B) 且 (D == 2) ????
虽然这看起来似乎是一个一致性错误或逻辑关系错误, 但其实不是, 并且在一些真实的CPU中就能看到这样的行为(就比如DEC Alpha).
为了解决这个问题, 必须在取地址和取数据之间插入一个数据依赖或更强的屏障:
CPU 1 CPU 2
======== =======
{ A == 1, B == 2, C = 3, P == &A, Q== &C }
B = 4;
<写屏障>
P = &B
Q = P;
<数据依赖屏障>
D = *Q;
这将强制最终结果是前两种情况之一, 而避免出现第三种情况.
[!] 注意, 这种非常违反直觉的情况最容易出现在cache分列的机器上, 比如, 一个cache组处理偶数号的cache行, 另一个cache组处理奇数号的cache行. P指针可能存储在奇数号的cache行中, 而B的值可能存储在偶数号的cache行中. 这样一来, 如果执行读操作的
CPU的偶数号cache组非常繁忙, 而奇数号cache组空闲, 它就可能看到P已被更新成新值(&B), 而B还是旧值(2).
另一个可能需要数据依赖屏障的例子是, 从内存读取一个数值, 用于计算数组的访问偏移:
CPU 1 CPU 2
========= ==========
{ M[0] == 1, M[1] == 2, M[3] = 3, P == 0,Q == 3 }
M[1] = 4;
<写屏障>
P = 1
Q = P;
<数据依赖屏障>
D = M[Q];
数据依赖屏障对于RCU非常重要, 举例来说. 参阅include/linux/rcupdate.h文件中的
rcu_dereference()函数. 这个函数使得当前RCU指针指向的对象被替换成新的对象时, 不会发生新对象尚未初始化完成的情况. (译注: 更新RCU对象时, 一般步骤是: 1-为新对象分配空间; 2-初始化新对象; 3-调用rcu_dereference()函数, 将对象指针指到新的对象上, 这
就意味着新的对象已生效. 这个过程中如果出现乱序访存, 可能导致对象指针的更新发生在新对象初始化完成之前. 也就是说, 新对象尚未初始化完成就已经生效了. 那么别的CPU就可能引用到一个尚未初始化完成的新对象, 从而出现错误.)
控制依赖
控制依赖需要使用一个完整的读内存屏障, 简单的数据依赖屏障不能使其正确工作. 考虑
下面的代码:
q = &a;
if (p)
q = &b;
<数据依赖屏障>
x = *q;
这段代码可能达不到预期的效果, 因为这里其实并不是数据依赖, 而是控制依赖, CPU可能
试图通过提前预测结果而对"if (p)"进行短路. 在这样的情况下, 需要的是:
q = &a;
if (p)
q = &b;
<读屏障>
x = *q;
(译注:例如:
CPU 1 CPU 2
=============== ===============
{ a == 1, b == 2, p == 0}
a = 3;
b = 4;
<写屏障>
p = 1;
q = &a;
if (p)
q = &b;
<数据依赖屏障>
x = *q;
CPU 1上的写屏障是为了保证这样的逻辑: 如果p == 1, 那么必定有a == 3&& b == 4.
但是到了CPU 2, 可能p的值已更新(==1), 而a和b的值未更新, 那么这时数据依赖屏障可以起作用, 确保x = *q时a和b的值更新. 因为从代码逻辑上说, q跟a或b是有所依赖的, 数据依赖屏障能保证这些有依赖关系的值都已更新.
然而, 换一个写法:
CPU 1 CPU 2
=============== ===============
{ a == 1, b == 2, p == 0}
p = 1;
<写屏障>
a = 3;
b = 4;
q = &a;
if (p)
q = &b;
<读屏障>
x = *q;
CPU 1上的写屏障是为了保证这样的逻辑: 如果a == 3 || b == 4, 那么必定有p == 1.
但是到了CPU 2, 可能a或b的值已更新, 而p的值未更新. 那么这时使用数据依赖屏障就不能保证p的更新. 因为从代码逻辑上说, p跟任何人都没有依赖关系. 这时必须使用读屏障, 以确保x = *q之前, p被更新.原文中"短路"的意思就是, 由于p没有数据依赖关系, CPU可以早早获得它的值, 而不必考虑更新.)
SMP内存屏障的配对使用
在处理CPU与CPU的交互时, 对应类型的内存屏障总是应该配对使用. 缺乏适当配对的使用基本上可以肯定是错误的.
一个写屏障总是与一个数据依赖屏障或读屏障相配对, 虽然通用屏障也可行. 类似的, 一个
读屏障或数据依赖屏障也总是与一个写屏障相配对, 尽管一个通用屏障也同样可行:
CPU 1 CPU 2
=============== ===============
a = 1;
<写屏障>
b = 2; x = b;
<读屏障>
y = a;
或:
CPU 1 CPU 2
=============== ===============
a = 1;
<写屏障>
b = &a; x= b;
<数据依赖屏障>
y = *x;
基本上, 读屏障总是需要用在这些地方的, 尽管可以使用"弱"类型.
[!] 注意, 在写屏障之前出现的STORE操作通常总是期望匹配读屏障或数据依赖屏障之后出现的LOAD操作, 反之亦然:
CPU 1 CPU 2
=============== ===============
a = 1; }---- --->{ v = c
b = 2; } \ / { w = d
<写屏障> \ <读屏障>
c = 3; } / \ { x = a;
d = 4; }---- --->{ y = b;
内存屏障举例
首先, 写屏障用作部分有序的STORE操作. 考虑如下的操作序列:
CPU 1
=======================
STORE A = 1
STORE B = 2
STORE C = 3
<写屏障>
STORE D = 4
STORE E = 5
这个操作序列会按顺序被提交到内存一致性系统, 而系统中的其他组件可能看到
{ STORE A, STORE B, STORE C }的组合出现在{ STORE D, STOREE }的组合之前, 而组合内部可能乱序:
+-------+ : :
| | +------+
| |------>| C=3 | } /\
| | : +------+ }----- \ -----> 操作被系统中的其他
| | : | A=1 | } \/ 组件所感知
| | : +------+ }
| CPU 1 | : | B=2 | }
| | +------+ }
| | wwwwwwwwwwwwwwww } <--- 在这一时刻, 写屏障要求在它之
| | +------+ } 前出现的STORE操作都先于在它
| | : | E=5 | } 之后出现的STORE操作被提交
| | : +------+ }
| |------>| D=4 | }
| | +------+
+-------+ : :
|
| CPU 1发起的STORE操作被提交到内存系统的顺序
|
V
其次, 数据依赖屏障用作部分有序的数据依赖LOAD操作. 考虑如下的操作序列:
CPU 1 CPU 2
============== =================
{ B = 7; X = 9; Y = 8; C = &Y }
STORE A = 1
STORE B = 2
<写屏障>
STORE C = &B LOAD X
STORE D = 4 LOAD C (得到&B)
LOAD *C (读取B)
没有干预的话, CPU 1的操作被CPU 2感知到的顺序是随机的, 尽管CPU 1执行了写屏障:
+-------+ : : : :
| | +------+ +-------+ | CPU 2所看到的
| |------>| B=2 |----- --->| Y->8 | | 更新序列
| | : +------+ \ +-------+ |
| CPU 1 | : | A=1 | \ --->| C->&Y | V
| | +------+ | +-------+
| | wwwwwwwwwwwwwwww | : :
| | +------+ | : :
| | : | C=&B |--- | : : +-------+
| | : +------+ \ | +-------+ | |
| |------>| D=4 | ----------->| C->&B |------>| |
| | +------+ | +-------+ | |
+-------+ : : | : : | |
| : : | |
| : : | CPU 2 |
| +-------+ | |
对B的取值显然不正确 ---> | | B->7 |------>| |
| +-------+ | |
| : : | |
| +-------+ | |
对X的LOAD延误了B的 ---> \ | X->9 |------>| |
一致性更新 \ +-------+ | |
----->| B->2 | +-------+
+-------+
: :
在上面的例子中, CPU 2看到的B的值是7, 尽管对*C(值应该是B)的LOAD发生在对C的LOAD之后.
但是, 如果一个数据依赖屏障被放到CPU 2的LOAD C和LOAD *C(假设值是B)之间:
CPU 1 CPU 2
======================= =======================
{ B = 7; X = 9; Y = 8; C = &Y }
STORE A = 1
STORE B = 2
<写屏障>
STORE C = &B LOAD X
STORE D = 4 LOAD C (获得&B)
<数据依赖屏障>
LOAD *C (读取B)
那么下面的情况将会发生:
+-------+ : : : :
| | +------+ +-------+
| |------>| B=2 |----- --->| Y->8 |
| | : +------+ \ +-------+
| CPU 1 | : | A=1 | \ --->| C->&Y |
| | +------+ | +-------+
| | wwwwwwwwwwwwwwww | : :
| | +------+ | : :
| | : | C=&B |--- | : : +-------+
| | : +------+ \ | +-------+ | |
| |------>| D=4 | ----------->| C->&B |------>| |
| | +------+ | +-------+ | |
+-------+ : : | : : | |
| : : | |
| : : | CPU 2 |
| +-------+ | |
| | X->9 |------>| |
| +-------+ | |
确保STORE C之前的影响 ---> \ ddddddddddddddddd | |
都被后续的LOAD操作感 \ +-------+ | |
知到 ----->| B->2 |------>| |
+-------+ | |
: : +-------+
第三, 读屏障用作部分有序的LOAD操作. 考虑如下事件序列:
CPU 1 CPU 2
======================= =======================
{ A = 0, B = 9 }
STORE A=1
<写屏障>
STORE B=2
LOAD B
LOAD A
没有干预的话, CPU 1的操作被CPU 2感知到的顺序是随机的, 尽管CPU 1执行了写屏障:
+-------+ : : : :
| | +------+ +-------+
| |------>| A=1 |------ --->| A->0 |
| | +------+ \ +-------+
| CPU 1 | wwwwwwwwwwwwwwww \ --->| B->9 |
| | +------+ | +-------+
| |------>| B=2 |--- | : :
| | +------+ \ | : : +-------+
+-------+ : : \ | +-------+ | |
---------->|B->2 |------>| |
| +-------+ | CPU 2 |
| | A->0 |------>| |
| +-------+ | |
| : : +-------+
\ : :
\ +-------+
---->| A->1 |
+-------+
: :
但是, 如果一个读屏障被放到CPU 2的LOAD B和LOAD A之间:
CPU 1 CPU 2
======================= =======================
{ A = 0, B = 9 }
STORE A=1
<写屏障>
STORE B=2
LOAD B
<读屏障>
LOAD A
那么CPU 1所施加的部分有序将正确的被CPU 2所感知:
+-------+ : : : :
| | +------+ +-------+
| |------>| A=1 |------ --->| A->0 |
| | +------+ \ +-------+
| CPU 1 | wwwwwwwwwwwwwwww \ --->| B->9 |
| | +------+ | +-------+
| |------>| B=2 |--- | : :
| | +------+ \ | : : +-------+
+-------+ : : \ | +-------+ | |
---------->|B->2 |------>| |
| +-------+ | CPU 2 |
| : : | |
| : : | |
在这一时刻, 读屏障导致 ----> \ rrrrrrrrrrrrrrrrr | |
STORE B之前的影响都被 \ +-------+ | |
CPU 2所感知 ---->| A->1 |------>| |
+-------+ | |
: : +-------+
为了更全面地说明这一点, 考虑一下如果代码在读屏障的两边都有一个LOAD A的话, 会发生什么:
CPU 1 CPU 2
======================= =======================
{ A = 0, B = 9 }
STORE A=1
<写屏障>
STORE B=2
LOAD B
LOAD A [第一次LOAD A]
<读屏障>
LOAD A [第二次LOAD A]
尽管两次LOAD A都发生在LOAD B之后, 它们也可能得到不同的值:
+-------+ : : : :
| | +------+ +-------+
| |------>| A=1 |------ --->| A->0 |
| | +------+ \ +-------+
| CPU 1 | wwwwwwwwwwwwwwww \ --->| B->9 |
| | +------+ | +-------+
| |------>| B=2 |--- | : :
| | +------+ \ | : : +-------+
+-------+ : : \ | +-------+ | |
---------->|B->2 |------>| |
| +-------+ | CPU 2 |
| : : | |
| : : | |
| +-------+ | |
| | A->0 |------>| 一次 |
| +-------+ | |
在这一时刻, 读屏障导致 ----> \ rrrrrrrrrrrrrrrrr | |
STORE B之前的影响都被 \ +-------+ | |
CPU 2所感知 ---->| A->1 |------>| 二次 |
+-------+ | |
: : +-------+
但是也可能CPU 2在读屏障结束之前就感知到CPU 1对A的更新:
+-------+ : : : :
| | +------+ +-------+
| |------>| A=1 |------ --->| A->0 |
| | +------+ \ +-------+
| CPU 1 | wwwwwwwwwwwwwwww \ --->| B->9 |
| | +------+ | +-------+
| |------>| B=2 |--- | : :
| | +------+ \ | : : +-------+
+-------+ : : \ | +-------+ | |
---------->|B->2 |------>| |
| +-------+ | CPU 2 |
| : : | |
\ : : | |
\ +-------+ | |
---->| A->1 |------>| 一次 |
+-------+ | |
rrrrrrrrrrrrrrrrr | |
+-------+ | |
| A->1 |------>| 二次 |
+-------+ | |
: : +-------+
这里只保证, 如果LOAD B得到的值是2的话, 第二个LOAD A能得到的值是1. 对于第一个LOAD A是不存在这样的保证的; 它可能得到A的值是0或是1.
读内存屏障与内存预取
许多CPU会对LOAD操作进行预取: 作为性能优化的一种手段, 当CPU发现它们将要从内存LOAD一个数据时, 它们会寻找一个不需要使用总线来进行其他LOAD操作的时机, 用于LOAD这个数据 - 尽管他们的指令执行流程实际上还没有到达该处LOAD的地方. 实际上, 这可能使得某些LOAD指令能够立即完成, 因为CPU已经预取到了所需要LOAD的值.
这也可能出现CPU实际上用不到这个预取的值的情况 - 可能因为一个分支而避开了这次LOAD - 在这样的情况下, CPU可以丢弃这个值或者干脆就缓存它以备后续使用.
考虑如下场景:
CPU 1 CPU 2
========= =============
LOAD B
DIVIDE } 除法指令通常消耗
DIVIDE } 很长的执行时间
LOAD A
这可能将表现为如下情况:
: : +-------+
+-------+ | |
--->| B->2 |------>| |
+-------+ | CPU 2 |
: :DIVIDE | |
+-------+ | |
CPU在执行除法指令 ---> -->|A->0 |~~~~ | |
的同时, 预取A +-------+ ~ | |
(译注: 此时总线空闲) : : ~ | |
: :DIVIDE | |
: : ~ | |
一旦除法结束, --> : : ~-->| |
CPU能马上使 : : | |
LOAD指令生效 : : +-------+
如果在第二个LOAD之前放一个读屏障或数据依赖屏障:
CPU 1 CPU 2
======== ===========
LOAD B
DIVIDE
DIVIDE
<读屏障>
LOAD A
这在一定程度上将迫使预取所获得的值, 根据屏障的类型而被重新考虑. 如果没有新的更新操作作用到已经被预取的内存地址, 则预取到的值就会被使用:
: : +-------+
+-------+ | |
--->| B->2 |------>| |
+-------+ | CPU 2 |
: :DIVIDE | |
+-------+ | |
CPU在执行除法指令 ---> -->|A->0 |~~~~ | |
的同时, 预取A +-------+ ~ | |
: : ~ | |
: :DIVIDE | |
: : ~ | |
: : ~ | |
rrrrrrrrrrrrrrrr~ | |
: : ~ | |
: : ~-->| |
: : | |
: : +-------+
但是, 如果存在一个来自于其他CPU的更新或失效, 那么预取将被取消, 并且重新载入值:
: : +-------+
+-------+ | |
--->| B->2 |------>| |
+-------+ | CPU 2 |
: :DIVIDE | |
+-------+ | |
CPU在执行除法指令 ---> -->|A->0 |~~~~ | |
的同时, 预取A +-------+ ~ | |
: : ~ | |
: :DIVIDE | |
: : ~ | |
: : ~ | |
rrrrrrrrrrrrrrrrr | |
+-------+ | |
预取被丢弃, 并且更 --> -->| A->1 |------>| |
新后的值被重新获取 +-------+ | |
: : +-------+
====================
内核中显式的内存屏障
linux内核拥有各式各样的屏障, 作用在不同层次上:
(*) 编译优化屏障.
(*) CPU内存屏障.
(*) MMIO写屏障.
编译优化屏障
Linux内核有一个显式的编译器屏障函数, 能够防止编译器优化将访存操作从它的任一侧移
到另一侧:
barrier();
这是一个通用屏障 - 弱类型的编译优化屏障并不存在.
编译优化屏障并不直接作用到CPU, CPU依然可以按其意愿乱序执行代码.
(译注: 既然编译优化屏障并不能限制CPU的乱序访存, 那么单纯的编译优化屏障能起到什么作用呢?以内核中的preempt_disable宏为例:
#define preempt_disable() \
do { \
inc_preempt_count(); \
barrier(); \
} while (0)
preempt_disable()和对应的preempt_enable()之间的代码是禁止内核抢占的, 通过对当前
进程的preempt_count进行++, 以标识进入禁止抢占状态(preempt_count==0时可抢占). 这里在对preempt_count自增之后, 使用了编译优化屏障.
如果不使用屏障, 本该在不可抢占状态下执行的指令可能被重排到preempt_count++之前(因为这些指令基本上不会对preempt_count有依赖). 而抢占可能是由中断处理程序来触发的, 在那些应该在不可抢占状态下执行的指令被执行之后, preempt_count++之前, 可能发生中断. 中断来了, preempt_count的值还是0, 于是进程可能会被错误的抢占掉.
究其原因, 是因为编译器看到的上下文依赖逻辑是静态的, 它不知道这段代码跟中断处理程序还存在依赖关系, 所以没法限制自己的乱序行为. 所以, 这里的编译优化屏障是必要的.
但是, 仅仅使用编译优化屏障就足够了么? 是的, 因为preempt_count这个变量是属于当前
进程的, 仅会被当前CPU访问.
CPU乱序可能导致后面应该在禁止抢占状态下执行的指令先于preempt_disable()执行完, 但是没有关系, 因为前面也提到过, CPU是"顺序流入, 乱序流出"的, 就算后面的指令先执行完,preempt_disable()也必定已经存在于流水线中了, CPU知道preempt_count变量将要被修改. 而触发抢占的代码肯定会检查preempt_count是否为0, 而这里的检查又将依赖于
preempt_disable()的修改结果, 必定在preempt_disable()完成之后才会进行.
究其原因, 是因为CPU看到的上下文依赖逻辑是动态的, 它不管指令是来自于普通的处理流程, 还是来自于中断处理程序, 只要指令存在依赖, 它都能发现. 所以, 对于类似这样的只被一个CPU所关注的内存访问, CPU的乱序访存并不会存在问题. )
CPU内存屏障
Linux内核有8种基本的CPU内存屏障:
类型 强制 SMP环境
=============== =======================
通用 mb() smp_mb()
写 wmb() smp_wmb()
读 rmb() smp_rmb()
数据依赖 read_barrier_depends() smp_read_barrier_depends()
(译注: 这里所说有SMP屏障是只在SMP环境下才生效的屏障, 而强制屏障则是不管在不在SMP环境下都生效的屏障. 这里所谓的SMP环境, 确切的说, 其实是内核的编译选项指定为SMP的情况, 并不是指实际运行内核的机器的环境. 不过既然编译选项指定了SMP环境, 那么编译生成的内核也基本上将会运行在SMP环境. 下面提到的UP环境亦是同理.)
除了数据依赖屏障之外, 所有的内存屏障都隐含了编译优化屏障的功能. 数据依赖屏障不对编译器输出的代码顺序造成任何额外的影响.
注: 在存在数据依赖关系的情况下, 编译器预期会将LOAD指令按正确的顺序输出(例如, 在`a[b]`语句中, 对b的load必须放在对a[b]的load之前), 但在C规范下, 并不保证编译器不去预测B的值(比如预测它等于1), 于是先load a再load b(比如, tmp =a[1]; if (b != 1) tmp = a[b];). 编译器在load a[b]之后又重新load b, 也可能会存在问题, 因为b拥有比a[b]更新的副本. 这些问题的解决尚未达成共识, 然而内核中的ACCESS_ONCE宏是解决问题的一个好的开始.
在UP系统中, SMP内存屏障将退化成编译器优化屏障, 因为它假定CPU能够保证自身的一致性, 并本身就能以正确的顺序处理重叠的内存访问.
[!] 注意, SMP内存屏障必须用于控制在SMP系统中的共享内存的引用顺序, 而使用锁也能够满足需求.
强制屏障不应该用来控制SMP的影响, 因为强制屏障会过多地增加UP系统的开销. 不过, 在使用MMIO来访问松散属性的IO内存窗口时, 强制屏障可以用来控制这些访存的影响. (译注:这里所指的内存窗口, 是假定对于CPU来说, 可以设置属于不同区间的内存地址拥有不同的属性. 这些属性可以指示一个内存段是否可以松散访问, 即乱序访问.) 强制屏障即使在非SMP环境下也可能需要, 因为它们可以通过禁止编译器和CPU的乱序访存, 从而影响设备感知到内存操作的顺序.
还有一些更高级的屏障函数:
(*) set_mb(var, value)
该函数将value赋值到var变量中, 然后取决于具体编译参数下的函数实现, 可能在之
后插入一个内存屏障. 在UP系统中, 它不能保证会插入编译优化屏障以外的其他屏障.
(*) smp_mb__before_atomic_dec();
(*) smp_mb__after_atomic_dec();
(*) smp_mb__before_atomic_inc();
(*) smp_mb__after_atomic_inc();
它们跟一些进行原子操作的函数配合使用, 这些函数进行了原子加法, 减法, 自增和
自减, 而又不将原子变量的值返回, 特别被用于引用计数. 这些原子操作本身并不隐
含内存屏障. (译注: 像这样被操作的原子变量, 多半是孤立而没有数据依赖的. 如果
有数据依赖, 那么依赖关系将在一定程度上限制CPU的乱序. 否则, CPU的乱序就完全
要靠内存屏障来限制了.)
举个例子, 考虑如下代码段, 它将object标识为已删除, 然后将其引用计数自减:
obj->dead = 1;
smp_mb__before_atomic_dec();
atomic_dec(&obj->ref_count);
这样可以确保设置删除标记在自减引用计数之前生效.
(*) smp_mb__before_clear_bit(void);
(*) smp_mb__after_clear_bit(void);
它们的用途类似于原子加减的屏障. 它们通常是跟一些进行按位解锁操作的函数配合
使用, 必须小心, 因为位操作本身也并不隐含内存屏障.
考虑这样一个场景, 程序通过清除锁定位来实施一些解锁性质的操作. clear_bit()函
数需要像这样的屏障:
smp_mb__before_clear_bit();
clear_bit( ... );
这样可以防止应该在锁定位被清除之前发生的内存操作漏到位清除之后去(译注: 注意
UNLOCK的屏障作用就是要保证它之前的访存操作一定先于它而完成). 关于UNLOCK操作的实现, 请参阅"锁相关函数"章节.
MMIO写屏障
对于内存映射IO的写操作, Linux内核还有一个特别的屏障:
mmiowb();
这是一个强制写屏障的变体, 能够将弱有序的IO内存窗口变成部分有序. 它的作用可能超出CPU与硬件的界面, 从而影响到许多层次上的硬件设备.
====================
内核中隐式的内存屏障
Linux内核中有一些其他的方法也隐含了内存屏障, 包括锁和调度方法.
这个范围是一个最低限度的保证; 一些特定的体系结构可能提供更多的保证, 但是在特定体系结构的代码之外, 不能依赖于它们.
锁相关函数
Linux内核有很多锁结构:
(*) spin locks
(*) R/W spin locks
(*) mutexes
(*) semaphores
(*) R/W semaphores
(*) RCU
在所有情况下, 它们都是LOCK操作和UNLOCK操作的变种. 这些操作都隐含一定的屏障:
(1) LOCK操作所隐含的:
在LOCK操作之后出现的内存操作, 一定在LOCK操作完成之后才会完成.
而在LOCK操作之前出现的内存操作, 可能在LOCK操作完成之后才完成.
(2) UNLOCK操作所隐含的:
在UNLOCK操作之前出现的内存操作, 一定在UNLOCK操作完成之前完成.
而在UNLOCK操作之后出现的内存操作, 可能在LOCK操作完成之前就完成了.
(3) LOCK操作+LOCK操作所隐含的:
在某个LOCK操作之前出现的所有LOCK操作都将在这个LOCK之前完成.
(4) LOCK操作+UNLOCK操作所隐含的:
在UNLOCK操作之前出现的所有LOCK操作都将在这个UNLOCK之前完成.
在LOCK操作之前出现的所有UNLOCK操作都将在这个LOCK之前完成.
(5) LOCK失败所隐含的:
某些变种的LOCK操作可能会失败, 比如可能因为不能立刻获得锁(译注: 如try_lock操
作), 再比如因为在睡眠等待锁变为可用的过程中接收到了未被阻塞的信号(译注: 如
semaphores的down_interruptible操作). 失败的锁操作不隐含任何屏障.
因此, 根据(1), (2)和(4), 一个无条件的LOCK跟在一个UNLOCK之后, 锁相当于一个完整的屏障, 而一个UNLOCK跟在一个LOCK之后并非如此.
[!] 注意: LOCK和UNLOCK只是单向的屏障, 其结果是, 临界区之外的指令可能会在临界区中执行.
一个UNLOCK跟在一个LOCK之后并不能认为是一个完整的屏障, 因为出现在LOCK之前的访存可能在LOCK之后才执行, 而出现在UNLOCK之后的访存可能在UNLOCK之前执行, 这两次访存可能会交叉:
*A = a;
LOCK
UNLOCK
*B = b;
可能表现为:
LOCK, STORE *B, STORE *A, UNLOCK
锁和信号量在UP环境下可能不提供顺序保证, 在这种情况下不能被认作是真正的屏障 - 特别是对于IO访问 - 除非结合中断禁用操作.
参阅"跨CPU的锁的屏障作用"章节.
例如, 考虑如下代码:
*A = a;
*B = b;
LOCK
*C = c;
*D = d;
UNLOCK
*E = e;
*F = f;
如下的事件序列都是可接受的:
LOCK, {*F,*A}, *E, {*C,*D}, *B, UNLOCK
[+] 注意, {*F,*A} 代表一次合并访问.
但是下面的序列都不可接受:
{*F,*A}, *B, LOCK, *C, *D, UNLOCK, *E
*A, *B, *C, LOCK, *D, UNLOCK, *E, *F
*A, *B, LOCK,*C, UNLOCK, *D, *E, *F
*B, LOCK, *C, *D, UNLOCK, {*F,*A}, *E
禁止中断函数
禁止中断(类似于LOCK)和启用中断(类似于UNLOCK)的函数只会起到编译优化屏障的作用. 所以, 如果在这种情况下需要使用内存或IO屏障, 必须采取其他手段.
睡眠唤醒函数
在一个全局事件标记上的睡眠和唤醒可以被看作是两条数据之间的交互: 正在等待事件的进程的状态, 和用于表示事件发生的全局数据. 为了确保它们按正确的顺序发生, 进入睡眠的原语和发起唤醒的原语都隐含了某些屏障.
首先, 睡眠进程通常执行类似于如下的代码序列:
for (;;) {
set_current_state(TASK_UNINTERRUPTIBLE);
if(event_indicated)
break;
schedule();
}
set_current_state()在它更改进程状态之后会自动插入一个通用内存屏障:
CPU 1
===============================
set_current_state();
set_mb();
STORE current->state
<通用屏障>
LOAD event_indicated
set_current_state()可能被包装在以下函数中:
prepare_to_wait();
prepare_to_wait_exclusive();
因此这些函数也隐含了一个在设置了进程状态之后的通用内存屏障. 以上的各个函数又被包装在其他一些函数中, 所有这些包装函数都相当于在对应的位置插入了内存屏障:
wait_event();
wait_event_interruptible();
wait_event_interruptible_exclusive();
wait_event_interruptible_timeout();
wait_event_killable();
wait_event_timeout();
wait_on_bit();
wait_on_bit_lock();
其次, 用作唤醒操作的代码通常是下面这样:
event_indicated = 1;
wake_up(&event_wait_queue);
或:
event_indicated = 1;
wake_up_process(event_daemon);
类似wake_up()的函数会隐含一个写内存屏障. 当且仅当它们的确唤醒了某个进程时. 屏障
出现在进程的睡眠状态被清除之前, 也就是在设置唤醒事件标记的STORE操作和将进程状态修改为TASK_RUNNING的STORE操作之间:
CPU 1 CPU 2
============= ===============================
set_current_state(); STORE event_indicated
set_mb(); wake_up();
STORE current->state <写屏障>
<通用屏障> STORE current->state
LOAD event_indicated
可用的唤醒函数包括:
complete();
wake_up();
wake_up_all();
wake_up_bit();
wake_up_interruptible();
wake_up_interruptible_all();
wake_up_interruptible_nr();
wake_up_interruptible_poll();
wake_up_interruptible_sync();
wake_up_interruptible_sync_poll();
wake_up_locked();
wake_up_locked_poll();
wake_up_nr();
wake_up_poll();
wake_up_process();
[!] 注意, 对于唤醒函数读写事件之前, 睡眠函数调用set_current_state()之后的那些
STORE操作, 睡眠和唤醒所隐含的内存屏障并不保证它们的顺序. 比如说, 如果睡眠
函数这样做:
set_current_state(TASK_INTERRUPTIBLE);
if (event_indicated)
break;
__set_current_state(TASK_RUNNING);
do_something(my_data);
而唤醒函数这样做:
my_data = value;
event_indicated = 1;
wake_up(&event_wait_queue);
睡眠函数并不能保证在看到my_data的修改之后才看到event_indicated的修改. 在这种情况下, 两边的代码必须在对my_data访存之前插入自己的内存屏障. 因此上述的睡眠函数应该这样做:
set_current_state(TASK_INTERRUPTIBLE);
if (event_indicated) {
smp_rmb();
do_something(my_data);
}
而唤醒函数应该这样做:
my_data = value;
smp_wmb();
event_indicated = 1;
wake_up(&event_wait_queue);
其他函数
其他隐含了屏障的函数:
(*) schedule()和类似函数隐含了完整的内存屏障.
(译注: schedule函数完成了进程的切换, 它的两边可能对应着两个不同的上下文. 如
果访存操作跨越schedule函数而进行了乱序, 那么基本上可以肯定是错误的.)
===================
跨CPU的锁的屏障作用
在SMP系统中, 锁定原语给出了多种形式的屏障: 其中一种在一些特定的锁冲突的情况下,
会影响其他CPU上的内存访问顺序.
锁与内存访问
假设系统中有(M)和(Q)这一对spinlock, 有三个CPU; 那么可能发生如下操作序列:
CPU 1 CPU 2
=========== =======
*A = a; *E = e;
LOCK M LOCK Q
*B = b; *F = f;
*C = c; *G = g;
UNLOCK M UNLOCK Q
*D = d; *H = h;
那么对于CPU 3来说, 从*A到*H的访问顺序是没有保证的, 不像单独的锁对应单独的CPU有那样的限制. 例如, CPU 3可能看到的顺序是:
*E, LOCK M, LOCK Q, *G, *C, *F, *A, *B,UNLOCK Q, *D, *H, UNLOCK M
但是它不会看到如下情况:
*B, *C or *D 先于 LOCK M
*A, *B or *C 后于 UNLOCK M
*F, *G or *H 先于 LOCK Q
*E, *F or *G 后于 UNLOCK Q
但是, 如果是下面的情形:
CPU 1 CPU 2
========= ======================
*A = a;
LOCK M [1]
*B = b;
*C = c;
UNLOCK M [1]
*D = d; *E = e;
LOCK M [2]
*F= f;
*G= g;
UNLOCK M [2]
*H= h;
CPU 3可能看到:
*E, LOCK M [1], *C, *B, *A, UNLOCK M [1],
LOCK M [2],*H, *F, *G, UNLOCK M [2], *D
但是如果CPU 1先得到锁, CPU 3不会看到下面的情况:
*B, *C, *D, *F, *G or *H 先于 LOCK M [1]
*A, *B or *C 后于 UNLOCK M [1]
*F, *G or *H 先于 LOCK M [2]
*A, *B, *C, *E, *F or *G 后于 UNLOCK M [2]
锁与IO访问
在某些情况下(特别是涉及到NUMA的情况), 两个CPU上发起的属于两个spinlock临界区的IO访问可能被PCI桥看成是交错发生的, 因为PCI桥并不一定参与cache一致性协议, 以至于无法响应读内存屏障.
例如:
CPU 1 CPU2
========= ==========
spin_lock(Q)
writel(0, ADDR)
writel(1, DATA);
spin_unlock(Q);
spin_lock(Q);
writel(4, ADDR);
writel(5, DATA);
spin_unlock(Q);
PCI桥可能看到的是:
STORE *ADDR = 0, STORE *ADDR = 4, STORE*DATA = 1, STORE *DATA = 5
这可能会引起硬件操作的错误.
这里所需要的是, 在释放spinlock之前, 使用mmiowb()作为干预, 例如:
CPU 1 CPU2
================= ===============
spin_lock(Q)
writel(0, ADDR)
writel(1, DATA);
mmiowb();
spin_unlock(Q);
spin_lock(Q);
writel(4, ADDR);
writel(5, DATA);
mmiowb();
spin_unlock(Q);
这样就能确保CPU 1的两次STORE操作先于CPU 2的STORE操作被PCI桥所看到.
此外, 对于同一硬件设备在进行STORE操作之后再进行LOAD操作, 可以省去mmiowb(), 因为LOAD操作将强制STORE操作在开始LOAD之前就完成:
CPU 1 CPU2
======== =======
spin_lock(Q)
writel(0, ADDR)
a = readl(DATA);
spin_unlock(Q);
spin_lock(Q);
writel(4, ADDR);
b = readl(DATA);
spin_unlock(Q);
=====================
什么地方需要内存屏障?
在正常操作下, 内存操作的乱序一般并不会成为问题, 即使是在SMP内核中, 一段单线程的线性代码也总是能够正确工作. 但是, 有四种情况, 乱序绝对可能是一个问题:
(*) 处理器间交互.
(*) 原子操作.
(*) 访问设备.
(*) 中断.
处理器间交互
当系统中拥有不止一个CPU时, 系统中的多个CPU可能在同一时间工作在同样的数据集上. 这将产生同步问题, 并且这样的问题通常要靠使用锁来解决. 但是, 锁是昂贵的, 所以不是万不得已的情况下最好不要使用锁. 在这种情况下, 为防止错误, 导致两个CPU相互影响的那些内存操作可能需要仔细协调好顺序.
比如, 考虑一下读写信号量的slow path. 信号量的等待队列里有一个进程正在等待, 这个
等待进程栈空间上的一段内存(译注: 也就是栈上分配的waiter结构)被链到信号量的等待链
表里:
struct rw_semaphore {
...
spinlock_tlock;
structlist_head waiters;
};
struct rwsem_waiter {
structlist_head list;
structtask_struct *task;
};
要唤醒这样一个等待进程, up_read()函数或up_write()函数需要这样做:
(1) 读取该等待进程所对应的waiter结构的next指针, 以记录下一个等待进程是谁;
(2) 读取waiter结构中的task指针, 以获取对应进程的进程控制块;
(3) 清空waiter结构中的task指针, 以表示这个进程正在获得信号量;
(4) 对这个进程调用wake_up_process()函数; 并且
(5) 释放waiter结构对进程控制块的引用计数.
换句话说, 这个过程会执行如下事件序列:
LOAD waiter->list.next;
LOAD waiter->task;
STORE waiter->task;
CALL wakeup
RELEASE task
而如果其中一些步骤发生了乱序, 那么整个过程可能会产生错误.
一旦等待进程将自己挂入等待队列, 并释放了信号量里的锁, 这个等待进程就不会再获得这个锁了(译注: 参阅信号量的代码, 它内部使用了一个spinlock来进行同步); 它要做的事情就是在继续工作之前, 等待waiter结构中的task指针被清空(译注: 然后自己会被唤醒). 而既然waiter结构存在于等待进程的栈上, 这就意味着, 如果在waiter结构中的next指针被读取之前, task指针先被清空了的话(译注: 等待进程先被唤醒了), 那么, 这个等待进程可能已经在另一个CPU上开始运行了(译注: 相对于唤醒进程所运行的CPU), 并且在up*()函数有机会读取到next指针之前, 栈空间上对应的waiter结构可能已经被复用了(译注: 被唤醒的进程从down*()函数返回, 然后可能进行新的函数调用, 导致栈空间被重复使用).
看看上面的事件序列可能会发生什么:
CPU 1 CPU 2
========== ====================
down_xxx()
将waiter结构链入等待队列
进入睡眠
up_yyy()
LOAD waiter->task;
STORE waiter->task;
被CPU 1的UP事件唤醒
<被抢占了>
重新得到运行
down_xxx()函数返回
继续调用foo()函数
foo()重用了栈上的waiter结构
<抢占返回>
LOAD waiter->list.next;
--- OOPS ---
对付这个问题可以使用信号量中的锁, 但是当进程被唤醒后, down_xxx()函数其实没必要重
新获得这个spinlock.
实际的解决办法是插入一个通用SMP内存屏障:
LOAD waiter->list.next;
LOAD waiter->task;
smp_mb();
STORE waiter->task;
CALL wakeup
RELEASE task
这样, 对于系统中的其他CPU来说, 屏障将保证屏障之前的所有内存访问先于屏障之后的所有内存访问发生. 屏障并不保证屏障之前的所有内存访问都在屏障指令结束之前完成.
在UP系统中 - 这种情况将不是问题 - smp_mb()函数只是一个编译优化屏障, 这就确保了编译器生成顺序正确的指令, 而不需要干预CPU. 既然只有一个CPU, 该CPU的数据依赖逻辑将处理所有事情.
原子操作
虽然原子操作在技术上实现了处理器之间的交互, 然而特别注意一些原子操作隐含了完整的内存屏障, 而另外一些则没有, 但是它们却作为一个群体被整个内核严重依赖.
许多原子操作修改内存中的一些状态, 并且返回该状态相关的信息(旧状态或新状态), 就在
其中实际操作内存的两边各隐含一个SMP环境下的通用内存屏障(smp_mb())(除显式的锁操作之外, 稍后说明). 它们包括:
xchg();
cmpxchg();
atomic_cmpxchg();
atomic_inc_return();
atomic_dec_return();
atomic_add_return();
atomic_sub_return();
atomic_inc_and_test();
atomic_dec_and_test();
atomic_sub_and_test();
atomic_add_negative();
atomic_add_unless(); /* 如果成功 (返回 1) */
test_and_set_bit();
test_and_clear_bit();
test_and_change_bit();
它们被用于作为类LOCK和类UNLOCK操作的实现, 和用于控制对象析构的引用计数, 这些情况下, 隐含内存屏障是有必要的.
以下操作由于没有隐含内存屏障, 会有潜在的问题, 但有可能被用于实现类UNLOCK这样的操作:
atomic_set();
set_bit();
clear_bit();
change_bit();
如果需要, 对应于这些函数, 可以使用相应的显式内存屏障(比如
smp_mb__before_clear_bit()).
下面这些函数也不隐含内存屏障, 并且在一些情况下, 可能也需要用到显式内存屏障(比如
smp_mb__before_atomic_dec()):
atomic_add();
atomic_sub();
atomic_inc();
atomic_dec();
如果它们用于产生统计, 那么他们可能就不需要内存屏障, 除非统计数据之间存在耦合.
如果它们被用作控制对象生命周期的引用计数, 那么它们可能并不需要内存屏障, 因为要么引用计数需要在一个锁的临界区里面进行调整, 要么调用者已经持有足够的引用而相当于拥有了锁(译注: 一般在引用计数减为0的时候需要将对应的对象析构, 如果调用者知道引用计数在某些情况下不可能减为0, 那么这个对象也就不可能在这些情况下被析构, 也就不需要通过内存屏障来避免访存乱序导致的对象在析构之后还被访问的情况), 这样的情况下并不需要内存屏障.
如果它们用于构成锁的一些描述信息, 那么他们可能就需要内存屏障, 因为锁原语一般需要按一定的顺序来操作.
基本上, 每个场景都需要仔细考虑是否需要使用内存屏障.
以下操作是特殊的锁原语:
test_and_set_bit_lock();
clear_bit_unlock();
__clear_bit_unlock();
它们都执行了类LOCK和类UNLOCK的操作. 相比其他操作, 它们应该优先被用于实现锁原语, 因为它们的实现可以在许多体系结构下得到优化.
[!] 注意, 这些特殊的内存屏障原语对一些情况也是有用的, 因为在一些体系结构的CPU上,
使用的原子操作本身就隐含了完整的内存屏障功能, 所以屏障指令在这里是多余的, 在
这样的情况下, 这些特殊的屏障原语将不使用额外的屏障操作.
访问设备
许多设备都可以被映射到内存, 因此在CPU看来, 它们只是一组内存地址. 为了控制这些设备, 驱动程序通常需要确保正确的内存访问按正确的顺序来执行.
但是, 聪明的CPU或者聪明的编译器却导致了潜在的问题, 如果CPU或编译器认为乱序, 或合并访问更有利于效率的话, 驱动程序代码中仔细安排的访存序列可能并不会按正确的顺序被送到设备上 - 从而可能导致设备的错误.
在Linux内核里面, IO访问应该使用适当的访问函数 - 例如inb()或writel() - 它们知道如
何得到恰当的访问顺序. 大多数情况下, 在使用这些函数之后就不必再显式的使用内存屏障, 但是在两种情况下, 内存屏障可能还是需要的:
(1) 在一些系统中, IO存储操作对于所有CPU来说并不是严格有序的, 所以对于所有的通用驱动程序(译注: 通用驱动程序需要适应各种体系结构的系统), 需要使用锁, 并且一定要在解锁临界区之前执行mmiowb()函数.
(2) 如果访存函数访问松散属性的IO内存窗口, 那么需要使用强制内存屏障来确保执行顺
序.
中断
驱动程序可能被它自己的中断处理程序所打断, 然后驱动程序中的这两个部分可能会相互干扰对方控制或访问设备的意图.
通过禁用本地中断(一种形式的锁)可能至少部分缓解这种情况, 这样的话, 驱动程序中的关
键操作都将包含在禁用中断的区间中. 于是当驱动程序的中断处理程序正在执行时, 驱动程序的核心代码不可能在相同的CPU上运行, 并且在当前中断被处理完之前中断处理程序不允许再次被调用, 于是中断处理程序就不需要再对这种情况使用锁.
但是, 考虑一个驱动程序正通过一个地址寄存器和一个数据寄存器跟以太网卡交互的情况.
假设驱动程序的核心代码在禁用中断的情况下操作了网卡, 然后驱动程序的中断处理程序
被调用:
LOCAL IRQ DISABLE
writew(ADDR, 3);
writew(DATA, y);
LOCAL IRQ ENABLE
<进入中断>
writew(ADDR, 4);
q = readw(DATA);
<退出中断>
如果执行顺序的规则足够松散, 对数据寄存器的写操作可能发生在第二次对地址寄存器的写操作之后:
STORE *ADDR = 3, STORE *ADDR = 4, STORE*DATA = y, q = LOAD *DATA
如果执行顺序像这样松散, 就需要假定在禁用中断区间内应该完成的访问可能泄漏到区间之外, 并且可能漏到中断过程中进行访问 - 反之亦然 - 除非使用隐式或显式的屏障.
通常这并不是一个问题, 因为禁用中断区间内完成的IO访存将会包含严格有序的同步LOAD操作, 形成隐式的IO屏障. 如果这还不够, 那么需要显式的调用一下mmiowb().
在一个中断服务程序与两个运行在不同CPU的程序相互通信的情况下, 类似的情况也可能发生. 如果出现这样的情况, 那么禁用中断的锁操作需要用于确保执行顺序. (译注: 也就是
类似于spinlock_irq这样的操作.)
===================
内核中I/O屏障的作用
在对IO内存进行存取的时候, 驱动程序应该使用适当的存取函数:
(*) inX(), outX():
它们都是倾向于跟IO空间打交道, 而不是普通内存空间, 不过这主要取决于具体CPU的 逻辑. i386和x86_64处理器确实有特殊的IO空间存取周期和指令, 但是许多系统结构的CPU却并没有这些概念.
包括PCI总线也可能会定义成IO空间 - 比如在i386和x86_64的CPU上 - 很容易将它映射到CPU的IO空间上. 但是, 它也可能作为虚拟的IO空间被映射到CPU的内存空间上, 特别对于那些不支持IO空间的CPU.
访问这些空间可能是完全同步的(比如在i386上), 但是对于桥设备(比如PCI主桥)可能
并不完全是这样.
他们能保证完全遵守IO操作之间的访问顺序.
他们不能保证完全遵从IO操作与其他类型的内存操作之间的访问顺序.
(*) readX(), writeX():
在发起调用的CPU上, 这些函数是否保证完全遵从内存访问顺序而且不进行合并访问,
取决于它们所访问的内存窗口上定义的属性. 例如, 较新的i386体系结构的机器, 可
以通过MTRR寄存器来控制内存窗口的属性.
通常, 只要不是访问预取设备, 这些函数将保证完全有序并且不进行合并访问.
但是对于桥设备(比如PCI桥), 如果它们愿意的话, 可能会倾向于对内存操作进行延迟
处理; 要冲刷一个STORE操作, 首选是对相同地址进行一次LOAD[*], 但是对于PCI来说, 对相同设备或相同的配置的IO空间进行一次LOAD就足够了.
[*] 注意! 试图从刚写过的地址LOAD数据, 可能会导致错误 - 比如对于16550 Rx/Tx
串口寄存器.
遇到带预取的IO内存, 可能需要使用mmiowb()屏障来强制让STORE操作有序.
关于PCI事务交互方面的更多信息, 请参阅PCI规范.
(*) readX_relaxed()
这些函数类似于readX(), 但是任何情况下都不保证有序. 请注意, 这里没有用到IO读
屏障.
(*) ioreadX(), iowriteX()
这些函数在进行访存的时候会根据访存类型选择适当的操作, inX()/outX()或
readX()/writeX().
======================
最小限度有序的假想模型
从概念上说, 必须假定的CPU是弱有序的, 但是它会保持程序上下文逻辑关系的外观. 一些CPU(比如i386或x86_64)比另一些(比如powerpc或frv)更具有约束力, 而在体系结构无关的代码中, 必须假定为最松散的情况(也就是DEC Alpha).
也就是说, 必须考虑到CPU可能会按它喜欢的顺序来执行操作 - 甚至并行执行 - 只是当指令流中的一条指令依赖于之前的一条指令时, 之前的这条指定才必须在后面这条指令可能被处理之前完全结束; 换句话说: 保持程序的上下文逻辑关系.
[*] 一些指令会产生不止一处影响 - 比如会修改条件码, 修改寄存器或修改内存 - 不同
的指令可能依赖于不同的影响.
CPU也可能丢弃那些最终不产生任何影响的操作序列. 比如, 如果两个相邻的指令都将一个立即数LOAD到寄存器, 那么第一个LOAD指令可能被丢弃.
类似的, 也需要假设编译器可能按它觉得舒服的顺序来调整指令流, 但同样也会保持程序的上下文逻辑关系.
===============
CPU cache的影响
操作cache中缓存的内存之后, 相应的影响会在整个系统间得到传播. 位于CPU和内存之间的cache, 和保持系统状态一致的内存一致性机构, 在一定程度上影响了传播的方法.
自从CPU与系统中其他部分的交互通过使用cache来实现以来, 内存系统就包含了CPU的缓存,而内存屏障基本上就工作在CPU和其cache之间的界面上(逻辑上说, 内存屏障工作在下图中虚线所示的地方):
<--- CPU ---> : <----------- 内存 ----------->
:
+--------+ +--------+ : +--------+ +-----------+
| | | | : | | | | +--------+
| CPU | | 内存 | : | CPU | | | | |
| 核心 |--->| 请求 |----->| Cache |<-->| | | |
| | | 队列 | : | | | |--->| 内存 |
| | | | : | | | | | |
+--------+ +--------+ : +--------+ | | | |
: | Cache | +--------+
: | 一致性 |
: | 机构 | +--------+
+--------+ +--------+ : +--------+ | | | |
| | | | : | | | | | |
| CPU | | 内存 | : | CPU | | |--->| 设备 |
| 核心 |--->| 请求 |----->| Cache |<-->| | | |
| | | 队列 | : | | | | | |
| | | | : | | | | +--------+
+--------+ +--------+ : +--------+ +-----------+
:
一些LOAD和STORE可能不会实际出现在发起操作的CPU之外, 因为在CPU自己的cache上就能满足需要, 尽管如此, 如果其他CPU关心这些数据, 那么完整的内存访问还是会发生, 因为cache一致性机构将迁移相应的cache行到访问它的CPU, 使一致性得到传播.
在保持程序所期望的上下文逻辑的前提下, CPU核心可能会按它认为合适的顺序来执行指令.一些指令会产生LOAD和STORE操作, 并且将它们放到内存请求队列中, 等待被执行. CPU核心可能会按它喜欢的顺序来将这些操作放进队列, 然后继续运行, 直到它必须等待这些访存指令完成的时候为止.
内存屏障所需要关心的是访存操作从CPU一侧穿越到内存一侧的顺序, 和系统中的其他部件感知到的操作发生的顺序.
[!] 对于一个CPU自己的LOAD和STORE来说, 并不需要使用内存屏障, 因为CPU总是能按程序执行顺序看到它们所执行的LOAD和STORE操作.
[!] MMIO或其他设备存取可能绕开cache系统. 这取决于访问设备所经过的内存窗口的属性和/或是否使用了CPU所特有的与设备进行交互的指令.
CACHE一致性
但是, 事情并不是像上面所说的那样简单: 因为虽然可以期望cache是一致的, 但是一致性
传播的顺序却是没有保证的. 也就是说, 虽然一个CPU所做出的更新将最终被其它CPU都看到, 但是却不保证其他CPU所看到的都是相同的顺序.
考虑这样一个系统, 它具有双CPU(1和2), 每个CPU有一对并行的数据cache(CPU 1对应A/B, CPU 2对应C/D):
:
: +--------+
: +---------+ | |
+--------+ : +--->| Cache A|<------->| |
| | : | +---------+ | |
| CPU 1 |<---+ | |
| | : | +---------+ | |
+--------+ : +--->| Cache B|<------->| |
: +---------+ | |
: | 内存 |
: +---------+ | 系统 |
+--------+ : +--->| Cache C|<------->| |
| | : | +---------+ | |
| CPU 2 |<---+ | |
| | : | +---------+ | |
+--------+ : +--->| Cache D|<------->| |
: +---------+ | |
: +--------+
:
想象一下该系统有如下属性:
(*) 一个奇数号的cache行可能被缓存在cache A, cacheC, 或者可能依然驻留在内存中;
(*) 一个偶数号的cache行可能被缓存在cache B, cacheD, 或者可能依然驻留在内存中;
(*) 而当CPU核心访问一个cache时, 另一个cache可以同时利用总线来访问系统中的其他部分 - 可能是替换一个脏的cache行或者进行预取;
(*) 每个cache都有一个操作队列, 被用于保持cache与系统中的其他部分的一致性;
(*) 当LOAD命中了已经存在于cache中的行时, 该一致性队列并不会得到冲刷, 尽管队列中的内容可能会影响这些LOAD操作. (译注: 也就是说, 队列中有针对某一cache行的更新操作正在等待被执行, 而这时LOAD操作需要读这个cache行. 这种情况下, LOAD并不会等待队列中的这个更新完成, 而是直接获取了更新前的值.)
接下来, 想象一下在第一个CPU上执行两个写操作, 并在它们之间使用一个写屏障, 以保证它们按要求的顺序到达该CPU的cache:
CPU 1 CPU 2 说明
=========== =========== ================
u== 0, v == 1 并且 p == &u, q == &u
v = 2;
smp_wmb(); 确保对v的修改先于对p的修改被感知
<A:modify v=2> v的值只存在于cache A中
p = &v;
<B:modify p=&v> p的值只存在于cache B中
写内存屏障保证系统中的其他CPU会按正确的顺序看到本地CPU cache的更新. 但是设想一下第二个CPU要去读取这些值的情形:
CPU 1 CPU 2 说明
========== =========== ==============
...
q = p;
x = *q;
上面这一对读操作可能不会在预期的顺序下执行, 比如持有p的cache行可能被更新到另一个CPU的cache, 而持有v的cache行因为其他一些cache事件的影响而延迟了对那个CPU的cache的更新:
CPU 1 CPU 2 说明
========== ============ =============
u== 0, v == 1 并且 p == &u, q == &u
v = 2;
smp_wmb();
<A:modify v=2> <C:busy>
<C:queue v=2>
p = &v; q= p;
<D:request p>
<B:modify p=&v> <D:commitp=&v>
<D:read p>
x = *q;
<C:read *q> 在v被更新到cache之前读取v
<C:unbusy>
<C:commit v=2>
基本上, 虽然最终CPU 2的两个cache行都将得到更新, 但是在没有干预的情况下, 并不能保证更新的顺序跟CPU 1提交的顺序一致.
我们需要在两次LOAD之间插入一个数据依赖屏障或读屏障, 以作为干预. 这将强制cache在处理后续的请求之前, 先让它的一致性队列得到提交:
CPU 1 CPU 2 说明
========= ============ ================
u== 0, v == 1 并且 p == &u, q == &u
v = 2;
smp_wmb();
<A:modify v=2> <C:busy>
<C:queue v=2>
p = &v; q= p;
<D:request p>
<B:modify p=&v> <D:commitp=&v>
<D:read p>
smp_read_barrier_depends()
<C:unbusy>
<C:commit v=2>
x = *q;
<C:read *q> 在v被更新到cache之后读取v
这些问题会在DEC Alpha处理器上遇到, 这些处理器使用了分列cache, 通过提高数据总线的利用率以提升性能. 虽然大部分的CPU在读操作依赖于读操作的时候, 会在第二个读操作中隐含一个数依赖屏障, 但是并不是所有CPU都这样, 因此不能依赖这一点.
其他的CPU也可能使用分列cache, 但对于普通的内存访问, 他们会协调各个cache列. 而
Alpha处理器的处理逻辑则取消了这样的协调动作, 除非使用内存屏障.
cache一致性与DMA
对于进行DMA操作的设备, 并不是所有系统都保持它们的cache一致性. 在这种情况下, 准备进行DMA的设备可能从RAM得到陈旧的数据, 因为脏的cache行可能还驻留在各个CPU的cache中, 而尚未写回到RAM. 为了解决这个问题, 内核的相应部分必须将cache中重叠的数据冲刷掉(或者使它们失效)(译注: 冲刷掉cache中的相应内容, 以保持cache与RAM的一致).
此外, 在设备已经通过DMA将数据写入RAM之后, 这些数据可能被cache写回RAM的脏的cache行所覆盖, 或者CPU已缓存的cache行可能直接掩盖了RAM被更新的事实(译注: 使得对应的LOAD操作只能获得cache中的旧值, 而无法得到RAM中的新值), 直到cache行被从CPU cache中丢弃并且重新由RAM载入. 为解决这个问题, 内核的相应部分必须将cache中重叠的数据失效.
cache一致性与MMIO
内存映射IO通常通过内存地址来触发, 这些地址是CPU内存空间的某个窗口中的一部分, 而这个窗口相比于普通RAM对应的窗口会有着不同的属性.
这些属性通常包含这样的情况: 访存会完全绕过cache, 而直接到达设备总线. 这意味着在
效果上, MMIO可能超越先前发出的对被缓存内存的访问(译注: 意思是, MMIO后执行, 但是先到达内存; 而先执行的写内存操作则可能被缓存在cache上, 之后才能冲刷到内存). 这种情况下, 如果这两者有某种依赖的话, 使用一个内存屏障并不足够, 而需要在写被缓存内存和MMIO访存之间将cache冲刷掉.
=============
CPU所能做到的
程序员可能会想当然地认为CPU将完全按照指定的顺序执行内存操作, 如果CPU是这样的话,比方说让它执行下面的代码:
a = *A;
*B = b;
c = *C;
d = *D;
*E = e;
对于每一条指令, 他们会期望CPU在完成内存操作之后, 才会去执行下一条指令, 于是系统中的其他组件将看到这样一个明确的操作序列:
LOAD *A, STORE *B, LOAD *C, LOAD *D,STORE *E.
当然, 实际情况是混乱的. 对于许多CPU和编译器来说, 上述假设不成立, 因为:
(*) LOAD操作可能更加需要立即完成以确保程序的执行速度(译注: 因为往往会有后续指令
需要等待LOAD的结果), 而STORE操作推迟一下往往并不会有问题;
(*) LOAD操作可以通过预取来完成, 并且在确认数据已经不需要之后, 预取结果可以丢弃;
(*) LOAD操作可以通过预取来完成, 导致结果被获取的时机可能并不符合期望的执行顺序;
(*) 内存访问的顺序可能被重新排列, 以促进更好的使用CPU总线和cache;
(*) 有一些内存或IO设备支持对相邻地址的批量访问, 在跟它们打交道的时候, LOAD和
STORE操作可能被合并, 从而削减访存事务建立的成本, 以提高性能(内存和PCI设备可能都可以这样做); 并且
(*) CPU的数据cache可能影响访问顺序, 尽管cache一致性机构可以缓解这个问题 - 一旦STORE操作命中了cache - 但并不能保证一致性将按顺序传播到其他CPU(译注: 如果 STORE操作命中了cache, 那么被更新过的脏数据可能会在cache中停留一段时间, 而不会立刻冲刷到内存中);
所以说, 另一个CPU可能将上面的代码看作是:
LOAD *A, ..., LOAD {*C,*D}, STORE *E,STORE *B
("LOAD {*C,*D}"是一个合并的LOAD)
但是, CPU将保证自身的一致性: 它将按正确的顺序看到自己的内存操作, 而不需要使用内
存屏障. 以下面的代码为例:
U = *A;
*A = V;
*A = W;
X = *A;
*A = Y;
Z = *A;
假设不存在外部的干扰, 那么可以肯定最终的结果一定是:
U == the original value of *A
U == *A的初始值
X == W
Z == Y
*A == Y
对于上面的代码, CPU可能产生的全部内存访问序列如下:
U=LOAD *A, STORE *A=V, STORE *A=W, X=LOAD *A, STORE *A=Y, Z=LOAD *A
然而, 对于这个序列, 如果没有干预, 在保证一致性的前提下, 序列中的一些操作也很可能
会被合并或丢弃.
在CPU看到这些操作之前, 编译器也可能会合并, 丢弃或推迟序列中的一些操作.
例如:
*A = V;
*A = W;
可能削减为:
*A = W;
于是, 在没有使用写屏障的情况下, 可以认为将V写入*A的STORE操作丢失了. 类似的:
*A = Y;
Z = *A;
在没有内存屏障的情况下, 可能削减为:
*A = Y;
Z = Y;
于是在该CPU之外, 根本就看不到有LOAD操作存在.
特别值得一提的Alpha处理器
DEC Alpha是现有的最为松散的CPU之一. 不仅如此, 许多版本的Alpha CPU拥有分列的数据cache, 允许他们在不同的时间更新两个语义相关的缓存. 因为内存一致性系统需要同步更新系统的两个cache, 数据依赖屏障在这里就真正成为了必要, 以使得CPU能够按正确的顺序来处理指针的更新和新数据的获取.