【问题标题】:Java instruction reordering and CPU memory reorderingJava 指令重新排序和 CPU 内存重新排序
【发布时间】:2021-12-02 17:27:12
【问题描述】:

这是一个后续问题

How to demonstrate Java instruction reordering problems?

有很多文章和博客提到了 Java 和 JVM 指令重新排序,这可能会导致用户操作的反直觉结果。

当我要求演示导致意外结果的 Java 指令重新排序时,有几个 cmet 表明更普遍的关注领域是内存重新排序,并且很难在 x86 CPU 上演示。

指令重新排序只是更大的内存重新排序、编译器优化和内存模型问题的一部分吗?这些问题真的是 Java 编译器和 JVM 独有的吗?它们是否特定于某些 CPU 类型?

【问题讨论】:

  • 嗯,不。您的问题使它看起来像是一个仅限 java 的问题,但竞争条件在每种语言中都是可能的,并且取决于编译器使用的优化 - 如果您在编码时没有注意,可以进行。然后出现了 CPU 架构,但这仍然可以归结为“编译器搞砸了”(因为每个 CPU 架构都有不同的编译器)。
  • 编译器很少会破坏你的代码,它只是优化你写的东西(警告,C++优化实际上可以破坏代码),所以如果编译器重新排序你的代码错误,你没有在第一名。
  • @Shark:如果优化“破坏了你的代码”,它已经被破坏并且在某些情况下只是碰巧工作,例如使用调试版本在语句之间将所有内容存储/重新加载到内存中。 (Java 没有等效于未优化的构建,所以我猜 Java 程序员永远不会错误地认为他们的代码在很多情况下首先可以工作。当然,作为对提问者链接的上一个问题的答案间接显示,您可能会因 x86 上缺乏编译时重新排序而偶然获得发布/获取同步,并让它在 ARM / 其他一切上中断。)
  • @Shark:在 C 中没有“通常工作正常”之类的东西。现代编译器基于没有未定义行为的假设积极优化,因此为了正确性,您不能有用地考虑汇编语言等价物,例如对于有符号溢出检测:您首先需要避免导致它。如果您的代码在某些编译器上被-O3 破坏,那么在另一个编译器上它也很容易被-O1 破坏。 (只有 -O0 对内存排序的东西是特殊的,因为它不在语句之间的寄存器中保存值,这不是你“通常”会做的事情。)
  • @Shark:所以你真的必须了解 ISO C 和 C++,而不仅仅是编写“显然应该有效”的东西,以便分别为现代 C 和 C++ 编译器编写安全代码。整个情况基本上很糟糕,尽管它确实让编译器为安全编写的代码生成了良好的汇编。

标签: java cpu-architecture memory-barriers instruction-reordering


【解决方案1】:

无需编译时重新排序源代码与 asm 中的操作即可实现内存重新排序。由运行线程的 CPU 对连贯共享缓存(即内存)执行的内存操作(加载和存储)顺序也与它执行这些指令的顺序不同。

执行加载访问缓存(或存储缓冲区),但在现代 CPU 中执行“存储与其他内核实际可见的值是分开的(从存储缓冲区提交到 L1d缓存)。执行存储实际上只是将地址和数据写入存储缓冲区;在存储退出之前不允许提交,因此已知是非推测性的,即肯定会发生。

将内存重新排序描述为“指令重新排序”是一种误导。即使在按顺序执行 asm 指令的 CPU 上,您也可以进行内存重新排序(只要它有一些机制可以找到内存级别的并行性并让内存操作在某些方面无序完成 ),即使 asm 指令顺序与源顺序匹配。因此,该术语错误地暗示仅以正确的顺序(在 asm 中)具有简单的加载和存储指令对于与内存顺序相关的任何事情都是有用的;至少在非 x86 CPU 上不是这样。这也很奇怪,因为指令对寄存器有影响(至少加载,并且在一些具有后增量寻址模式的 ISA 上,存储也可以)。

tmp = y 加载之后谈论类似 StoreLoad 重新排序为 x = 1 “发生”之类的事情很方便,但要谈论的是效果何时发生(对于加载)或对其他核心可见(对于存储) 与此线程的其他操作有关。但是在编写 Java 或 C++ 源代码时,关心它是发生在编译时还是运行时,或者该源代码是如何变成一条或多条指令的,几乎没有意义。此外,Java 源代码没有 指令,它有语句。

也许该术语可以用来描述 .class 中的字节码指令与 JIT 编译器生成的本机机器代码之间的编译时重新排序,但如果是这样,那么将其用于内存重新排序通常是一种误用,而不仅仅是编译/JIT 时重新排序,不包括运行时重新排序。只强调编译时重新排序并不是很有帮助,除非您有信号处理程序(如 POSIX)或在现有线程的上下文中异步运行的等效程序。


