概括
CPU 内核具有特殊的内存排序缓冲区,以帮助它们进行乱序执行。这些可以(并且通常是)单独用于加载和存储:加载顺序缓冲区的 LOB 和存储顺序缓冲区的 SOB。
为 Unsafe API 选择的防护操作是基于以下假设选择的:底层处理器将具有单独的加载顺序缓冲区(用于重新排序加载)、存储顺序缓冲区(用于重新排序存储)。
因此,基于此假设,从软件的角度来看,您可以向 CPU 请求以下三件事之一:
- 清空 LOB (loadFence):意味着在处理完 LOB 的所有条目之前,不会在此内核上开始执行其他指令。在 x86 中,这是一个 LFENCE。
- 清空 SOB (storeFence):表示在处理完 SOB 中的所有条目之前,不会在此内核上开始执行其他指令。在 x86 中,这是一个 SFENCE。
- Empty both LOBs and SOBs(fullFence): 表示以上两者。在 x86 中,这是一个 MFENCE。
实际上,每个特定的处理器架构都提供不同的内存排序保证,这可能比上述更严格或更灵活。例如,SPARC 架构可以重新排序加载-存储和存储-加载序列,而 x86 不会这样做。此外,存在无法单独控制 LOB 和 SOB 的架构(即,只有全围栏是可能的)。然而,在这两种情况下:
根据 assylias 提供的 100% 现场回答,JEP 中解释了特定 API 选择的原因。如果您了解内存排序和缓存一致性,那么 assylias 的回答就足够了。我认为它们与 C++ API 中的标准化指令相匹配的事实是一个主要因素(大大简化了 JVM 实现):http ://en.cppreference.com/w/cpp/atomic/memory_order很可能,实际实现将调用相应的 C++ API,而不是使用一些特殊指令。
下面我对基于 x86 的示例进行了详细解释,它将提供理解这些内容所需的所有上下文。事实上,划分的 (下面的部分回答了另一个问题:“您能否提供一些基本示例来说明内存栅栏如何工作以控制 x86 架构中的缓存一致性?”
原因是我自己(来自软件开发人员而不是硬件设计人员)在理解什么是内存重新排序时遇到了困难,直到我了解了缓存一致性在 x86 中实际工作的具体示例。这为讨论一般的内存栅栏提供了宝贵的上下文(对于其他架构也是如此)。最后,我使用从 x86 示例中获得的知识稍微讨论了 SPARC
参考文献 [1] 是更详细的解释,并有单独的部分讨论以下各项:x86、SPARC、ARM 和 PowerPC,因此如果您对更多细节感兴趣,这是一本很好的读物。
x86 架构示例
x86 提供了 3 种栅栏指令:LFENCE(加载栅栏)、SFENCE(存储栅栏)和 MFENCE(加载-存储栅栏),因此它 100% 映射到 Java API。
这是因为 x86 具有单独的加载顺序缓冲区 (LOB) 和存储顺序缓冲区 (SOB),因此 LFENCE/SFENCE 指令确实适用于各自的缓冲区,而 MFENCE 则适用于两者。
SOB 用于存储传出值(从处理器到缓存系统),而缓存一致性协议用于获取写入缓存行的权限。LOB 用于存储失效请求,以便可以异步执行失效(减少接收端的停顿,希望在那里执行的代码实际上不需要该值)。
乱序商店和 SFENCE
假设您有一个双处理器系统,它的两个 CPU(0 和 1)执行以下例程。考虑缓存线failure
最初归 CPU 1 所有,而缓存线shutdown
最初归 CPU 0 所有的情况。
// CPU 0:
void shutDownWithFailure(void)
{
failure = 1; // must use SOB as this is owned by CPU 1
shutdown = 1; // can execute immediately as it is owned be CPU 0
}
// CPU1:
void workLoop(void)
{
while (shutdown == 0) { ... }
if (failure) { ...}
}
在没有存储围栏的情况下,CPU 0 可能会因故障而发出关闭信号,但 CPU 1 将退出循环并且不会进入故障处理 if 块。
这是因为 CPU0 会将值 1 写入failure
存储顺序缓冲区,同时发送缓存一致性消息以获取对缓存行的独占访问。然后它将继续执行下一条指令(在等待独占访问时)并shutdown
立即更新标志(此缓存线已由 CPU0 独占,因此无需与其他内核协商)。最后,当它稍后收到来自 CPU1 的无效确认消息(关于failure
)时,它将继续处理 SOBfailure
并将值写入缓存(但现在顺序颠倒了)。
插入 storeFence() 将解决问题:
// CPU 0:
void shutDownWithFailure(void)
{
failure = 1; // must use SOB as this is owned by CPU 1
SFENCE // next instruction will execute after all SOBs are processed
shutdown = 1; // can execute immediately as it is owned be CPU 0
}
// CPU1:
void workLoop(void)
{
while (shutdown == 0) { ... }
if (failure) { ...}
}
值得一提的最后一个方面是 x86 具有存储转发:当 CPU 写入的值卡在 SOB 中时(由于缓存一致性),它随后可能会尝试在 SOB 之前执行相同地址的加载指令处理并交付给缓存。因此,CPU 将在访问缓存之前查询 SOB,因此在这种情况下检索到的值是从 SOB 中最后写入的值。这意味着无论如何,来自 THIS 核心的存储永远不会随着来自 THIS 核心的后续加载而重新排序。
乱序加载和 LFENCE
现在,假设您已经安装了商店围栏,并且很高兴在通往 CPU 1 的途中shutdown
无法超车,并专注于另一边。failure
即使在商店围栏存在的情况下,也有发生错误事情的情况。考虑failure
在两个缓存中(共享)而shutdown
仅存在于 CPU0 的缓存中并由其独占拥有的情况。坏事可能发生如下:
- CPU0 将 1 写入
failure
; 作为缓存一致性协议的一部分,它还向 CPU1 发送一条消息,以使其共享缓存行的副本无效。
- CPU0 执行 SFENCE 并停止,等待用于
failure
提交的 SOB。
- CPU1
shutdown
由于 while 循环进行检查,并且(意识到它缺少值)发送缓存一致性消息来读取值。
- CPU1 在步骤 1 中接收到来自 CPU0 的消息以使其无效
failure
,并立即发送确认消息。注意:这是使用失效队列实现的,因此实际上它只是输入一个注释(在其 LOB 中分配一个条目)以稍后进行失效,但在发送确认之前实际上并不执行它。
- CPU0 收到确认
failure
并通过 SFENCE 进入下一条指令
- CPU0 在不使用 SOB 的情况下将 1 写入关闭,因为它已经独占拥有高速缓存行。由于高速缓存行是 CPU0 独有的,因此不会发送额外的无效消息
- CPU1 接收该
shutdown
值并将其提交到其本地缓存,继续执行下一行。
- CPU1 检查
failure
if 语句的值,但由于无效队列(LOB 注释)尚未处理,它使用其本地缓存中的值 0(不进入 if 块)。
- CPU1处理无效队列并更新
failure
为1,但已经来不及了……
我们所说的加载顺序缓冲区,实际上是无效请求的排队,上面可以通过以下方式修复:
// CPU 0:
void shutDownWithFailure(void)
{
failure = 1; // must use SOB as this is owned by CPU 1
SFENCE // next instruction will execute after all SOBs are processed
shutdown = 1; // can execute immediately as it is owned be CPU 0
}
// CPU1:
void workLoop(void)
{
while (shutdown == 0) { ... }
LFENCE // next instruction will execute after all LOBs are processed
if (failure) { ...}
}
你在 x86 上的问题
既然您知道 SOB/LOB 的作用,请考虑您提到的组合:
loadFence() becomes load_loadstoreFence();
不,负载栅栏等待 LOB 被处理,本质上是清空失效队列。这意味着所有后续加载都将看到最新的数据(没有重新排序),因为它们将从缓存子系统中获取(这是一致的)。存储不能在后续加载时重新排序,因为它们不通过 LOB。(此外,存储转发处理本地修改的缓存行)从这个特定核心(执行加载栅栏的那个)的角度来看,加载栅栏之后的存储将在所有寄存器加载数据之后执行。没有其他办法了。
load_storeFence() becomes ???
不需要 load_storeFence,因为它没有意义。要存储某些内容,您必须使用输入进行计算。要获取输入,您必须执行加载。存储将使用从负载中获取的数据进行。如果您想确保在加载时看到来自所有其他处理器的最新值,请使用 loadFence。对于栅栏存储转发之后的负载,需要注意一致的排序。
所有其他情况都类似。
SPARC
SPARC 更加灵活,可以在后续加载时重新排序存储(以及在后续存储中加载)。我对 SPARC 不太熟悉,所以我的猜测是没有存储转发(重新加载地址时不咨询 SOB),所以“脏读”是可能的。事实上我错了:我在 [3] 中找到了 SPARC 架构,而实际情况是存储转发是线程化的。从第 5.3.4 节开始:
所有负载都会检查存储缓冲区(仅限同一线程)是否存在写后读 (RAW) 危险。当加载的双字地址与 STB 中存储的地址匹配并且加载的所有字节在存储缓冲区中有效时,就会发生完整的 RAW。当双字地址匹配时会出现部分 RAW,但存储缓冲区中的所有字节均无效。(例如,ST(字存储)后跟 LDX(双字加载)到同一地址会导致部分 RAW,因为完整的双字不在存储缓冲区条目中。)
因此,不同的线程会查询不同的存储顺序缓冲区,因此可能会在存储后进行脏读。
参考
[1] 内存屏障:软件黑客的硬件视图,Linux 技术中心,IBM Beaverton
http://www.rdrop.com/users/paulmck/scalability/paper/whymb.2010.07.23a.pdf
[2] 英特尔® 64 和 IA-32 架构软件开发人员手册,第 3A 卷
http://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-软件开发者-vol-3a-part-1-manual.pdf
[3] OpenSPARC T2 核心微架构规范http://www.oracle.com/technetwork/systems/opensparc/t2-06-opensparct2-core-microarch-1537749.html