Linux kernel memory barriers 【ChatGPT】
Linux内核内存屏障
免责声明
本文档不是一个规范;它故意(为了简洁)和无意(因为是人类)不完整。本文档旨在指导如何使用Linux提供的各种内存屏障,但如果有任何疑问(而且有很多),请咨询。一些疑问可能通过参考tools/memory-model/中的正式内存一致性模型和相关文档来解决。尽管如此,即使这个内存模型也应该被视为其维护者的集体意见,而不是一个绝对的权威。
重申一下,本文档不是Linux对硬件的期望规范。
本文档的目的是双重的:
- 指定可以依赖于任何特定屏障的最小功能。
- 提供如何使用可用屏障的指南。
请注意,一个体系结构可以为任何特定屏障提供更多的功能,但如果该体系结构提供的功能少于最小要求,那么该体系结构是错误的。
还要注意,由于体系结构的工作方式使得显式屏障在某些情况下是不必要的,因此屏障可能对于某个体系结构来说是一个空操作。
目录
- 抽象内存访问模型
- 什么是内存屏障?
- 显式内核屏障
- 隐式内核内存屏障
- CPU间获取屏障效果
- 内存屏障的需求
- 内核I/O屏障效果
- 假定的最小执行顺序模型
- CPU缓存的影响
- CPU的行为
- 示例用途
- 参考文献
抽象内存访问模型
考虑以下系统的抽象模型:
: :
: :
: :
+-------+ : +--------+ : +-------+
| | : | | : | |
| | : | | : | |
| CPU 1 |<----->| Memory |<----->| CPU 2 |
| | : | | : | |
| | : | | : | |
+-------+ : +--------+ : +-------+
^ : ^ : ^
| : | : |
| : | : |
| : v : |
| : +--------+ : |
| : | | : |
| : | | : |
+---------->| Device |<----------+
: | | :
: | | :
: +--------+ :
: :
每个CPU执行生成内存访问操作的程序。在抽象CPU中,内存操作的顺序非常松散,一个CPU实际上可以以任何顺序执行内存操作,只要程序因果关系似乎得到维护。同样,编译器也可以以任何顺序排列它发出的指令,只要它不影响程序的表面操作。
因此,在上面的图中,由CPU执行的内存操作的效果被系统的其余部分感知,因为操作穿过CPU和系统其余部分之间的接口(虚线)。
例如,考虑以下事件序列:
CPU 1 CPU 2
=============== ===============
{ A == 1; B == 2 }
A = 3; x = B;
B = 4; y = A;
由中间的内存系统看到的访问集可以以24种不同的组合排列:
STORE A=3, STORE B=4, y=LOAD A->3, x=LOAD B->4
STORE A=3, STORE B=4, x=LOAD B->4, y=LOAD A->3
STORE A=3, y=LOAD A->3, STORE B=4, x=LOAD B->4
STORE A=3, y=LOAD A->3, x=LOAD B->2, STORE B=4
STORE A=3, x=LOAD B->2, STORE B=4, y=LOAD A->3
STORE A=3, x=LOAD B->2, y=LOAD A->3, STORE B=4
STORE B=4, STORE A=3, y=LOAD A->3, x=LOAD B->4
STORE B=4, ...
因此可能会产生四种不同的值组合:
x == 2, y == 1
x == 2, y == 3
x == 4, y == 1
x == 4, y == 3
此外,CPU提交给内存系统的存储可能不会被另一个CPU进行的加载以与存储提交的顺序相同的顺序感知。
作为另一个例子,考虑以下事件序列:
CPU 1 CPU 2
=============== ===============
{ A == 1, B == 2, C == 3, P == &A, Q == &C }
B = 4; Q = P;
P = &B; D = *Q;
这里存在明显的地址依赖性,因为CPU 2中D中加载的值取决于CPU 2从P检索的地址。在序列结束时,可能出现以下任何一种结果:
(Q == &A) and (D == 1)
(Q == &B) and (D == 2)
(Q == &B) and (D == 4)
请注意,CPU 2永远不会尝试将C加载到D中,因为CPU将在发出*Q的加载之前将P加载到Q中。
设备操作
一些设备将它们的控制接口呈现为一组内存位置,但访问控制寄存器的顺序非常重要。例如,想象一个以地址端口寄存器(A)和数据端口寄存器(D)访问一组内部寄存器的以太网卡。要读取内部寄存器5,可能会使用以下代码:
*A = 5;
x = *D;
但这可能会显示为以下两个序列之一:
STORE *A = 5, x = LOAD *D
x = LOAD *D, STORE *A = 5
第二个序列几乎肯定会导致故障,因为它在尝试读取寄存器之后设置了地址。
保证
CPU可能会有一些最小保证:
-
在任何给定的CPU上,相关的内存访问将按顺序发出,与自身相关。这意味着对于:
Q = READ_ONCE(P); D = READ_ONCE(*Q);
CPU将发出以下内存操作:
Q = LOAD P, D = LOAD *Q
并且总是按照这个顺序。然而,在DEC Alpha上,READ_ONCE()还会发出一个内存屏障指令,因此DEC Alpha CPU将发出以下内存操作:
Q = LOAD P, MEMORY_BARRIER, D = LOAD *Q, MEMORY_BARRIER
无论是在DEC Alpha上还是不在DEC Alpha上,READ_ONCE()也会防止编译器的恶作剧。
-
在特定CPU内部,重叠的加载和存储将会按顺序出现。这意味着对于:
a = READ_ONCE(*X); WRITE_ONCE(*X, b);
CPU将只发出以下内存操作序列:
a = LOAD *X, STORE *X = b
而对于:
WRITE_ONCE(*X, c); d = READ_ONCE(*X);
CPU将只发出:
STORE *X = c, d = LOAD *X
(如果它们针对重叠的内存片段,则加载和存储会重叠)。
必须或不得假设的事项:
-
不得假设编译器会按照你的意愿处理未受READ_ONCE()和WRITE_ONCE()保护的内存引用。没有这些保护,编译器有权进行各种“创造性”的转换,这些转换在编译器屏障部分有所涉及。
-
不得假设独立的加载和存储将按照给定的顺序发出。这意味着对于:
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; *(A + 4) = Y;
我们可能会得到以下任一序列:
STORE *A = X; STORE *(A + 4) = Y; STORE *(A + 4) = Y; STORE *A = X; STORE {*A, *(A + 4) } = {X, Y};
反保证:
-
这些保证不适用于位域,因为编译器通常会生成代码,使用非原子的读-修改-写序列来修改这些位域。不要尝试使用位域来同步并行算法。
-
即使在位域受锁保护的情况下,给定位域中的所有字段都必须受到一个锁的保护。如果给定位域中的两个字段受到不同锁的保护,编译器的非原子读-修改-写序列可能导致对一个字段的更新损坏相邻字段的值。
-
这些保证仅适用于正确对齐和大小的标量变量。"正确大小"目前意味着与"char"、"short"、"int"和"long"大小相同的变量。"正确对齐"意味着自然对齐,因此在32位和64位系统上,对于"char"没有约束,对于"short"是两字节对齐,对于"int"是四字节对齐,对于"long"是四字节或八字节对齐。请注意,这些保证是在C11标准中引入的,因此在使用较旧的C11之前的编译器(例如gcc 4.6)时要小心。包含这一保证的标准部分是第3.14节,其中定义了"内存位置"如下:
- 内存位置
要么是标量类型的对象,要么是所有宽度均非零的相邻位域的最大序列
注释1:两个执行线程可以更新和访问不相互干扰的分离内存位置。
注释2:如果一个位域和一个相邻的非位域成员在同一内存位置,则它们是分离的内存位置。如果一个位域声明在嵌套结构声明内部,而另一个没有声明在嵌套结构声明内部,或者它们之间有一个零长度的位域声明,或者它们之间有一个非位域成员声明,那么两个位域也是分离的内存位置。如果它们之间的所有成员都是位域,那么在同一结构中并发更新两个位域是不安全的,无论这些中间位域的大小是多少。
- 内存位置
什么是内存屏障?
如上所述,独立的内存操作实际上是以随机顺序执行的,但这对于 CPU-CPU 交互和 I/O 来说可能会成为问题。需要的是一种干预方式,指示编译器和 CPU 限制执行顺序。
内存屏障就是这样的干预方式。它们在屏障两侧的内存操作上施加了一种感知到的部分顺序。
这种强制执行很重要,因为系统中的 CPU 和其他设备可以使用各种技巧来提高性能,包括重新排序、延迟和组合内存操作;推测加载;推测分支预测和各种类型的缓存。内存屏障用于覆盖或抑制这些技巧,使代码能够合理地控制多个 CPU 和/或设备的交互。
内存屏障的种类
内存屏障有四种基本类型:
-
写入(或存储)内存屏障。
写入内存屏障保证了屏障之前指定的所有存储操作将在屏障之后指定的所有存储操作之前发生,与系统的其他组件相关。
写入屏障只对存储进行部分排序;它不需要对加载产生任何影响。
CPU 可以被视为随着时间的推移将一系列存储操作提交到内存系统。写入内存屏障之前的所有存储操作将发生在写入内存屏障之后的所有存储操作之前。
[!] 请注意,写入内存屏障通常应与读取或地址依赖性屏障配对使用;请参阅“SMP 屏障配对”子节。
-
地址依赖性屏障(历史性)。
地址依赖性屏障是读取屏障的一种较弱形式。在执行两个加载操作时,第二个加载操作依赖于第一个加载操作的结果(例如,第一个加载操作检索第二个加载操作将要访问的地址),则需要地址依赖性屏障来确保第一个加载操作获取的地址在第二个加载操作访问之后更新。
地址依赖性屏障只对相互依赖的加载进行部分排序;它不需要对存储、独立加载或重叠加载产生任何影响。
如(1)中所述,系统中的其他 CPU 可以被视为将存储序列提交到 CPU 可以感知的内存系统。由正在考虑的 CPU 发出的地址依赖性屏障保证了对于任何在其之前的加载,如果该加载触及另一个 CPU 的存储序列中的一个,那么在屏障完成时,触及加载之前的所有存储的效果将对在地址依赖性屏障之后发出的任何加载可感知。
请参阅“内存屏障序列示例”子节,其中显示了排序约束的图表。
[!] 请注意,第一个加载操作确实必须具有_地址_依赖性,而不是控制依赖性。如果第二个加载操作的地址依赖于第一个加载操作,但依赖性是通过条件而不是实际加载地址本身进行的,则这是_控制_依赖性,需要使用完整的读取屏障或更好的屏障。请参阅“控制依赖性”子节以获取更多信息。
[!] 请注意,地址依赖性屏障通常应与写入屏障配对使用;请参阅“SMP 屏障配对”子节。
[!] Linux 内核 v5.9 删除了用于显式地址依赖性屏障的内核 API。现在,用于标记从共享变量(例如 READ_ONCE() 和 rcu_dereference())加载的 API 提供了隐式地址依赖性屏障。
-
读取(或加载)内存屏障。
读取屏障是地址依赖性屏障加上保证了屏障之前指定的所有加载操作将在屏障之后指定的所有加载操作之前发生,与系统的其他组件相关。
读取屏障只对加载进行部分排序;它不需要对存储产生任何影响。
读取内存屏障意味着地址依赖性屏障,因此可以替代它们。
[!] 请注意,读取屏障通常应与写入屏障配对使用;请参阅“SMP 屏障配对”子节。
-
通用内存屏障。
通用内存屏障保证了屏障之前指定的所有加载和存储操作将在屏障之后指定的所有加载和存储操作之前发生,与系统的其他组件相关。
通用内存屏障对加载和存储都进行部分排序。
通用内存屏障意味着读取和写入内存屏障,因此可以替代它们。
还有两种隐式的类型:
-
获取操作。
这充当单向可渗透屏障。它保证了获取操作之后的所有内存操作将在获取操作完成之后与系统的其他组件相关发生。
获取操作包括锁操作以及 smp_load_acquire() 和 smp_cond_load_acquire() 操作。
发生在获取操作之前的内存操作可能会在其完成之后发生。
获取操作几乎总是应该与释放操作配对使用。
-
释放操作。
这也充当单向可渗透屏障。它保证了释放操作之前的所有内存操作将在释放操作完成之前与系统的其他组件相关发生。
释放操作包括解锁操作和 smp_store_release() 操作。
发生在释放操作之后的内存操作可能会在其完成之前发生。
使用获取和释放操作通常排除了对其他类型内存屏障的需求。此外,释放+获取对并不保证作为完整内存屏障。然而,在给定变量上的获取操作后,所有在该变量上之前的任何释放操作之前的内存访问都保证是可见的。换句话说,在给定变量的临界区内,对于该变量的所有先前临界区的所有访问都保证已完成。
这意味着获取操作充当了最小的“获取”操作,释放操作充当了最小的“释放”操作。
atomic_t.txt 中描述的原子操作的子集具有除了完全排序和松散(无屏障语义)定义之外的获取和释放变体。对于执行加载和存储的复合原子操作,获取语义仅适用于加载部分,释放语义仅适用于存储部分。
只有在两个 CPU 之间或在 CPU 和设备之间存在交互的可能性时才需要内存屏障。如果可以保证在任何特定代码片段中不会有任何这样的交互,那么该代码片段中就不需要内存屏障。
请注意,这些是_最小_保证。不同的架构可能提供更实质性的保证,但不能依赖于架构特定代码之外的保证。
关于内存屏障,有一些事情在Linux内核中的内存屏障不能保证:
-
不能保证在内存屏障指令完成之前,任何在内存屏障之前指定的内存访问都会被完全执行;可以将屏障视为在CPU的访问队列中划定一条线,适当类型的访问不能越过该线。
-
不能保证在一个CPU上执行内存屏障会对另一个CPU或系统中的其他硬件产生直接影响。间接影响将是第二个CPU看到第一个CPU访问效果的顺序,但请参阅下一点:
-
除非第一个CPU也使用相匹配的内存屏障(参见"SMP Barrier Pairing"小节),否则不能保证第二个CPU能够正确看到第二个CPU的访问效果的顺序,即使第二个CPU使用了内存屏障。
-
不能保证某个CPU之外的硬件会重新排序内存访问。CPU缓存一致性机制应该在CPU之间传播内存屏障的间接影响,但可能不会按顺序执行。
- 有关总线主控DMA和一致性的信息,请阅读以下文档:
- Documentation/driver-api/pci/pci.rst
- Documentation/core-api/dma-api-howto.rst
- Documentation/core-api/dma-api.rst
- 有关总线主控DMA和一致性的信息,请阅读以下文档:
地址依赖屏障(历史)
从Linux内核v4.15开始,为DEC Alpha添加了smp_mb()到READ_ONCE(),这意味着唯一需要关注此部分的人是那些在DEC Alpha体系结构特定代码上工作的人以及那些在READ_ONCE()本身上工作的人。对于那些需要的人以及对历史感兴趣的人,这里是地址依赖屏障的故事。
[!] 虽然地址依赖关系在加载到加载和加载到存储关系中都有观察到,但对于加载到存储的情况,不需要地址依赖屏障。
地址依赖屏障的要求有点微妙,它们并不总是显而易见地需要。为了说明这一点,考虑以下事件序列:
CPU 1 CPU 2
=============== ===============
{ A == 1, B == 2, C == 3, P == &A, Q == &C }
B = 4;
<写屏障>
WRITE_ONCE(P, &B);
Q = READ_ONCE_OLD(P);
D = *Q;
[!] READ_ONCE_OLD() 对应于4.15之前内核的READ_ONCE(),它不意味着地址依赖屏障。
这里存在明显的地址依赖关系,似乎在序列结束时,Q必须是&A或&B,并且:
(Q == &A) 意味着 (D == 1)
(Q == &B) 意味着 (D == 4)
但是!CPU 2对P的感知可能在对B的感知之前更新,因此可能导致以下情况:
(Q == &B) 且 (D == 2) ????
虽然这可能看起来像一种一致性或因果关系维护的失败,但实际上并非如此,这种行为可以在某些真实的CPU上观察到(例如DEC Alpha)。
为了解决这个问题,READ_ONCE()自Linux内核v4.15起提供了一个隐式的地址依赖屏障:
CPU 1 CPU 2
=============== ===============
{ A == 1, B == 2, C == 3, P == &A, Q == &C }
B = 4;
<写屏障>
WRITE_ONCE(P, &B);
Q = READ_ONCE(P);
<隐式地址依赖屏障>
D = *Q;
这强制发生了两个推论之一,并防止第三种可能性的出现。
[!] 请注意,这种极其反直觉的情况最容易在具有分离缓存的机器上出现,例如,一个缓存组处理偶数缓存行,另一个缓存组处理奇数缓存行。指针P可能存储在奇数缓存行中,变量B可能存储在偶数缓存行中。然后,如果读取CPU的缓存的偶数组非常繁忙,而奇数组处于空闲状态,就可以看到指针P(&B)的新值,但变量B(2)的旧值。
地址依赖屏障不需要用于有序的相关写入,因为Linux内核支持的CPU在确定以下三点之后才进行写入:(1)写入实际发生,(2)写入位置,(3)写入的值。但请仔细阅读"CONTROL DEPENDENCIES"部分和Documentation/RCU/rcu_dereference.rst文件:编译器可以并且确实以许多高度创造性的方式打破依赖关系。
CPU 1 CPU 2
=============== ===============
{ A == 1, B == 2, C = 3, P == &A, Q == &C }
B = 4;
<写屏障>
WRITE_ONCE(P, &B);
Q = READ_ONCE_OLD(P);
WRITE_ONCE(*Q, 5);
因此,不需要地址依赖屏障来对读取到Q与写入*Q
进行排序。换句话说,即使没有现代READ_ONCE()的隐式地址依赖屏障,也禁止出现这种情况:
(Q == &B) && (B == 4)
请注意,这种模式应该很少见。毕竟,依赖关系排序的整个目的是防止对数据结构的写入,以及与这些写入相关的昂贵的缓存未命中。这种模式可以用于记录罕见的错误条件等,而CPU的自然排序可以防止丢失这些记录。
请注意,地址依赖提供的排序仅适用于包含它的CPU。有关更多信息,请参阅"Multicopy atomicity"部分。
地址依赖屏障对于RCU系统非常重要。请参阅include/linux/rcupdate.h中的rcu_assign_pointer()和rcu_dereference()。这允许将RCU指针的当前目标替换为新的修改目标,而不会导致替换目标看起来是不完全初始化的。
有关更详细的示例,请参阅"Cache Coherency"小节。
控制依赖
控制依赖可能有些棘手,因为当前的编译器并不理解它们。本节的目的是帮助您防止编译器的无知破坏您的代码。
一个加载-加载的控制依赖需要一个完整的读内存屏障,而不仅仅是(隐式的)地址依赖屏障才能使其正常工作。考虑以下代码片段:
q = READ_ONCE(a);
<隐式地址依赖屏障>
if (q) {
/* BUG: 没有地址依赖!!! */
p = READ_ONCE(b);
}
这将不会产生期望的效果,因为实际上并没有地址依赖,而是一个控制依赖,CPU 可能会试图预测结果,以便其他 CPU 看到从 b 加载的结果发生在从 a 加载的结果之前。在这种情况下,实际上需要的是:
q = READ_ONCE(a);
if (q) {
<读屏障>
p = READ_ONCE(b);
}
然而,存储操作并不会被推测。这意味着对于加载-存储的控制依赖,顺序是被提供的,就像以下示例中一样:
q = READ_ONCE(a);
if (q) {
WRITE_ONCE(b, 1);
}
控制依赖通常与其他类型的屏障配对使用。需要注意的是,READ_ONCE() 和 WRITE_ONCE() 都是必需的!没有 READ_ONCE(),编译器可能会将从 'a' 加载的结果与其他从 'a' 加载的结果合并。没有 WRITE_ONCE(),编译器可能会将对 'b' 的存储与其他对 'b' 的存储合并。任何一种情况都可能对顺序产生高度反直觉的影响。
更糟糕的是,如果编译器能够证明(比如说)变量 'a' 的值总是非零,它就有权通过消除 "if" 语句来优化原始示例:
q = a;
b = 1; /* BUG: 编译器和 CPU 都可以重新排序!!! */
因此不要省略 READ_ONCE()。
很容易尝试在 "if" 语句的两个分支上强制执行相同的存储操作的顺序,如下所示:
q = READ_ONCE(a);
if (q) {
barrier();
WRITE_ONCE(b, 1);
do_something();
} else {
barrier();
WRITE_ONCE(b, 1);
do_something_else();
}
不幸的是,当前的编译器在高优化级别下会将其转换为以下形式:
q = READ_ONCE(a);
barrier();
WRITE_ONCE(b, 1); /* BUG: 与从 a 加载的顺序不一致!!! */
if (q) {
/* WRITE_ONCE(b, 1); -- 向上移动,BUG!!! */
do_something();
} else {
/* WRITE_ONCE(b, 1); -- 向上移动,BUG!!! */
do_something_else();
}
现在从 'a' 加载和对 'b' 的存储之间没有条件了,这意味着 CPU 有权重新排序它们:条件是绝对必需的,并且必须在应用了所有编译器优化后,仍然存在于汇编代码中。因此,如果您需要在此示例中进行排序,您需要显式的内存屏障,例如 smp_store_release():
q = READ_ONCE(a);
if (q) {
smp_store_release(&b, 1);
do_something();
} else {
smp_store_release(&b, 1);
do_something_else();
}
相比之下,如果没有显式的内存屏障,两个分支的控制顺序仅在存储操作不同时才得到保证,例如:
q = READ_ONCE(a);
if (q) {
WRITE_ONCE(b, 1);
do_something();
} else {
WRITE_ONCE(b, 2);
do_something_else();
}
初始的 READ_ONCE() 仍然是必需的,以防止编译器证明 'a' 的值。
此外,您需要小心处理局部变量 'q',否则编译器可能能够猜测值并再次删除所需的条件。例如:
q = READ_ONCE(a);
if (q % MAX) {
WRITE_ONCE(b, 1);
do_something();
} else {
WRITE_ONCE(b, 2);
do_something_else();
}
如果 MAX 被定义为 1,那么编译器知道 (q % MAX) 等于零,这种情况下编译器有权将上述代码转换为以下形式:
q = READ_ONCE(a);
WRITE_ONCE(b, 2);
do_something_else();
在这种转换下,CPU 不需要尊重从变量 'a' 加载到变量 'b' 的顺序。添加一个 barrier() 是很诱人的,但这并没有帮助。条件已经消失,屏障也无法使其恢复。因此,如果您依赖于此顺序,您应该确保 MAX 大于 1,可能如下所示:
q = READ_ONCE(a);
BUILD_BUG_ON(MAX <= 1); /* 从 a 加载到 b 的顺序。 */
if (q % MAX) {
WRITE_ONCE(b, 1);
do_something();
} else {
WRITE_ONCE(b, 2);
do_something_else();
}
请再次注意,对 'b' 的存储是不同的。如果它们是相同的,正如前面所述,编译器可能会将此存储操作移到 'if' 语句之外。
您还必须小心不要过分依赖布尔短路求值。考虑以下示例:
q = READ_ONCE(a);
if (q || 1 > 0)
WRITE_ONCE(b, 1);
因为第一个条件不会出错,第二个条件总是为真,编译器可以将此示例转换为以下形式,破坏控制依赖:
q = READ_ONCE(a);
WRITE_ONCE(b, 1);
这个示例强调了确保编译器无法猜测您的代码的需要。更一般地说,尽管 READ_ONCE() 确实强制编译器实际生成给定加载的代码,但它并不强制编译器使用结果。
此外,控制依赖仅适用于包含控制依赖的 if 语句的 then 子句和 else 子句。特别是,它不一定适用于 if 语句后面的代码:
q = READ_ONCE(a);
if (q) {
WRITE_ONCE(b, 1);
} else {
WRITE_ONCE(b, 2);
}
WRITE_ONCE(c, 1); /* BUG: 与从 'a' 读取的顺序不一致。 */
很容易认为这里实际上是有顺序的,因为编译器不能重新排序 volatile 访问,也不能重新排序对 'b' 的写入与条件。不幸的是,编译器可能会将对 'b' 的两次写入编译为条件移动指令,就像这个幻想的伪汇编语言中一样:
ld r1,a
cmp r1,$0
cmov,ne r4,$1
cmov,eq r4,$2
st r4,b
st $1,c
一个弱排序的 CPU 在从 'a' 加载到 'c' 的存储之间没有任何依赖。控制依赖仅延伸到两个 cmov 指令对和依赖于它们的存储。简而言之,控制依赖仅适用于包含控制依赖的 if 语句的 then 子句和 else 子句(包括这两个子句调用的函数),而不适用于包含控制依赖的 if 语句后面的代码。
请注意,控制依赖提供的顺序仅适用于包含它的 CPU。有关更多信息,请参阅 "多副本原子性" 部分。
总结:
-
控制依赖可以对先前的加载操作与后续的存储操作进行排序。然而,它们不保证任何其他类型的顺序:不保证先前的加载操作与后续的加载操作,也不保证先前的存储操作与后续的任何操作。如果您需要这些其他形式的顺序,使用 smp_rmb()、smp_wmb(),或者在先前的存储操作和后续的加载操作的情况下,使用 smp_mb()。
-
如果 "if" 语句的两个分支都以相同的存储操作开始,那么这些存储操作必须被排序,可以通过在两者之前加上 smp_mb(),或者使用 smp_store_release() 来进行存储操作。请注意,在 "if" 语句的每个分支的开头使用 barrier() 是不够的,因为正如上面的示例所示,优化编译器可以破坏控制依赖,同时又遵守 barrier() 的规则。
-
控制依赖需要至少一个运行时条件在先前的加载操作和后续的存储操作之间,并且这个条件必须涉及先前的加载操作。如果编译器能够优化掉条件,它也将优化掉顺序。谨慎使用 READ_ONCE() 和 WRITE_ONCE() 可以帮助保留所需的条件。
-
控制依赖要求编译器避免将依赖重新排序为不存在。谨慎使用 READ_ONCE() 或 atomic{,64}_read() 可以帮助保留您的控制依赖。更多信息,请参见编译器屏障部分。
-
控制依赖仅适用于包含控制依赖的 if 语句的 then 子句和 else 子句,包括这两个子句调用的任何函数。控制依赖不适用于包含控制依赖的 if 语句后面的代码。
-
控制依赖通常与其他类型的屏障配对使用。
-
控制依赖不提供多副本原子性。如果您需要所有 CPU 同时看到给定存储操作,请使用 smp_mb()。
-
编译器不理解控制依赖。因此,您的工作是确保它们不破坏您的代码。
SMP屏障配对
在处理CPU-CPU交互时,某些类型的内存屏障应该始终成对出现。缺乏适当的配对几乎肯定是一个错误。
一般的屏障彼此配对,尽管它们也可以与大多数其他类型的屏障配对,但没有多副本原子性。一个获取屏障与一个释放屏障配对,但两者也可以与其他屏障配对,包括当然是一般的屏障。写屏障与地址依赖屏障、控制依赖、获取屏障、释放屏障、读屏障或一般屏障配对。类似地,读屏障、控制依赖或地址依赖屏障与写屏障、获取屏障、释放屏障或一般屏障配对:
CPU 1 CPU 2
=============== ===============
WRITE_ONCE(a, 1);
<写屏障>
WRITE_ONCE(b, 2); x = READ_ONCE(b);
<读屏障>
y = READ_ONCE(a);
或者:
CPU 1 CPU 2
=============== ===============================
a = 1;
<写屏障>
WRITE_ONCE(b, &a); x = READ_ONCE(b);
<隐式地址依赖屏障>
y = *x;
甚至:
CPU 1 CPU 2
=============== ===============================
r1 = READ_ONCE(y);
<一般屏障>
WRITE_ONCE(x, 1); if (r2 = READ_ONCE(x)) {
<隐式控制依赖>
WRITE_ONCE(y, 1);
}
assert(r1 == 0 || r2 == 0);
基本上,读屏障始终必须存在,尽管它可以是“较弱”的类型。
[!] 注意,在写屏障之前的存储通常应与读屏障或地址依赖屏障之后的加载匹配,反之亦然:
CPU 1 CPU 2
=================== ===================
WRITE_ONCE(a, 1); }---- --->{ v = READ_ONCE(c);
WRITE_ONCE(b, 2); } \ / { w = READ_ONCE(d);
<写屏障> \ <读屏障>
WRITE_ONCE(c, 3); } / \ { x = READ_ONCE(a);
WRITE_ONCE(d, 4); }---- --->{ y = READ_ONCE(b);
内存屏障序列示例
首先,写屏障作为存储操作的部分排序。考虑以下事件序列:
CPU 1
=======================
存储 A = 1
存储 B = 2
存储 C = 3
<写屏障>
存储 D = 4
存储 E = 5
这个事件序列按照一种顺序提交到内存一致性系统,使得系统的其余部分可能会感知为无序集合 { 存储 A, 存储 B, 存储 C } 都发生在无序集合 { 存储 D, 存储 E } 之前:
+-------+ : :
| | +------+
| |------>| C=3 | } /\
| | : +------+ }----- \ -----> 可感知到的事件
| | : | A=1 | } \/ 对于系统的其余部分
| | : +------+ }
| CPU 1 | : | B=2 | }
| | +------+ }
| | wwwwwwwwwwwwwwww } <--- 在这一点上,写屏障
| | +------+ } 要求屏障之前的所有存储
| | : | E=5 | } 在进一步的存储发生之前被提交
| | : +------+ }
| |------>| D=4 | }
| | +------+
+-------+ : :
|
| CPU 1 提交存储的顺序
V
其次,地址依赖屏障作为地址相关加载的部分排序。考虑以下事件序列:
CPU 1 CPU 2
======================= =======================
{ B = 7; X = 9; Y = 8; C = &Y }
存储 A = 1
存储 B = 2
<写屏障>
存储 C = &B 加载 X
存储 D = 4 加载 C (得到 &B)
加载 *C (读取 B)
在没有干预的情况下,CPU 2 可能会以某种有效随机的顺序感知到 CPU 1 上的事件,尽管 CPU 1 发出了写屏障:
+-------+ : : : :
| | +------+ +-------+ | 更新感知的顺序
| |------>| B=2 |----- --->| Y->8 | | 在 CPU 2 上
| | : +------+ \ +-------+ |
| CPU 1 | : | A=1 | \ --->| C->&Y | V
| | +------+ | +-------+
| | wwwwwwwwwwwwwwww | : :
| | +------+ | : :
| | : | C=&B |--- | : : +-------+
| | : +------+ \ | +-------+ | |
| |------>| D=4 | ----------->| C->&B |------>| |
| | +------+ | +-------+ | |
+-------+ : : | : : | |
| : : | |
| : : | CPU 2 |
| +-------+ | |
显然不正确的感知 ---> | | B->7 |------>| |
B (!) 的感知 | +-------+ | |
| : : | |
| +-------+ | |
对 X 的加载导致 ---> \ | X->9 |------>| |
B 一致性的维护 \ +-------+ | |
----->| B->2 | +-------+
+-------+
: :
在上面的例子中,CPU 2 感知到 B 是 7,尽管 *C
的加载(应该是 B)发生在加载 C 之后。
然而,如果在CPU 2的*C
(即B)的加载和C的加载之间放置了一个地址依赖性屏障:
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 |------>| |
| +-------+ | |
Makes sure all effects ---> \ aaaaaaaaaaaaaaaaa | |
prior to the store of C \ +-------+ | |
are perceptible to ----->| B->2 |------>| |
subsequent loads +-------+ | |
: : +-------+
第三,读屏障作为加载的部分顺序。考虑以下事件序列:
CPU 1 CPU 2
======================= =======================
{ A = 0, B = 9 }
STORE A=1
<写屏障>
STORE B=2
LOAD B
LOAD A
如果没有干预,CPU 2可能会以某种有效随机的顺序感知CPU 1上的事件,尽管CPU 1发出了写屏障:
+-------+ : : : :
| | +------+ +-------+
| |------>| A=1 |------ --->| A->0 |
| | +------+ \ +-------+
| CPU 1 | wwwwwwwwwwwwwwww \ --->| B->9 |
| | +------+ | +-------+
| |------>| B=2 |--- | : :
| | +------+ \ | : : +-------+
+-------+ : : \ | +-------+ | |
---------->| B->2 |------>| |
| +-------+ | CPU 2 |
| | A->0 |------>| |
| +-------+ | |
| : : +-------+
\ : :
\ +-------+
---->| A->1 |
+-------+
: :
如果在 CPU 2 的 B 的加载和 A 的加载之间放置了读屏障:
CPU 1 CPU 2
======================= =======================
{ A = 0, B = 9 }
存储 A=1
<写屏障>
存储 B=2
加载 B
<读屏障>
加载 A
那么 CPU 1 强加的部分顺序将被 CPU 2 正确地感知:
+-------+ : : : :
| | +------+ +-------+
| |------>| A=1 |------ --->| A->0 |
| | +------+ \ +-------+
| CPU 1 | wwwwwwwwwwwwwwww \ --->| B->9 |
| | +------+ | +-------+
| |------>| B=2 |--- | : :
| | +------+ \ | : : +-------+
+-------+ : : \ | +-------+ | |
---------->| B->2 |------>| |
| +-------+ | CPU 2 |
| : : | |
| : : | |
At this point the read ----> \ rrrrrrrrrrrrrrrrr | |
barrier causes all effects \ +-------+ | |
prior to the storage of B ---->| A->1 |------>| |
to be perceptible to CPU 2 +-------+ | |
: : +-------+
为了更全面地说明这一点,考虑一下如果代码在读屏障的两侧都包含了对 A 的加载:
CPU 1 CPU 2
======================= =======================
{ A = 0, B = 9 }
存储 A=1
<写屏障>
存储 B=2
加载 B
加载 A [A 的第一次加载]
<读屏障>
加载 A [A 的第二次加载]
尽管两次对 A 的加载都发生在 B 的加载之后,它们可能得到不同的值:
+-------+ : : : :
| | +------+ +-------+
| |------>| A=1 |------ --->| A->0 |
| | +------+ \ +-------+
| CPU 1 | wwwwwwwwwwwwwwww \ --->| B->9 |
| | +------+ | +-------+
| |------>| B=2 |--- | : :
| | +------+ \ | : : +-------+
+-------+ : : \ | +-------+ | |
---------->| B->2 |------>| |
| +-------+ | CPU 2 |
| : : | |
| : : | |
| +-------+ | |
| | A->0 |------>| 1st |
| +-------+ | |
At this point the read ----> \ rrrrrrrrrrrrrrrrr | |
barrier causes all effects \ +-------+ | |
prior to the storage of B ---->| A->1 |------>| 2nd |
to be perceptible to CPU 2 +-------+ | |
: : +-------+
但是,很可能 CPU 1 对 A 的更新在读屏障完成之前就对 CPU 2 可感知:
+-------+ : : : :
| | +------+ +-------+
| |------>| A=1 |------ --->| A->0 |
| | +------+ \ +-------+
| CPU 1 | wwwwwwwwwwwwwwww \ --->| B->9 |
| | +------+ | +-------+
| |------>| B=2 |--- | : :
| | +------+ \ | : : +-------+
+-------+ : : \ | +-------+ | |
---------->| B->2 |------>| |
| +-------+ | CPU 2 |
| : : | |
\ : : | |
\ +-------+ | |
---->| A->1 |------>| 1st |
+-------+ | |
rrrrrrrrrrrrrrrrr | |
+-------+ | |
| A->1 |------>| 2nd |
+-------+ | |
: : +-------+
保证是,如果 B 的加载得到 B == 2,那么第二次对 A 的加载将始终得到 A == 1。对于第一次对 A 的加载,没有这样的保证;它可能得到 A == 0,也可能得到 A == 1。
读取内存屏障与加载推测
许多 CPU 会对加载进行推测:也就是说,它们会发现需要从内存中加载一个项目,并找到一个它们不用于任何其他加载的总线时间,因此提前进行加载 - 即使它们实际上还没有到达指令执行流中的那一点。这使得实际的加载指令有可能立即完成,因为 CPU 已经有了手头的值。
可能 CPU 实际上并不需要这个值 - 也许是因为分支绕过了加载 - 在这种情况下,它可以丢弃该值或者只是将其缓存以供以后使用。
考虑以下情况:
CPU 1 CPU 2
======================= =======================
LOAD B
DIVIDE } 除法指令通常需要很长时间来执行
DIVIDE }
LOAD A
可能看起来像这样:
: : +-------+
+-------+ | |
--->| B->2 |------>| |
+-------+ | CPU 2 |
: :DIVIDE | |
+-------+ | |
The CPU being busy doing a ---> --->| A->0 |~~~~ | |
division speculates on the +-------+ ~ | |
LOAD of A : : ~ | |
: :DIVIDE | |
: : ~ | |
Once the divisions are complete --> : : ~-->| |
the CPU can then perform the : : | |
LOAD with immediate effect : : +-------+
在第二个加载之前放置一个读取屏障或地址依赖屏障:
CPU 1 CPU 2
======================= =======================
LOAD B
DIVIDE
DIVIDE
<读取屏障>
LOAD A
将强制重新考虑任何推测获得的值,这取决于所使用的屏障类型。如果对推测的内存位置没有进行更改,则将仅使用推测的值:
: : +-------+
+-------+ | |
--->| B->2 |------>| |
+-------+ | CPU 2 |
: :DIVIDE | |
+-------+ | |
The CPU being busy doing a ---> --->| A->0 |~~~~ | |
division speculates on the +-------+ ~ | |
LOAD of A : : ~ | |
: :DIVIDE | |
: : ~ | |
: : ~ | |
rrrrrrrrrrrrrrrr~ | |
: : ~ | |
: : ~-->| |
: : | |
: : +-------+
但如果有来自另一个 CPU 的更新或使之无效的操作挂起,那么推测将被取消,并重新加载值:
: : +-------+
+-------+ | |
--->| B->2 |------>| |
+-------+ | CPU 2 |
: :DIVIDE | |
+-------+ | |
The CPU being busy doing a ---> --->| A->0 |~~~~ | |
division speculates on the +-------+ ~ | |
LOAD of A : : ~ | |
: :DIVIDE | |
: : ~ | |
: : ~ | |
rrrrrrrrrrrrrrrrr | |
+-------+ | |
The speculation is discarded ---> --->| A->1 |------>| |
and an updated value is +-------+ | |
retrieved : : +-------+
多副本原子性
多副本原子性是一个关于顺序的深刻直觉概念,实际的计算机系统并不总是提供这种特性,即一个给定的存储在同一时间对所有 CPU 可见,或者说所有 CPU 对所有存储的顺序达成一致。然而,完全支持多副本原子性会排除有价值的硬件优化,因此一个更弱的形式被称为“其他多副本原子性”,它只保证一个给定的存储在同一时间对所有 -其他- CPU 可见。本文剩余部分将讨论这种更弱的形式,但为简洁起见将其简称为“多副本原子性”。
以下示例演示了多副本原子性:
CPU 1 CPU 2 CPU 3
======================= ======================= =======================
{ X = 0, Y = 0 }
存储 X=1 r1=加载 X (读取 1) 加载 Y (读取 1)
<通用屏障> <读屏障>
存储 Y=r1 加载 X
假设 CPU 2 从 X 加载返回 1,然后将其存储到 Y,而 CPU 3 从 Y 加载返回 1。这表明 CPU 1 对 X 的存储发生在 CPU 2 对 X 的加载之前,并且 CPU 2 对 Y 的存储发生在 CPU 3 对 Y 的加载之前。此外,内存屏障保证 CPU 2 在执行存储之前执行加载,CPU 3 在从 Y 加载之前执行从 X 加载。那么问题来了,“CPU 3 从 X 加载会返回 0 吗?”
因为 CPU 3 从 X 加载在某种意义上发生在 CPU 2 的加载之后,自然地期望 CPU 3 从 X 加载必然返回 1。这个期望来自于多副本原子性:如果在 CPU A 上执行的加载之后在 CPU B 上执行的加载(且 CPU A 最初没有存储它读取的值),那么在多副本原子性系统上,CPU B 的加载必须返回 CPU A 的加载返回的值或者某个更晚的值。然而,Linux 内核不要求系统具备多副本原子性。
上面示例中使用了通用内存屏障来弥补任何多副本原子性的缺失。在这个示例中,如果 CPU 2 从 X 加载返回 1,CPU 3 从 Y 加载返回 1,那么 CPU 3 从 X 加载的确也会返回 1。
然而,依赖关系、读屏障和写屏障并不总能弥补非多副本原子性。例如,假设上面示例中 CPU 2 的通用屏障被移除,只留下下面所示的数据依赖关系:
CPU 1 CPU 2 CPU 3
======================= ======================= =======================
{ X = 0, Y = 0 }
存储 X=1 r1=加载 X (读取 1) 加载 Y (读取 1)
<数据依赖> <读屏障>
存储 Y=r1 加载 X (读取 0)
这种替换允许非多副本原子性肆虐:在这个示例中,CPU 2 从 X 加载返回 1,CPU 3 从 Y 加载返回 1,并且从 X 加载返回 0 是完全合法的。
关键点在于,尽管 CPU 2 的数据依赖关系对其加载和存储进行了排序,但它并不保证对 CPU 1 的存储进行排序。因此,如果这个示例在一个非多副本原子性系统上运行,其中 CPU 1 和 CPU 2 共享一个存储缓冲区或一级缓存,CPU 2 可能提前访问 CPU 1 的写入。因此,通用屏障是必需的,以确保所有 CPU 对多个访问的组合顺序达成一致。
通用屏障不仅可以弥补非多副本原子性,还可以生成额外的顺序,以确保 -所有- CPU 都会感知 -所有- 操作的相同顺序。相比之下,一系列释放-获取对并不提供这种额外的顺序,这意味着只有参与该对的 CPU 才能保证对访问的组合顺序达成一致。例如,为了尊重 Herman Hollerith 的幽灵,切换到 C 代码:
int u, v, x, y, z;
void cpu0(void)
{
r0 = smp_load_acquire(&x);
WRITE_ONCE(u, 1);
smp_store_release(&y, 1);
}
void cpu1(void)
{
r1 = smp_load_acquire(&y);
r4 = READ_ONCE(v);
r5 = READ_ONCE(u);
smp_store_release(&z, 1);
}
void cpu2(void)
{
r2 = smp_load_acquire(&z);
smp_store_release(&x, 1);
}
void cpu3(void)
{
WRITE_ONCE(v, 1);
smp_mb();
r3 = READ_ONCE(u);
}
因为 cpu0()、cpu1() 和 cpu2() 参与了一系列 smp_store_release()/smp_load_acquire() 对,以下结果是被禁止的:
r0 == 1 && r1 == 1 && r2 == 1
此外,由于 cpu0() 和 cpu1() 之间存在释放-获取关系,cpu1() 必须看到 cpu0() 的写入,因此以下结果是被禁止的:
r1 == 1 && r5 == 0
然而,释放-获取对提供的顺序仅适用于参与该对的 CPU,不适用于 cpu3(),至少除了存储之外。因此,以下结果是可能的:
r0 == 0 && r1 == 1 && r2 == 1 && r3 == 0 && r4 == 0
另外,以下结果也是可能的:
r0 == 0 && r1 == 1 && r2 == 1 && r3 == 0 && r4 == 0 && r5 == 1
尽管 cpu0()、cpu1() 和 cpu2() 将按顺序看到它们各自的读取和写入,但没有参与释放-获取对的 CPU 可能对顺序持不同意见。这种不一致源于用于实现 smp_load_acquire() 和 smp_store_release() 的弱内存屏障指令并不要求在所有情况下对先前的存储进行排序以防止后续的加载。这意味着 cpu3() 可能看到 cpu0() 对 u 的存储发生在 cpu1() 对 v 的加载之后,尽管 cpu0() 和 cpu1() 都同意这两个操作是按照预期顺序发生的。
然而,请记住 smp_load_acquire() 并不是魔法。特别是,它只是带有顺序的从其参数读取。它 -不- 确保将读取任何特定值。因此,以下结果是可能的:
r0 == 0 && r1 == 0 && r2 == 0 && r5 == 0
请注意,即使在一个神话般的顺序一致系统上,这种结果也是可能的,因为在那里不会发生重新排序。
重申一下,如果你的代码需要所有操作的完全顺序,那么应该在整个过程中使用通用屏障。
显式内核屏障
Linux内核有各种不同级别的屏障:
-
编译器屏障。
-
CPU内存屏障。
编译器屏障
Linux内核有一个显式的编译器屏障函数,可以防止编译器将内存访问从屏障两侧移动到另一侧:
barrier();
这是一个通用的屏障 -- 没有barrier()
的读-读或写-写变体。然而,READ_ONCE()
和WRITE_ONCE()
可以被视为barrier()
的弱形式,只影响由READ_ONCE()
或WRITE_ONCE()
标记的特定访问。
barrier()
函数具有以下效果:
-
防止编译器重新排序屏障后的访问,使其在屏障前面的任何访问之前。其中一个用例是简化中断处理程序代码和被中断的代码之间的通信。
-
在循环内,强制编译器在每次循环通过时加载该循环条件中使用的变量。
READ_ONCE()
和WRITE_ONCE()
函数可以防止任何数量的优化,在单线程代码中是完全安全的,但在并发代码中可能会导致致命错误。以下是这些优化的一些示例:
-
编译器有权重新排序对同一变量的加载和存储,在某些情况下,CPU有权重新排序对同一变量的加载。这意味着以下代码:
a[0] = x; a[1] = x;
可能导致x的旧值存储在a[1]中而不是a[0]中。防止编译器和CPU这样做的方法如下:
a[0] = READ_ONCE(x); a[1] = READ_ONCE(x);
简而言之,
READ_ONCE()
和WRITE_ONCE()
为从多个CPU访问单个变量提供了缓存一致性。 -
编译器有权合并对同一变量的连续加载。这种合并可能导致编译器“优化”以下代码:
while (tmp = a) do_something_with(tmp);
成为以下代码,虽然在某种意义上对于单线程代码来说是合法的,但几乎肯定不是开发人员的意图:
if (tmp = a) for (;;) do_something_with(tmp);
使用
READ_ONCE()
防止编译器这样做:while (tmp = READ_ONCE(a)) do_something_with(tmp);
-
编译器有权重新加载变量,例如,在高寄存器压力的情况下,编译器无法将所有感兴趣的数据保存在寄存器中。因此,编译器可能会优化我们先前示例中的变量'tmp':
while (tmp = a) do_something_with(tmp);
这可能导致以下代码,在单线程代码中是完全安全的,但在并发代码中可能会导致致命错误:
while (a) do_something_with(a);
例如,此代码的优化版本可能导致在“while”语句和调用
do_something_with()
之间由其他CPU修改变量a的情况下将零传递给do_something_with()
。再次使用
READ_ONCE()
防止编译器这样做:while (tmp = READ_ONCE(a)) do_something_with(tmp);
请注意,如果编译器寄存器不足,它可能会将tmp保存到堆栈上。编译器重新加载变量的开销和稍后恢复是编译器重新加载变量的原因。这对于单线程代码来说是完全安全的,因此您需要告诉编译器在这种情况下是不安全的。
-
编译器有权完全省略加载,如果它知道值将是什么。例如,如果编译器可以证明变量'a'的值始终为零,它可以优化此代码:
while (tmp = a) do_something_with(tmp);
变成这样:
do { } while (0);
这种转换对于单线程代码来说是有利的,因为它消除了加载和分支。问题在于,编译器将执行其证明,假设当前CPU是唯一更新变量'a'的CPU。如果变量'a'是共享的,那么编译器的证明将是错误的。使用
READ_ONCE()
告诉编译器它并不知道自己认为的那么多:while (tmp = READ_ONCE(a)) do_something_with(tmp);
但请注意,编译器也会密切关注您在
READ_ONCE()
之后对值的处理。例如,假设您执行以下操作,MAX是一个具有值1的预处理器宏:while ((tmp = READ_ONCE(a)) % MAX) do_something_with(tmp);
那么编译器知道对MAX应用"%"运算符的结果将始终为零,这再次允许编译器将代码优化为接近不存在的状态。(它仍然会从变量'a'加载。)
-
类似地,编译器有权完全省略存储,如果它知道变量已经具有要存储的值。同样,编译器假设当前CPU是唯一存储到变量的CPU,这可能导致编译器对共享变量做出错误的决定。例如,假设您有以下内容:
a = 0; ... 不存储到变量a的代码 ... a = 0;
编译器看到变量'a'的值已经为零,因此它可能会省略第二次存储。如果另一个CPU可能在此期间存储到变量'a',这将导致致命的意外。使用
WRITE_ONCE()
防止编译器做出这种错误的猜测:WRITE_ONCE(a, 0); ... 不存储到变量a的代码 ... WRITE_ONCE(a, 0);
-
编译器有权重新排序内存访问,除非您告诉它不要这样做。例如,考虑进程级代码和中断处理程序之间的以下交互:
void process_level(void) { msg = get_message(); flag = true; } void interrupt_handler(void) { if (flag) process_message(msg); }
没有什么可以阻止编译器将
process_level()
转换为以下内容,实际上,这对于单线程代码可能是有利的:void process_level(void) { flag = true; msg = get_message(); }
如果中断发生在这两个语句之间,那么
interrupt_handler()
可能会收到一个混乱的msg。使用WRITE_ONCE()
防止这种情况发生如下:void process_level(void) { WRITE_ONCE(msg, get_message()); WRITE_ONCE(flag, true); } void interrupt_handler(void) { if (READ_ONCE(flag)) process_message(READ_ONCE(msg)); }
请注意,如果此中断处理程序本身可以被其他访问'flag'和'msg'的东西中断,例如,嵌套中断或NMI,那么
interrupt_handler()
中需要READ_ONCE()
和WRITE_ONCE()
包装,除了文档目的外,interrupt_handler()
中不需要READ_ONCE()
和WRITE_ONCE()
。(还要注意,嵌套中断在现代Linux内核中通常不会发生,实际上,如果中断处理程序返回时启用了中断,您将收到一个WARN_ONCE()
警告。)您应该假设编译器可以将
READ_ONCE()
和WRITE_ONCE()
移动到不包含READ_ONCE()
、WRITE_ONCE()
、barrier()
或类似原语的代码之后。这种效果也可以通过
barrier()
实现,但READ_ONCE()
和WRITE_ONCE()
更具选择性:使用READ_ONCE()
和WRITE_ONCE()
,编译器只需要忘记指定内存位置的内容,而使用barrier()
,编译器必须丢弃它当前在任何机器寄存器中缓存的所有内存位置的值。当然,编译器还必须尊重READ_ONCE()
和WRITE_ONCE()
发生的顺序,尽管CPU当然不必这样做。 -
编译器有权发明对变量的存储,如以下示例:
if (a) b = a; else b = 42;
编译器可能通过以下方式优化此存储以节省一个分支:
b = 42; if (a) b = a;
在单线程代码中,这不仅是安全的,而且还节省了一个分支。不幸的是,在并发代码中,这种优化可能导致其他CPU在加载变量'b'时看到一个虚假的值42 -- 即使变量'a'从未为零。使用
WRITE_ONCE()
防止这种情况发生如下:if (a) WRITE_ONCE(b, a); else WRITE_ONCE(b, 42);
编译器也可以发明加载。这些通常不太有害,但它们可能导致缓存行跳动,从而导致性能和可伸缩性不佳。使用
READ_ONCE()
防止发明加载。 -
对于大小允许使用单个内存引用指令访问的对齐内存位置,防止“加载撕裂”和“存储撕裂”,其中单个大访问被多个较小的访问替换。例如,对于具有16位存储指令和7位立即字段的体系结构,编译器可能会诱使使用两个16位存储立即指令来实现以下32位存储:
p = 0x00010002;
请注意,GCC确实使用这种优化,这并不奇怪,因为构建常量然后存储它可能需要超过两条指令。在事实上,最近的一个错误(已经修复)导致GCC在一个
volatile
存储中错误地使用了这种优化。在没有这样的错误的情况下,使用WRITE_ONCE()
可以防止在以下示例中发生存储撕裂:WRITE_ONCE(p, 0x00010002);
使用紧凑结构也可能导致加载和存储撕裂,如以下示例:
struct __attribute__((__packed__)) foo { short a; int b; short c; }; struct foo foo1, foo2; ... foo2.a = foo1.a; foo2.b = foo1.b; foo2.c = foo1.c;
因为没有
READ_ONCE()
或WRITE_ONCE()
包装和没有volatile
标记,编译器完全有权将这三个赋值语句实现为一对32位加载,然后是一对32位存储。这将导致'foo1.b'上的加载撕裂和'foo2.b'上的存储撕裂。READ_ONCE()
和WRITE_ONCE()
再次防止在此示例中发生撕裂:foo2.a = foo1.a; WRITE_ONCE(foo2.b, READ_ONCE(foo1.b)); foo2.c = foo1.c;
总之,永远不需要在已标记为
volatile
的变量上使用READ_ONCE()
和WRITE_ONCE()
。例如,因为'jiffies'被标记为volatile
,所以永远不需要使用READ_ONCE(jiffies)
。原因是READ_ONCE()
和WRITE_ONCE()
被实现为volatile
转换,当其参数已经标记为volatile
时,它没有任何效果。请注意,这些编译器屏障对CPU没有直接影响,CPU可能会随意重新排序事物。
CPU内存屏障
Linux内核有七种基本的CPU内存屏障:
类型 强制性 SMP条件性
======================= =============== ===============
通用 mb() smp_mb()
写入 wmb() smp_wmb()
读取 rmb() smp_rmb()
地址依赖性 READ_ONCE()
除了地址依赖性屏障之外,所有内存屏障都隐含了一个编译器屏障。地址依赖性不会强加任何额外的编译器排序。
附注:在地址依赖性的情况下,编译器应该按正确的顺序发出加载指令(例如,a[b]
在加载a[b]之前必须加载b的值),但是C规范中没有保证编译器不会推测b的值(例如,等于1)并在b之前加载a[b](例如,tmp = a[1]; if (b != 1) tmp = a[b];)。还存在编译器在加载a[b]后重新加载b的问题,因此比a[b]有一个更新的b副本。关于这些问题尚未达成共识,但是READ_ONCE()宏是一个很好的起点。
在单处理器编译系统上,SMP内存屏障会被简化为编译器屏障,因为假设CPU在自身上是自洽的,并且会正确地对重叠访问进行排序。但是,请参阅下面的“虚拟机客户机”小节。
[!] 请注意,在SMP系统上,必须使用SMP内存屏障来控制对共享内存的引用顺序,尽管使用锁定也足够。
不应该使用强制性屏障来控制SMP效果,因为强制性屏障会对SMP和UP系统都造成不必要的开销。但是,它们可以用于控制通过放松的内存I/O窗口访问的MMIO效果。即使在非SMP系统上,这些屏障也是必需的,因为它们会通过禁止编译器和CPU重新排序来影响内存操作的顺序。
还有一些更高级的屏障函数:
-
smp_store_mb(var, value)
这将值分配给变量,然后在其后插入一个完整的内存屏障。在单处理器编译中,不能保证插入的内容超过编译器屏障。
-
smp_mb__before_atomic();
-
smp_mb__after_atomic();
这些用于与不隐含内存屏障的原子RMW函数一起使用,但代码需要内存屏障的情况。不隐含内存屏障的原子RMW函数的示例包括add、subtract、(失败的)条件操作、_relaxed函数,但不包括atomic_read或atomic_set。当使用原子操作进行引用计数时,可能需要内存屏障的常见示例。
这些也用于不隐含内存屏障的原子RMW位操作函数(例如set_bit和clear_bit)。
例如,考虑一个将对象标记为已死亡并递减对象引用计数的代码片段:
obj->dead = 1; smp_mb__before_atomic(); atomic_dec(&obj->ref_count);
这确保了对象上的死亡标记在引用计数递减之前被设置。
有关更多信息,请参阅Documentation/atomic_{t,bitops}.txt。
-
dma_wmb();
-
dma_rmb();
-
dma_mb();
这些用于一致内存,以保证对CPU和DMA设备都可访问的共享内存的写入或读取顺序。有关一致内存的更多信息,请参阅Documentation/core-api/dma-api.rst文件。
例如,考虑一个与设备共享内存并使用描述符状态值来指示描述符属于设备还是CPU,并使用门铃在新描述符可用时通知设备的设备驱动程序:
if (desc->status != DEVICE_OWN) { /* 在拥有描述符之前不要读取数据 */ dma_rmb(); /* 读取/修改数据 */ read_data = desc->data; desc->data = write_data; /* 在更新状态之前刷新修改 */ dma_wmb(); /* 分配所有权 */ desc->status = DEVICE_OWN; /* 使描述符状态对设备可见,然后通知设备有新的描述符 */ writel(DESC_NOTIFY, doorbell); }
dma_rmb()允许我们确保设备在从描述符读取数据之前已释放所有权,dma_wmb()允许我们确保数据在设备可以看到它拥有所有权之前已写入描述符。dma_mb()隐含了dma_rmb()和dma_wmb()。
请注意,dma_*()屏障不会为对MMIO区域的访问提供任何排序保证。有关I/O访问器和MMIO排序的更多信息,请参阅后面的“KERNEL I/O BARRIER EFFECTS”小节。
-
pmem_wmb();
这用于持久内存,以确保将修改写入持久存储的存储达到平台的耐久性域。
例如,在对pmem区域进行非临时写入后,我们使用pmem_wmb()来确保存储已达到平台的耐久性域。这确保了在启动后续指令引起的任何数据访问或数据传输之前,存储已更新到持久存储。这是wmb()所做的排序之外的额外操作。
对于从持久内存加载,现有的读取内存屏障足以确保读取顺序。
-
io_stop_wc();
对于具有写合并属性的内存访问(例如,由ioremap_wc()返回的内存),CPU可能会等待先前的访问与后续的访问合并。当此等待对性能有影响时,可以使用io_stop_wc()来防止在此宏之前和之后合并写合并内存访问。
隐式内核内存屏障
在 Linux 内核中,一些其他函数暗示了内存屏障,其中包括锁定和调度函数。
这个规范是一个“最低”保证;任何特定的架构可能提供更实质性的保证,但在架构特定的代码之外可能无法依赖这些保证。
锁定获取函数
Linux 内核有许多锁定构造:
- 自旋锁
- 读/写自旋锁
- 互斥体
- 信号量
- 读/写信号量
在所有情况下,每个构造的“获取”操作和“释放”操作都暗示了某些屏障:
-
获取操作暗示:
- 在获取之后发出的内存操作将在获取操作完成后完成。
- 在获取之前发出的内存操作可能在获取操作完成后完成。
-
释放操作暗示:
- 在释放之前发出的内存操作将在释放操作完成之前完成。
- 在释放之后发出的内存操作可能在释放操作完成之前完成。
-
获取 vs 获取 暗示:
- 在另一个获取操作之前发出的所有获取操作将在该获取操作之前完成。
-
获取 vs 释放 暗示:
- 在释放操作之前发出的所有获取操作将在释放操作之前完成。
-
失败的条件获取暗示:
- 获取操作的某些锁定变体可能失败,要么是因为无法立即获取锁定,要么是因为在等待锁定可用时接收到未阻塞的信号。失败的锁定不暗示任何屏障。
注意:锁定获取和释放只是单向屏障的一个后果是,临界区域外的指令的影响可能渗入临界区域内部。
获取后跟释放的操作可能不被假定为完整的内存屏障,因为可能发生在获取之前的访问在获取之后发生,以及在释放之后的访问在释放之前发生,然后这两个访问本身可能交叉:
*A = a;
获取 M
释放 M
*B = b;
可能发生为:
获取 M,存储 *B,存储 *A,释放 M
当获取和释放是锁定获取和释放时,如果锁的获取和释放是对同一锁变量的,那么另一个 CPU 从不持有该锁的角度来看,同样的重新排序也可能发生。简而言之,获取后跟释放可能不被假定为完整的内存屏障。
同样,释放后跟获取的情况也不暗示完整的内存屏障。因此,CPU 执行与释放和获取对应的临界区域可能交叉,以至于:
*A = a;
释放 M
获取 N
*B = b;
可能发生为:
获取 N,存储 *B,存储 *A,释放 M
看起来这种重新排序可能引入死锁。但这不会发生,因为如果出现这种死锁威胁,释放将简单地完成,从而避免死锁。
这为什么有效呢?
一个关键点是我们只谈论 CPU 进行重新排序,而不是编译器。如果编译器(或者开发人员)交换了操作,死锁可能会发生。
但假设 CPU 重新排序了操作。在这种情况下,解锁在汇编代码中先于锁。CPU 只是选择尝试先执行后面的锁操作。如果存在死锁,这个锁操作将简单地自旋(或者尝试休眠,但稍后再说)。CPU 最终会执行先前在汇编代码中先于锁操作的解锁操作,这将解开潜在的死锁,使锁操作成功。
但如果锁是一个休眠锁呢?在这种情况下,代码将尝试进入调度器,在那里它最终会遇到一个内存屏障,这将强制先前的解锁操作完成,再次解开死锁。可能会有一个休眠解锁竞争,但无论如何,锁定原语都需要正确解决这样的竞争。
在 UP 编译系统上,锁和信号量可能不提供任何关于排序的保证,因此在这种情况下不能指望它们实际上实现任何事情 - 特别是关于 I/O 访问 - 除非与中断禁用操作结合使用。
另请参阅“跨 CPU 获取屏障效果”部分。
例如,考虑以下情况:
*A = a;
*B = b;
获取
*C = c;
*D = d;
释放
*E = e;
*F = f;
以下事件序列是可以接受的:
获取,{*F,*A},*E,{*C,*D},*B,释放
注意:{*F,*A}
表示一个组合访问。
但以下事件序列都不是:
{*F,*A},*B, 获取,*C,*D, 释放,*E
*A,*B,*C, 获取,*D, 释放,*E,*F
*A,*B, 获取,*C, 释放,*D,*E,*F
*B, 获取,*C,*D, 释放,{*F,*A},*E
禁用中断函数
禁用中断(获取等效)和启用中断(释放等效)的函数将仅充当编译器屏障。因此,如果在这种情况下需要内存或 I/O 屏障,必须从其他方式提供。
休眠和唤醒函数
在全局数据中标记的事件上休眠和唤醒可以被视为等待事件的任务的任务状态和用于指示事件的全局数据之间的交互。为了确保这些看起来按正确的顺序发生,开始进入休眠过程的原语和启动唤醒的原语暗示了某些屏障。
首先,休眠者通常遵循以下事件序列:
for (;;) {
set_current_state(TASK_UNINTERRUPTIBLE);
if (event_indicated)
break;
schedule();
}
在 set_current_state() 改变任务状态后,将自动插入一个一般内存屏障:
CPU 1
===============================
set_current_state();
smp_store_mb();
存储 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() 唤醒了某些东西,将执行一般内存屏障。如果它没有唤醒任何东西,那么可能会执行内存屏障,也可能不会执行;你不能依赖它。屏障发生在访问任务状态之前,特别是在指示事件的存储和设置 TASK_RUNNING 的存储之间:
CPU 1(休眠者) CPU 2(唤醒者)
=============================== ===============================
set_current_state(); 存储 event_indicated
smp_store_mb(); wake_up();
存储 current->state ...
<一般屏障> <一般屏障>
LOAD event_indicated if ((LOAD task->state) & TASK_NORMAL)
存储 task->state
其中“task”是被唤醒的线程,它等于 CPU 1 的“current”。
重申一下,如果实际上唤醒了某些东西,wake_up() 保证执行一般内存屏障,但否则没有这样的保证。为了看到这一点,考虑以下事件序列,其中 X 和 Y 最初都是零:
CPU 1 CPU 2
=============================== ===============================
X = 1; Y = 1;
smp_mb(); LOAD Y
LOAD Y LOAD X
如果唤醒确实发生,两个加载中的至少一个将看到 1。另一方面,如果唤醒没有发生,两个加载可能都看到 0。
wake_up_process() 总是执行一般内存屏障。屏障再次发生在访问任务状态之前。特别是,如果前面片段中的 wake_up() 被替换为对 wake_up_process() 的调用,那么两个加载中的一个将被保证看到 1。
可用的唤醒函数包括:
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();
就内存排序而言,这些函数都提供了与 wake_up()(或更强)相同的保证。
注意:休眠者和唤醒者暗示的内存屏障不会在唤醒之前对多个存储进行排序,以便在休眠者调用 set_current_state() 后对这些存储值的加载。例如,如果休眠者执行:
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);
不能保证 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()
等函数意味着完全的内存屏障。
跨CPU获取屏障效果
在SMP系统中,锁原语提供了更实质性的屏障形式:在任何特定锁的冲突上下文中,它会影响其他CPU上的内存访问顺序。
获取与内存访问
考虑以下情况:系统有一对自旋锁(M)和(Q),以及三个CPU;那么如果发生以下事件序列:
CPU 1 CPU 2
=============================== ===============================
WRITE_ONCE(*A, a); WRITE_ONCE(*E, e);
ACQUIRE M ACQUIRE Q
WRITE_ONCE(*B, b); WRITE_ONCE(*F, f);
WRITE_ONCE(*C, c); WRITE_ONCE(*G, g);
RELEASE M RELEASE Q
WRITE_ONCE(*D, d); WRITE_ONCE(*H, h);
那么无法保证CPU 3将以何种顺序看到对 *A
到 *H
的访问,除了由各自CPU上的独立锁所施加的约束。例如,它可能会看到:
*E, ACQUIRE M, ACQUIRE Q, *G, *C, *F, *A, *B, RELEASE Q, *D, *H, RELEASE M
但它不会看到以下任何一种情况:
*B, *C 或 *D 在 ACQUIRE M 之前
*A, *B 或 *C 在 RELEASE M 之后
*F, *G 或 *H 在 ACQUIRE Q 之前
*E, *F 或 *G 在 RELEASE Q 之后
需要内存屏障的地方
在正常操作下,内存操作重排序通常不会成为问题,因为单线程的线性代码片段即使在SMP内核中也会正确运行。然而,有四种情况下重排序绝对可能会成为问题:
-
跨处理器交互。
-
原子操作。
-
访问设备。
-
中断。
跨处理器交互
当系统有多个处理器时,系统中的多个CPU可能同时处理相同的数据集。这可能会导致同步问题,通常处理这些问题的方式是使用锁。然而,锁的成本相当昂贵,因此如果可能的话最好在不使用锁的情况下进行操作。在这种情况下,可能需要仔细地对影响两个CPU的操作进行排序,以防止故障发生。
例如,考虑读/写信号量的慢路径。在这里,等待的进程被排队到信号量上,因为它的堆栈的一部分链接到等待进程的列表上:
struct rw_semaphore {
...
spinlock_t lock;
struct list_head waiters;
};
struct rwsem_waiter {
struct list_head list;
struct task_struct *task;
};
要唤醒特定的等待者,up_read()
或 up_write()
函数必须:
(1) 从等待者的记录中读取下一个指针,以了解下一个等待者记录的位置;
(2) 读取等待者的任务结构的指针;
(3) 清除任务指针,告诉等待者它已经获得了信号量;
(4) 在任务上调用 wake_up_process()
;以及
(5) 释放等待者的任务结构上的引用。
换句话说,它必须执行以下事件序列:
LOAD waiter->list.next;
LOAD waiter->task;
STORE waiter->task;
CALL wakeup
RELEASE task
如果这些步骤中的任何一步发生了顺序错误,那么整个过程可能会发生故障。
一旦它排队并释放了信号量锁,等待者就不会再次获得锁;它只是等待它的任务指针在继续之前被清除。由于记录在等待者的堆栈上,这意味着如果在读取列表中的下一个指针之前清除任务指针,另一个CPU可能会开始处理等待者,并可能在up*()
函数有机会读取下一个指针之前破坏等待者的堆栈。
然后考虑上述事件序列可能发生的情况:
CPU 1 CPU 2
=============================== ===============================
down_xxx()
排队等待者
休眠
up_yyy()
LOAD waiter->task;
STORE waiter->task;
被其他事件唤醒
<抢占>
恢复处理
down_xxx() 返回
调用 foo()
foo() 破坏 *waiter
</抢占>
LOAD waiter->list.next;
--- 出错 ---
这可以通过使用信号量锁来处理,但是 down_xxx()
函数在被唤醒后需要不必要地再次获取自旋锁。
处理这个问题的方法是插入一个通用的SMP内存屏障:
LOAD waiter->list.next;
LOAD waiter->task;
smp_mb();
STORE waiter->task;
CALL wakeup
RELEASE task
在这种情况下,该屏障保证了屏障之前的所有内存访问将在屏障之后的所有内存访问之前对系统上的其他CPU都是可见的。它 不 保证屏障之前的所有内存访问将在屏障指令本身完成时之前完成。
在单处理器系统(这不会是一个问题)中,smp_mb()
只是一个编译器屏障,确保编译器以正确的顺序生成指令,而不会实际干预CPU。由于只有一个CPU,该CPU的依赖顺序逻辑将处理其他一切。
原子操作
虽然它们在技术上是跨处理器交互的考虑,但原子操作特别值得注意,因为其中一些意味着完全的内存屏障,而另一些则不是,但它们在整个内核中作为一个组被广泛依赖。
有关更多信息,请参阅 Documentation/atomic_t.txt。
访问设备
许多设备可以进行内存映射,因此对CPU而言,它们看起来就像一组内存位置。为了控制这样的设备,驱动程序通常必须以完全正确的顺序进行正确的内存访问。
然而,聪明的CPU或聪明的编译器会产生一个潜在问题,即驱动程序代码中精心排序的访问如果CPU或编译器认为重新排序、合并或合并访问更有效,那么这些访问在不会以所需顺序到达设备,这会导致设备发生故障。
在Linux内核内部,I/O 应该通过适当的访问器例程进行 - 例如 inb()
或 writel()
- 它们知道如何适当地进行这样的访问。虽然这在大多数情况下使得显式使用内存屏障变得不必要,但如果访问器函数用于引用具有放松内存访问属性的I/O内存窗口,则 必须 使用内存屏障来强制排序。
有关更多信息,请参阅 Documentation/driver-api/device-io.rst。
中断
驱动程序可能会被其自身的中断服务例程中断,因此驱动程序的两个部分可能会相互干扰,以控制或访问设备。
这可能会得到缓解 - 至少部分地 - 通过禁用本地中断(一种锁定形式),使得所有关键操作都包含在驱动程序的中断禁用部分内。当驱动程序的中断例程正在执行时,驱动程序的核心可能不会在同一CPU上运行,其中断不允许再次发生,直到当前中断已被处理,因此中断处理程序不需要针对此进行锁定。
然而,考虑一个驱动程序正在与具有地址寄存器和数据寄存器的以太网卡进行通信。如果该驱动程序的核心在中断禁用状态下与卡通信,然后驱动程序的中断处理程序被调用:
本地中断禁用
writew(ADDR, 3);
writew(DATA, y);
本地中断启用
<中断>
writew(ADDR, 4);
q = readw(DATA);
</中断>
如果顺序规则足够宽松,数据寄存器的存储可能会发生在对地址寄存器的第二次存储之后:
存储 *ADDR = 3, 存储 *ADDR = 4, 存储 *DATA = y, q = 加载 *DATA
如果顺序规则被放宽,必须假定在中断禁用部分内进行的访问可能会泄漏到外部,并且可能会与在中断中执行的访问交错 - 反之亦然 - 除非使用隐式或显式的屏障。
通常这不会是一个问题,因为在这种部分内进行的I/O访问将包括对严格有序的I/O寄存器的同步加载操作,这些寄存器形成了隐式的I/O屏障。
类似的情况可能发生在中断例程和在相互通信的两个运行在不同CPU上的例程之间。如果这种情况可能发生,那么应该使用中断禁用锁来保证顺序。
内核I/O屏障效果
通过I/O访问与外围设备进行接口的方式深深地依赖于体系结构和设备的特定。因此,本质上不可移植的驱动程序可能依赖于其目标系统的特定行为,以便以最轻量级的方式实现同步。对于打算在多个体系结构和总线实现之间可移植的驱动程序,内核提供了一系列访问器函数,提供各种程度的顺序保证:
-
readX(),writeX():
readX()和writeX() MMIO访问器将外围设备的指针作为__iomem *参数。对于使用默认I/O属性映射的指针(例如由ioremap()返回的指针),顺序保证如下:-
对同一外围设备的所有readX()和writeX()访问是相互有序的。这确保了同一CPU线程对特定设备的MMIO寄存器访问将按程序顺序到达。
-
由持有自旋锁的CPU线程发出的writeX()在稍后获取相同自旋锁后由另一个CPU线程发出的writeX()之前是有序的。这确保了在持有自旋锁时对特定设备的MMIO寄存器写入将按照锁的获取顺序到达。
-
由CPU线程对外围设备的writeX()将首先等待由同一线程发出或传播到同一线程的所有先前内存写入的完成。这确保了由CPU对由dma_alloc_coherent()分配的出站DMA缓冲区的写入将在CPU写入其MMIO控制寄存器以触发传输时对DMA引擎可见。
-
由CPU线程对外围设备的readX()将在同一线程开始任何后续内存读取之前完成。这确保了由CPU对由dma_alloc_coherent()分配的入站DMA缓冲区的读取在从DMA引擎的MMIO状态寄存器读取后建立DMA传输已完成后不会看到陈旧数据。
-
由CPU线程对外围设备的readX()将在同一线程开始任何后续delay()循环之前完成。这确保了如果第一个写入立即被readX()读回并在第二个writeX()之前调用udelay(1),则CPU对外围设备的两个MMIO寄存器写入将至少相隔1微秒:
writel(42, DEVICE_REGISTER_0); // 到达设备... readl(DEVICE_REGISTER_0); udelay(1); writel(42, DEVICE_REGISTER_1); // ...至少在1微秒之前到达。
使用非默认属性(例如由ioremap_wc()返回的指针)获得的__iomem指针的顺序属性特定于底层体系结构,因此上述保证通常不能依赖于对这些类型映射的访问。
-
-
readX_relaxed(),writeX_relaxed():
这些与readX()和writeX()类似,但提供更弱的内存顺序保证。具体来说,它们不保证与锁定、正常内存访问或delay()循环(即上述的2-5)的顺序,但它们仍然保证在操作使用默认I/O属性映射的__iomem指针时与同一CPU线程对同一外围设备的其他访问有序。 -
readsX(),writesX():
readsX()和writesX() MMIO访问器设计用于访问驻留在不具备执行DMA能力的外围设备上的基于寄存器的内存映射FIFO。因此,它们仅提供了readX_relaxed()和writeX_relaxed()的顺序保证,如上文所述。 -
inX(),outX():
inX()和outX()访问器旨在访问传统的基于端口映射的I/O外围设备,在某些体系结构(特别是x86)上可能需要特殊指令。被访问的外围设备的端口号作为参数传递。由于许多CPU体系结构最终通过内部虚拟内存映射访问这些外围设备,因此在使用默认I/O属性的映射时,inX()和outX()提供的可移植顺序保证与分别访问具有默认I/O属性的映射时提供的readX()和writeX()相同。
设备驱动程序可以期望outX()发出一个非发布写事务,它在返回之前等待I/O外围设备的完成响应。这并不是所有体系结构都保证的,因此不是可移植的顺序语义的一部分。
-
insX(),outsX():
与上述类似,当访问具有默认I/O属性的映射时,insX()和outsX()访问器提供了与readsX()和writesX()相同的顺序保证。 -
ioreadX(),iowriteX():
这些将根据它们实际执行的访问类型执行适当的操作,无论是inX()/outX()还是readX()/writeX()。
除了字符串访问器(insX(),outsX(),readsX()和writesX())之外,上述所有访问器都假定底层外围设备是小端的,因此在大端体系结构上将执行字节交换操作。
假定的最小执行顺序模型
必须假定概念上的 CPU 是弱排序的,但它将保持对自身的程序因果关系的外观。一些 CPU(如 i386 或 x86_64)比其他一些 CPU(如 powerpc 或 frv)更受限制,因此必须假定最放松的情况(即 DEC Alpha)超出了特定于体系结构的代码。
这意味着必须考虑 CPU 可以以任何它喜欢的顺序执行其指令流,甚至可以并行执行,只要指令流中的某个指令依赖于较早的指令,那么较早的指令必须足够完整[*],才能使后续指令继续进行;换句话说:只要保持因果关系的外观。
[*] 一些指令具有多个效果 - 例如改变条件码、改变寄存器或改变内存 - 不同的指令可能依赖于不同的效果。
CPU 也可以丢弃最终没有效果的任何指令序列。例如,如果两个相邻的指令都将一个立即值加载到同一个寄存器中,第一个指令可能会被丢弃。
同样,必须假定编译器可能以任何它认为合适的方式重新排序指令流,只要保持因果关系的外观。
CPU 缓存的影响
缓存内存操作在整个系统中的感知受到位于 CPU 和内存之间的缓存以及维护系统状态一致性的内存一致性系统的影响。
就 CPU 通过缓存与系统的其他部分进行交互的方式而言,内存系统必须包括 CPU 的缓存,而且大部分情况下内存屏障作用于 CPU 和其缓存之间的接口(内存屏障在以下图表中逻辑上作用于虚线):
<--- CPU ---> : <----------- Memory ----------->
:
+--------+ +--------+ : +--------+ +-----------+
| | | | : | | | | +--------+
| CPU | | Memory | : | CPU | | | | |
| Core |--->| Access |----->| Cache |<-->| | | |
| | | Queue | : | | | |--->| Memory |
| | | | : | | | | | |
+--------+ +--------+ : +--------+ | | | |
: | Cache | +--------+
: | Coherency |
: | Mechanism | +--------+
+--------+ +--------+ : +--------+ | | | |
| | | | : | | | | | |
| CPU | | Memory | : | CPU | | |--->| Device |
| Core |--->| Access |----->| Cache |<-->| | | |
| | | Queue | : | | | | | |
| | | | : | | | | +--------+
+--------+ +--------+ : +--------+ +-----------+
:
:
尽管任何特定的加载或存储可能实际上不会出现在发出它的 CPU 之外,因为它可能已经在 CPU 自己的缓存中得到满足,但就其他 CPU 而言,由于缓存一致性机制将缓存行迁移到访问的 CPU 并传播冲突时的效果,它仍将看起来好像完整的内存访问已经发生。
CPU 核心可以以它认为合适的任何顺序执行指令,只要保持预期的程序因果关系。一些指令生成加载和存储操作,然后进入要执行的内存访问队列。核心可以以任何它希望的顺序将这些放入队列,并继续执行,直到被迫等待指令完成。
内存屏障关注的是控制从 CPU 端到内存端的访问的顺序,以及其他系统中的观察者感知发生的效果的顺序。
[!] 在给定的 CPU 内部不需要内存屏障,因为 CPU 总是将自己的加载和存储视为按程序顺序发生的。
[!] MMIO 或其他设备访问可能绕过缓存系统。这取决于通过设备访问的内存窗口的属性和/或 CPU 可能具有的任何特殊设备通信指令的使用。
缓存一致性与 DMA
并非所有系统都与执行 DMA 的设备保持缓存一致性。在这种情况下,尝试执行 DMA 的设备可能从 RAM 中获取过时的数据,因为各种 CPU 的缓存中可能存在脏缓存行,可能尚未写回到 RAM。为了解决这个问题,内核的适当部分必须刷新每个 CPU 上重叠的缓存位(可能还要使其无效)。
此外,由设备通过 DMA 写入 RAM 的数据可能会被 CPU 的脏缓存行覆盖,或者 CPU 的缓存行可能简单地掩盖了 RAM 已被更新的事实,直到 CPU 的缓存行被丢弃并重新加载。为了解决这个问题,内核的适当部分必须使每个 CPU 上重叠的缓存位无效。
有关缓存管理的更多信息,请参阅 Documentation/core-api/cachetlb.rst。
缓存一致性与 MMIO
内存映射 I/O 通常通过 CPU 内存空间中具有不同属性分配的内存位置进行。其中的属性通常包括这样的事实,即这些访问完全绕过缓存并直接进入设备总线。这意味着 MMIO 访问可能实际上超越了先前发出的缓存内存访问。在这种情况下,内存屏障是不够的,而是必须在缓存内存写入和 MMIO 访问之间刷新缓存,如果两者在任何方面有依赖关系。
CPU所做的事情
程序员可能认为CPU会按照指定的顺序执行内存操作,因此,如果CPU被给予以下代码片段进行执行:
a = READ_ONCE(*A);
WRITE_ONCE(*B, b);
c = READ_ONCE(*C);
d = READ_ONCE(*D);
WRITE_ONCE(*E, e);
他们会期望CPU在执行每个指令之前完成每个指令的内存操作,从而导致外部观察者在系统中看到的操作序列是确定的:
LOAD *A, STORE *B, LOAD *C, LOAD *D, STORE *E.
现实情况当然要复杂得多。在许多CPU和编译器中,上述假设不成立,因为:
-
加载操作更有可能需要立即完成以允许执行进展,而存储操作通常可以延迟而不会出现问题;
-
加载操作可能是推测性的,如果证明是不必要的,则结果可能会被丢弃;
-
加载操作可能是推测性的,导致结果在预期的事件顺序中的错误时间被获取;
-
内存访问的顺序可能会重新排列以促进CPU总线和缓存的更好使用;
-
加载和存储操作可能会合并以提高与可以批量访问相邻位置的内存或I/O硬件进行通信时的性能,从而减少事务设置成本(内存和PCI设备都可以做到这一点);
-
CPU的数据缓存可能会影响顺序,虽然一旦存储实际命中缓存,一致性机制可能会缓解这个问题,但不能保证一致性管理将按顺序传播到其他CPU。
因此,另一个CPU可能实际上从上述代码片段中观察到的是:
LOAD *A, ..., LOAD {*C,*D}, STORE *E, STORE *B
(其中"LOAD {*C,*D}"是一个合并的加载操作)
然而,可以保证CPU是自洽的:它会看到自己的访问似乎是正确排序的,而不需要内存屏障。例如,对于以下代码:
U = READ_ONCE(*A);
WRITE_ONCE(*A, V);
WRITE_ONCE(*A, W);
X = READ_ONCE(*A);
WRITE_ONCE(*A, Y);
Z = READ_ONCE(*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
按照这个顺序,但是,如果没有干预,序列可能会有几乎任何组合的元素合并或丢弃,只要程序对世界的观察保持一致即可。请注意,上述示例中的READ_ONCE()和WRITE_ONCE()不是可选的,因为存在某些体系结构,其中给定的CPU可能会重新排序对同一位置的连续加载。在这种体系结构上,READ_ONCE()和WRITE_ONCE()会执行必要的操作以防止这种情况发生,例如,在Itanium上,READ_ONCE()和WRITE_ONCE()使用的volatile转换会导致GCC发出特殊的ld.acq和st.rel指令,以防止这种重新排序。
编译器在CPU看到它们之前也可以合并、丢弃或延迟序列的元素。
例如:
*A = V;
*A = W;
可以简化为:
*A = W;
因为在没有写屏障或WRITE_ONCE()的情况下,可以假设将V存储到*A的效果已经丢失。类似地:
*A = Y;
Z = *A;
在没有内存屏障或READ_ONCE()和WRITE_ONCE()的情况下,可以简化为:
*A = Y;
Z = Y;
并且LOAD操作永远不会出现在CPU之外。
然后是Alpha处理器
DEC Alpha处理器是最灵活的处理器之一。不仅如此,Alpha处理器的某些版本具有分离的数据缓存,使它们能够在不同的时间更新两个语义相关的缓存行。这就是地址依赖性屏障真正必要的地方,因为它将这两个缓存与内存一致性系统同步,从而使指针更改与新数据的发生顺序看起来是正确的。
Alpha定义了Linux内核的内存模型,尽管从v4.15开始,Linux内核在Alpha上将smp_mb()添加到READ_ONCE()中大大减少了其对内存模型的影响。
虚拟机客户机
即使客户机本身在编译时没有SMP支持,运行在虚拟机中的客户机可能会受到SMP效应的影响。这是在运行UP内核的同时与SMP主机进行接口交互的结果。在这种情况下使用强制屏障是可能的,但通常不是最佳选择。
为了最优地处理这种情况,可以使用低级别的virt_mb()等宏。当启用SMP时,它们与smp_mb()等具有相同的效果,但对于SMP和非SMP系统生成相同的代码。例如,虚拟机客户机在与(可能是SMP的)主机同步时应使用virt_mb()而不是smp_mb()。
在所有其他方面,这些与smp_mb()等对应物是等效的,特别是它们不控制MMIO效果:要控制MMIO效果,请使用强制屏障。
示例用途
循环缓冲区
可以使用内存屏障来实现循环缓冲区,而无需使用锁来使生产者与消费者串行化。详见:
Documentation/core-api/circular-buffers.rst
参考资料
Alpha AXP体系结构参考手册,第二版(Sites&Witek,Digital Press)
- 第5.2章:物理地址空间特性
- 第5.4章:缓存和写缓冲区
- 第5.5章:数据共享
- 第5.6章:读/写排序
AMD64体系结构程序员手册第2卷:系统编程
- 第7.1章:内存访问排序
- 第7.4章:缓冲和组合内存写入
ARM体系结构参考手册(ARMv8,适用于ARMv8-A体系结构配置文件)
- 第B2章:AArch64应用级内存模型
IA-32英特尔体系结构软件开发人员手册,第3卷:系统编程指南
- 第7.1章:锁定原子操作
- 第7.2章:内存排序
- 第7.4章:序列化指令
SPARC体系结构手册,第9版
- 第8章:内存模型
- 附录D:内存模型的正式规范
- 附录J:使用内存模型编程
PowerPC中的存储(Stone和Fitzgerald)
UltraSPARC程序员参考手册
- 第5章:内存访问和可缓存性
- 第15章:Sparc-V9内存模型
UltraSPARC III Cu用户手册
- 第9章:内存模型
UltraSPARC IIIi处理器用户手册
- 第8章:内存模型
UltraSPARC体系结构2005
- 第9章:内存
- 附录D:内存模型的正式规范
UltraSPARC T1补充到UltraSPARC体系结构2005
- 第8章:内存模型
- 附录F:缓存和缓存一致性
Solaris Internals,核心内核架构,p63-68:
- 第3.3章:锁和同步的硬件考虑因素
现代体系结构的Unix系统,对称多处理和内核程序的缓存:
- 第13章:其他内存模型
Intel Itanium体系结构软件开发人员手册:第1卷:
- 第2.6节:推测
- 第4.4节:内存访问
本文来自博客园,作者:摩斯电码,未经同意,禁止转载