这种效果根本不是 Java 独有的。(尽管我希望“指令重新排序”术语的这种奇怪用法是!)它与 C++ 非常相似(我认为 C# 和 Rust例如,可能大多数其他语言想要正常高效地编译,并且需要在源代码中使用特殊的东西来指定您何时希望您的内存操作相互排序,并立即对其他线程可见)。 https://preshing.com/20120625/memory-ordering-at-compile-time/
C++ 对非atomic<> 变量的访问定义甚至比 Java 还要少,无需同步,以确保永远不会与其他任何东西并行写入(未定义的行为1)。

甚至出现在汇编语言中,根据定义,源代码和机器代码之间没有重新排序。所有的 SMP CPU,除了一些像 80386 这样的古老 CPU 也进行在运行时重新排序内存,所以缺乏指令重新排序不会给你带来任何好处,尤其是在具有“弱”内存模型的机器上( x86 以外的大多数现代 CPU):https://preshing.com/20120930/weak-vs-strong-memory-models/ - x86 是“强排序的”,但不是 SC:它是程序顺序加上带有存储转发的存储缓冲区。因此,如果您想真正演示在 x86 上的 Java 中由于排序不足而造成的破坏,它要么是编译时重新排序,要么是 lack of sequential consistency 通过 StoreLoad 重新排序或存储缓冲区效果。其他不安全的代码(例如您之前问题的已接受答案)可能会在 x86 上运行,但在 ARM 等弱排序 CPU 上会失败。

(有趣的事实:现代 x86 CPU 积极地无序执行加载,但是根据 x86 的强排序内存模型检查以确保它们被“允许”这样做,即它们从中加载的缓存行仍然可读, 否则将 CPU 状态回滚到之前的状态:machine_clears.memory_ordering perf 事件。因此它们保持遵守强大的 x86 内存排序规则的错觉。其他 ISA 的命令较弱,并且可以在没有后续检查的情况下积极地乱序执行加载。 )

某些 CPU 内存型号甚至是 allow different threads to disagree about the order of stores done by two other threads。因此 C++ 内存模型也允许这样做,因此 PowerPC 上的额外屏障仅用于顺序一致性(atomicmemory_order_seq_cst,如 Java volatile)而不是获取/释放或更弱的命令。

相关:

脚注 1: C++ UB 不仅意味着加载了一个不可预知的值,而且 ISO C++ 标准对于整个程序之前或遇到UB之后。在内存排序的实践中,结果通常是可预测的(对于习惯于查看编译器生成的 asm 的专家)取决于目标机器和优化级别,例如hoisting loads out of loops breaking spin-wait loops 无法使用 atomic。但是当然,当您的程序包含 UB 时,您完全受制于编译器所做的任何事情,而不是您可以依赖的东西。


缓存是一致的,尽管存在常见的误解

然而,Java 或 C++ 跨多个线程运行的所有现实世界系统确实 具有一致的缓存;在循环中无限期地看到陈旧数据是编译器将值保存在寄存器(线程私有)中的结果,而不是 CPU 缓存彼此不可见的结果。 This is what makes C++ volatile work in practice for multithreading (but don't actually do that because C++11 std::atomic made it obsolete).

从未看到标志变量更改的影响是由于编译器将全局变量优化到寄存器中,而不是指令重新排序或 cpu 缓存。您可以说编译器将值“缓存”在寄存器中,但您可以选择其他措辞,这样不太可能让那些不了解线程私有寄存器与连贯缓存的人混淆。


脚注 2:在比较 Java 和 C++ 时,还要注意 C++ volatile 不保证任何关于内存顺序的事情,事实上,在 ISO C++ 中,多线程写入是未定义的行为即使使用 volatile,也可以同时使用同一个对象。如果您想要线程间可见性而不订购 wrt,请使用 std::memory_order_relaxed。周围的代码。

(Java volatile 就像 C++ std::atomic<T> 与默认的 std::memory_order_seq_cst 一样,AFAIK Java 没有办法放宽这一点来做更有效的原子存储,尽管大多数算法只需要获取/释放语义来实现它们的纯-loads 和 pure-stores,x86 can do for free。为了顺序一致性而耗尽存储缓冲区需要额外的成本。与线程间延迟相比并不多,但对每个线程的吞吐量很重要,如果同一个线程正在做一个大问题一堆东​​西到相同的数据没有来自其他线程的争用。)

【讨论】:

  • C++ UB means not just an unpredictable value loaded, but that the ISO C++ standard has nothing to say about what can/can't happen in the whole program at any time before or after UB is encountered. 。 . . . . “并且编译器作者可以在这种情况下为所欲为”。 UB就是UB,有些用不同的编译器编译会表现不同。
  • @Shark:没错。这使编译器可以根据不会发生的假设进行优化,例如 Why does unaligned access to mmap'ed memory sometimes segfault on AMD64? 甚至在 x86 上进行矢量化时,其中未对齐在 asm 中是安全的。还有Does the C++ standard allow for an uninitialized bool to crash a program?。或者在这种情况下,这就是让编译器忽略其他线程可能在内存中看到的非原子对象的原因,如果它们在不允许的任何时候查看,因为数据竞争 UB。
  • 我只是觉得有必要说(好吧,补充一下)UB 在不同平台和编译器之间的行为并不统一。它不是标准化的,所以每个人都会做他们认为在这种情况下最好的事情。那个小细节有时真的可以让你:D
  • @Shark:我想我找到了您反对的部分答案:就在您引用的内容之后,我建议专家通常可以预测 UB 效应。我改写了一些。是的,当然它在平台或编译器之间并不统一。尽管影响数据竞争 UB 的优化(例如是否发生存储或加载)通常相当简单,并且由任何理智的编译器完成(承载大量循环不变的事物),如果代码不是太复杂的话。
  • 与大多数其他 UB 案例不同,您可以通过查看 asm 事后理解,但可能没有准确猜到它会如何在给定的编译器/目标/优化级别组合上崩溃。而且我绝对不会基于对将要发生的事情的理解来支持编写包含 UB 的程序,我真的只是想说(作为一个一直在查看 gcc/clang 的 asm 输出的人),我可以预测某些事情会如何中断。或者,如果我想证明为什么某些东西不安全,我通常可以想出 C 来编译为 asm 来显示问题。
猜你喜欢
  • 2016-12-27
  • 2023-03-05
  • 2015-12-11
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2016-03-29
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多