Java 8 Unsafe:xxxFence()指令

Ale*_*lev 45 java concurrency unsafe memory-fences java-8

在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();
Run Code Online (Sandbox Code Playgroud)

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

考虑X和Y是要重新排序的操作类型/类,

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

我们现在可以将障碍名称"映射" Unsafe到这个术语:

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

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

我的猜测是 - 他们不是真的必要,但我不明白为什么目前.所以,我想知道不添加它们的原因.关于这一点的猜测也是受欢迎的(希望这不会导致这个问题在基于意见的情况下变成离题).

提前致谢.

Ale*_*ros 58

摘要

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

为Unsafe 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个呼叫都被实现为完整的围栏)

特定API选择的原因在JEP中解释,根据答案assylias提供的是100%的现场.如果您了解内存排序和缓存一致性,那么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) { ...}
}
Run Code Online (Sandbox Code Playgroud)

在没有存储围栏的情况下,CPU 0可能会因故障而发出关闭信号,但CPU 1将退出循环并且如果阻塞则不会进入故障处理.

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

插入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) { ...}
}
Run Code Online (Sandbox Code Playgroud)

值得一提的最后一个方面是x86具有存储转发:当CPU写入卡在SOB中的值时(由于缓存一致性),它可能随后尝试在SOB之前执行相同地址的加载指令.处理并传送到缓存.因此,CPU将访问SOB以访问高速缓存,因此在这种情况下检索的值是来自SOB的最后写入的值.这意味着无论如何,来自THIS核心的商店永远不会重新订购来自此核心的后续负载.

无序负载和LFENCE

现在,假设您有商店围栏,并且很高兴在前往CPU 1的路上shutdown无法超车failure,并专注于另一侧.即使存在商店围栏,也存在发生错误事件的情况.考虑failure两个缓存(共享)中的情况,而shutdown仅存在于CPU0的缓存中并由其独占.坏事可能发生如下:

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

你在x86上的问题

既然您知道SOB/LOB的作用,请考虑您提到的组合:

loadFence() becomes load_loadstoreFence();
Run Code Online (Sandbox Code Playgroud)

不,加载围栏等待处理LOB,基本上清空失效队列.这意味着所有后续加载都将看到最新数据(无重新排序),因为它们将从缓存子系统(相关)中获取.存储CANNNOT与后续加载重新排序,因为它们不通过LOB.(以及存储转发处理本地修改的cachce行)从这个特定核心(执行加载栅栏的核心)的角度来看,跟随加载栅栏的存储将在所有寄存器加载数据之后执行.没有其他办法了.

load_storeFence() becomes ???
Run Code Online (Sandbox Code Playgroud)

不需要load_storeFence,因为它没有意义.要存储某些内容,您必须使用输入来计算它.要获取输入,您必须执行加载.存储将使用从加载中获取的数据进行.如果要确保在加载时使用loadFence从所有OTHER处理器中看到最新值.对于围栏存储转发后的负载,负责一致的排序.

所有其他情况都类似.


SPARC

SPARC更加灵活,可以通过后续加载(以及后续商店的加载)对商店进行重新排序.我对SPARC并不熟悉,所以我的GUESS是没有存储转发(重新加载地址时不会查询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-部分-1-手册.pdf

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


ass*_*ias 7

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

理由:

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

实施(摘录):

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

    loadFence:  { OrderAccess::acquire(); }
    storeFence: { OrderAccess::release(); }
    fullFence:  { OrderAccess::fence(); }
Run Code Online (Sandbox Code Playgroud)

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

更精细的方法可能是可行的,但好处并不明显.

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


nty*_*sdd 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 设计是合理的。