Java 8 Unsafe: xxxFence() 指令

在Java 8中,三个内存屏障指令被添加到类中():Unsafe

/**
 * Ensures lack of reordering of loads before the fence
 * with loads or stores after the fence.
 */
void loadFence();

/**
 * Ensures lack of reordering of stores before the fence
 * with loads or stores after the fence.
 */
void storeFence();

/**
 * Ensures lack of reordering of loads or stores before the fence
 * with loads or stores after the fence.
 */
void fullFence();

如果我们用以下方式定义内存屏障(我认为这或多或少很容易理解):

将 X 和 Y 视为要重新排序的操作类型/类,

X_YFence() 是一个内存屏障指令,它确保在屏障启动后,在屏障完成之前,X 类型的所有操作在屏障之前完成。

现在,我们可以将障碍名称“映射”到以下术语:Unsafe

  • loadFence()成为load_loadstoreFence();
  • storeFence()成为store_loadStoreFence();
  • fullFence()成为loadstore_loadstoreFence();

最后,我的问题是 - 为什么我们没有,和?load_storeFence()store_loadFence()store_storeFence()load_loadFence()

我的猜测是-它们并不是必需的,但我现在不明白为什么。所以,我想知道不添加它们的原因。对此的猜测也是受欢迎的(希望这不会导致这个问题偏离主题,因为基于意见)。

提前致谢。


答案 1

总结

CPU 内核具有特殊的内存排序缓冲区,以帮助它们执行无序。这些可以(并且通常是)单独的用于加载和存储:用于加载顺序缓冲区的 LOB 和用于存储顺序缓冲区的 SOB。

为不安全 API 选择的屏蔽操作是基于以下假设选择的:基础处理器将具有单独的加载顺序缓冲区(用于对加载进行重新排序)、存储顺序缓冲区(用于对存储进行重新排序)。

因此,基于此假设,从软件的角度来看,您可以从CPU请求以下三件事之一:

  1. 清空 LOB(loadFence):表示在处理完 LOB 的所有条目之前,不会在此内核上开始执行任何其他指令。在 x86 中,这是一个 LFENCE。
  2. 清空 SOB(storeFence):表示在处理完 SOB 中的所有条目之前,不会在此内核上开始执行任何其他指令。在 x86 中,这是一个 SFENCE。
  3. 清空 LOB 和 SOB(fullFence):表示上述两者。在 x86 中,这是一个 MFENCE。

实际上,每个特定的处理器架构都提供不同的内存排序保证,这些保证可能比上述更严格,或者更灵活。例如,SPARC 体系结构可以对加载-存储和存储-加载序列进行重新排序,而 x86 则不会这样做。此外,存在无法单独控制LOB和SOB的体系结构(即只能进行全栅栏)。但是,在这两种情况下:

  • 当架构更加灵活时,API根本不提供对“更宽松”的排序组合的访问,这是一个选择问题。

  • 当架构更严格时,API只是在所有情况下实现更严格的排序保证(例如,所有3个调用实际上和更高版本都作为完整的栅栏实现)

JEP中根据assylias提供的答案解释了特定API选择的原因,该答案是100%现场。如果您了解内存排序和缓存一致性,那么 assylias 的答案就足够了。我认为它们与C++ API 中的标准化指令相匹配的事实是一个主要因素(大大简化了 JVM 实现):http://en.cppreference.com/w/cpp/atomic/memory_order 实际上,实际的实现将调用相应的C++ API,而不是使用一些特殊的指令。

