50

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

/**
 * 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 的操作在屏障开始之后的任何类型 Y 的操作之前完成。

我们现在可以将屏障名称“映射”Unsafe到这个术语:

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

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

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

提前致谢。

4

4 回答 4

63

概括

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

为 Unsafe API 选择的防护操作是基于以下假设选择的:底层处理器将具有单独的加载顺序缓冲区(用于重新排序加载)、存储顺序缓冲区(用于重新排序存储)。

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

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

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

  • 当架构更灵活时,API 根本不提供对“更宽松”排序组合的访问作为选择问题

  • 当架构更严格时,API 在所有情况下都简单地实现更严格的排序保证(例如,实际上所有 3 个调用都被实现为完整的栅栏)

根据 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 的缓存中并由其独占拥有的情况。坏事可能发生如下:

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

于 2014-05-20T22:11:37.867 回答
8

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) 指令似乎是一个合理的设计决策。

于 2014-05-21T11:48:43.767 回答
5

storeFence() 的文档是错误的。见https://bugs.openjdk.java.net/browse/JDK-8038978

loadFence() 是 LoadLoad 加上 LoadStore,所以很有用,通常称为获取栅栏。

storeFence() 是 StoreStore 加上 LoadStore,所以很有用,通常称为释放栅栏。

LoadLoad LoadStore StoreStore 是便宜的栅栏(x86 或 Sparc 上没有,Power 上便宜,ARM 上可能很贵)。

IA64 对获取和释放语义有不同的指令。

fullFence() 是 LoadLoad LoadStore StoreStore 加上 StoreLoad。

StordLoad 栅栏很昂贵(几乎在所有 CPU 上),几乎与完整栅栏一样昂贵。

这证明了 API 设计的合理性。

于 2015-06-02T18:30:31.170 回答
0

根据源代码中的注释,看起来 storeFence() 应该映射到“loadStore_storeFence”:

https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/jdk/internal/misc/Unsafe.java#L3422

/**
 * Ensures that loads and stores before the fence will not be reordered with
 * stores after the fence; a "StoreStore plus LoadStore barrier".
 * ...
 * /
于 2020-05-05T14:34:16.280 回答