Gon*_*n I 1 java cpu-architecture memory-barriers instruction-reordering
这是一个后续问题
有很多文章和博客提到 Java 和 JVM 指令重新排序,这可能会导致用户操作中出现反直觉的结果。
当我要求演示 Java 指令重新排序导致意外结果时,有几条评论说,更普遍的关注领域是内存重新排序,并且很难在 x86 CPU 上进行演示。
指令重新排序只是内存重新排序、编译器优化和内存模型等更大问题的一部分吗?这些问题真的是 Java 编译器和 JVM 特有的吗?它们是否特定于某些 CPU 类型?
内存重新排序是可能的,无需对源代码和 asm 中的操作进行编译时重新排序。由运行线程的 CPU 对一致共享缓存(即内存)执行的内存操作(加载和存储)的顺序也与其执行这些指令的顺序分开。
执行加载就是访问缓存(或存储缓冲区),但是在现代 CPU 中执行存储与其实际对其他内核可见的值是分开的(从存储缓冲区提交到 L1d 缓存)。执行存储实际上只是写入地址和数据进入存储缓冲区;直到存储退休后才允许提交,因此已知是非推测性的,即肯定会发生。
将内存重新排序描述为“指令重新排序”是有误导性的。即使在按顺序执行 asm 指令的 CPU 上,您也可以获得内存重新排序(只要它有一些机制来找到内存级并行性并以某些方式让内存操作无序完成),即使 asm 指令顺序匹配源顺序。因此,该术语错误地暗示,仅以正确的顺序(在 asm 中)使用简单的加载和存储指令对于与内存顺序相关的任何内容都是有用的;事实并非如此,至少在非 x86 CPU 上是这样。这也很奇怪,因为指令对寄存器有影响(至少是加载,在一些具有后递增寻址模式的 ISA 上,存储也可以)。
将 StoreLoad 重新排序之类的事情谈论为加载x = 1后“发生”很方便tmp = y,但要讨论的是与该线程的其他操作相关的效果何时发生(对于加载)或对其他核心可见(对于存储) 。但是,在编写 Java 或 C++ 源代码时,关心这种情况是发生在编译时还是运行时,或者该源代码如何转换为一条或多条指令是没有意义的。另外,Java 源代码没有指令,它有语句。
也许这个术语可以用来描述.classJIT 编译器生成的本机机器代码中的字节码指令之间的编译时重新排序,但如果是这样,那么将它用于一般的内存重新排序是一种误用,而不仅仅是编译/JIT - 时间重新排序,不包括运行时重新排序。仅突出显示编译时重新排序并不是很有帮助,除非您有信号处理程序(如 POSIX)或在现有线程上下文中异步运行的等效程序。
这种效果根本不是 Java 所独有的。(尽管我希望“指令重新排序”术语的这种奇怪用法是!)它与 C++ 非常相似(我认为 C# 和 Rust 是这样的,可能大多数其他语言都希望正常高效地编译,并且需要特殊的东西source 来指定何时您希望内存操作彼此有序,并且立即对其他线程可见)。https://preshing.com/20120625/memory-ordering-at-compile-time/
C++ 对非同步访问非atomic<>变量的定义比 Java 还要少,以确保永远不会与其他任何内容并行写入(未定义行为1)。
甚至以汇编语言存在,根据定义,源代码和机器代码之间没有重新排序。除了像 80386 这样的一些古老的 CPU 之外,所有 SMP CPU 也会在运行时进行内存重新排序,因此缺乏指令重新排序不会给您带来任何好处,特别是在具有“弱”内存模型的机器上(除 x86 之外的大多数现代 CPU) :https : //preshing.com/20120930/weak-vs-strong-memory-models/ - x86 是“强有序”的,但不是 SC:它是程序顺序加上带有存储转发的存储缓冲区。因此,如果您想实际演示x86 上 Java 排序不足造成的破坏,要么是编译时重新排序,要么是通过 StoreLoad 重新排序或存储缓冲区效果缺乏顺序一致性。其他不安全的代码(例如您之前问题中接受的答案可能恰好在 x86 上运行)将在 ARM 等弱有序 CPU 上失败。
(有趣的事实:现代 x86 CPU 会积极地无序执行加载,但要检查以确保根据 x86 的强有序内存模型“允许”它们执行此操作,即它们加载的缓存行仍然可读,否则滚动将 CPU 状态恢复到之前的状态:machine_clears.memory_orderingperf 事件。因此,它们维持了遵守强 x86 内存排序规则的假象。其他 ISA 的顺序较弱,可以直接无序地积极执行加载,而无需进行后续检查。)
某些 CPU 内存模型甚至允许不同的线程对另外两个线程完成的存储顺序存在分歧。因此,C++ 内存模型也允许这样做,因此 PowerPC 上的额外屏障仅需要用于顺序一致性(atomic与memory_order_seq_cstJava 一样volatile),而不是获取/释放或较弱的顺序。
有关的:
如何通过按顺序提交实现加载->存储重新排序?- 通过其他效果在有序 CPU 上进行内存重新排序,例如在允许执行此操作的弱有序 ISA 上使用可以实现命中未命中的缓存进行记分板加载,和/或从存储缓冲区进行无序提交。(OoO exec CPU 上的 LoadStore 重新排序仍然按顺序退出指令,这实际上比有序 CPU更令人惊讶,有序 CPU 具有允许加载的内存级并行性的特殊机制,OoO exec 可以替换。)
是否需要内存屏障是因为CPU乱序执行还是因为缓存一致性问题?(基本上是这个的重复;我在那里没有说太多,这里没有)
加载和存储是唯一需要重新排序的指令吗?(运行时)
推测执行的 CPU 分支是否可以包含访问 RAM 的操作码?- 存储执行顺序甚至与线程之间的内存排序无关,仅从存储缓冲区到 L1d 缓存的提交顺序。存储缓冲区对于将推测执行(包括存储指令)与其他内核可见的任何内容解耦至关重要。(以及这些存储上的缓存未命中。)
为什么自然对齐变量的整数赋值在 x86 上是原子的?- 在 asm 中正确,但在 C/C++ 中不安全;您需要std::atomic<int>使用 memory_order_relaxed 来获得相同的汇编,但以可移植安全的方式。
全局不可见加载指令- 加载数据来自哪里:可以进行存储转发,因此更准确地说 x86 的内存模型是“程序顺序 + 具有存储转发的存储缓冲区”,而不是说“仅 StoreLoad 重新排序”,如果您曾经关心这个核心重新加载自己最近的商店。
为什么内存重新排序在单核/处理器机器上不是问题?- 就像编译器的 as-if 规则一样,乱序 exec(以及其他效果)必须保留指令一次完全执行一个指令的错觉(在一个核心内,因此在线程内),按程序顺序,没有它们的作用重叠。这基本上是 CPU 架构的基本规则。
LWN:谁害怕一个糟糕的优化编译器?- 编译器可以对使用普通(非易失性/非_Atomic访问)的 C 代码执行令人惊讶的事情。这主要与 Linux 内核相关,Linux 内核使用内联汇编来滚动自己的原子,以实现屏障等某些功能,但也仅使用 C 来实现纯加载/纯存储(这与 Java 2volatile有很大不同。)volatile
脚注 1: C++ UB 不仅意味着加载的不可预测的值,而且 ISO C++ 标准没有说明在遇到 UB 之前或之后的任何时间整个程序中可以/不可以发生什么。在内存排序的实践中,结果通常是可预测的(对于习惯于查看编译器生成的 asm 的专家来说),具体取决于目标机器和优化级别,例如,将负载提升出循环会破坏无法使用的自旋等待循环atomic。但是,当然,当您的程序包含 UB 时,您完全受编译器所做的任何操作的支配,根本不是您可以依赖的东西。
然而,Java 或 C++ 运行多个线程的所有现实系统都具有一致的缓存;在循环中无限期地看到陈旧数据是编译器将值保存在寄存器(线程私有)中的结果,而不是 CPU 缓存彼此不可见的结果。 这就是 C++ 在多线程实践中发挥作用的原因volatile(但实际上并没有这样做,因为 C++11 std::atomic 使其过时)。
像从未看到标志变量更改这样的效果是由于编译器将全局变量优化到寄存器中,而不是指令重新排序或CPU缓存。您可以说编译器正在寄存器中“缓存”一个值,但您可以选择其他措辞,这样不太可能让那些还不了解线程私有寄存器与一致缓存的人感到困惑。
脚注 2:在比较 Java 和 C++ 时,还请注意,C++volatile不保证有关内存顺序的任何内容,事实上,在 ISO C++ 中,即使使用 volatile,多个线程同时写入同一对象也是未定义的行为。std::memory_order_relaxed如果您想要线程间可见性而不需要订购 wrt,请使用。周围的代码。
(Javavolatile就像具有std::atomic<T>默认值的C++std::memory_order_seq_cst一样,据我所知,Java 没有提供任何方法来放松这一点以实现更高效的原子存储,尽管大多数算法只需要其纯加载和纯存储的获取/释放语义,而x86可以做到这一点free .为了顺序一致性而耗尽存储缓冲区会产生额外的成本。与线程间延迟相比并不算多,但对于每个线程的吞吐量来说很重要,如果同一个线程对相同的数据执行一堆操作而没有争用,那么这很重要其他线程。)
| 归档时间: |
|
| 查看次数: |
515 次 |
| 最近记录: |