下面我用基于 x86 的示例进行了详细的解释,这些示例将提供理解这些内容所需的所有上下文。事实上,Demarcated(下面的部分回答了另一个问题:“您能否提供内存栅栏如何工作以控制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,正在执行以下例程。请考虑以下情况:高速缓存行保留最初由 CPU 1 拥有,而高速缓存行保留最初由 CPU 0 拥有。failureshutdown

// 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 将退出环路,并且如果阻塞,则不会进入故障处理。

这是因为 CPU0 会将 值 1 写入存储顺序缓冲区,同时发送缓存一致性消息以获取对缓存行的独占访问权限。然后,它将继续执行下一个指令(在等待独占访问时)并立即更新标志(此缓存行已由 CPU0 独占拥有,因此无需与其他内核协商)。最后,当它稍后收到来自CPU1(关于)的无效确认消息时,它将继续处理SOB并将值写入缓存(但顺序现在已经颠倒)。failureshutdownfailurefailure

插入 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的途中无法超车,并专注于另一侧。即使在商店围栏的存在下,也存在错误情况。请考虑以下情况:在两个缓存中(共享),而它仅存在于 CPU0 的缓存中并由 CPU0 的缓存独占拥有。坏事可能会发生如下:shutdownfailurefailureshutdown

  1. CPU0 将 1 写入 ;它还向 CPU1 发送一条消息,以使其作为高速缓存一致性协议一部分的共享高速缓存行的副本失效failure
  2. CPU0 执行 SFENCE 并停止,等待用于 的 SOB 提交。failure
  3. CPU1 由于 while 循环而进行检查,并且(意识到它缺少该值)发送缓存一致性消息来读取该值。shutdown
  4. CPU1 在步骤 1 中接收来自 CPU0 的消息以使其失效,并立即发送确认。注意:这是使用失效队列实现的,因此实际上它只是输入一个注释(在其 LOB 中分配一个条目)以便稍后执行失效,但在发送确认之前实际上并没有执行它。failure
  5. CPU0 接收确认并继续通过 SFENCE 到下一条指令failure
  6. CPU0 在不使用 SOB 的情况下将 1 写入关闭,因为它已经独占地拥有缓存行。不会发送额外的失效消息,因为缓存行是 CPU0 独有的
  7. CPU1 接收该值并将其提交到其本地缓存,然后继续执行下一行。shutdown
  8. CPU1 检查 if 语句的值,但由于尚未处理无效队列(LOB 注释),因此它使用其本地缓存中的值 0(不输入 if block)。failure
  9. CPU1 处理无效队列并更新到 1,但为时已晚...failure

我们所说的加载顺序缓冲区,实际上是无效请求的排队,上述问题可以通过以下方式进行修复:

// 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) 危险。当加载的 dword 地址与 STB 中存储的 dword 地址匹配并且加载的所有字节在存储缓冲区中都有效时,将发生完整的 RAW。当 dword 地址匹配,但所有字节在存储缓冲区中都无效时,将发生部分 RAW。(例如,ST(字存储)后跟 LDX(dword 加载)到同一地址会导致部分 RAW,因为完整的 dword 不在存储缓冲区条目中。

因此,不同的线程会查询不同的存储顺序缓冲区,因此在存储后可能会进行脏读取。


引用

[1] 内存障碍:软件黑客的硬件视图,Linux 技术中心,IBM 比弗顿 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-software-developer-vol-3a-part-1-manual.pdf

[3] OpenSPARC T2 核心微体系结构规范 http://www.oracle.com/technetwork/systems/opensparc/t2-06-opensparct2-core-microarch-1537749.html


答案 2

一个很好的信息来源是JEP 171本身

理由:

这三种方法提供了三种不同类型的内存栅栏,某些编译器和处理器需要这些内存栅栏来确保特定访问(加载和存储)不会重新排序。

实现(提取):

对于C++运行时版本(在 prims/unsafe.cpp 中),通过现有的 OrderAccess 方法实现:

    loadFence:  { OrderAccess::acquire(); }
    storeFence: { OrderAccess::release(); }
    fullFence:  { OrderAccess::fence(); }

换句话说,新方法与如何在JVM和CPU级别实现内存围栏密切相关。它们还与C++(实现热点的语言)中可用的内存屏障指令相匹配。

更细粒度的方法可能是可行的,但好处并不明显。

例如,如果您查看 JSR 133 Cookbook 中的 cpu 指令表,您将看到 LoadStore 和 LoadLoad 在大多数体系结构上都映射到相同的指令,即两者都有效地Load_LoadStore指令。因此,在JVM级别拥有单个Load_LoadStore()指令似乎是一个合理的设计决策。loadFence


推荐