TL;DR:它为编译器和硬件提供了更多空间来利用 as-if 规则,因为它不需要保留原始源代码的所有行为,只有单线程本身的结果。
将加载/存储的外部可观察(来自其他线程)排序作为优化必须保留的东西,为编译器提供了很大的空间来将事物合并到更少的操作中。对于硬件来说,延迟存储是一个大问题,但对于编译器来说,各种重新排序都会有所帮助。
(关于它为何对编译器有帮助的部分,请参阅部分内容)
为什么它有助于硬件
硬件在 CPU 内部重新排序较早的存储和较晚的负载 (StoreLoad reordering) 对于乱序执行至关重要。 (见下文)。
其他类型的重新排序(例如 StoreStore 重新排序,这是您的问题的主题)不是必需的,并且可以仅使用 StoreLoad 重新排序而不是其他三种重新排序来构建高性能 CPU。 (最好的例子是 tag:x86,其中每个商店都是一个 release-store, every load is an acquire-load。有关更多详细信息,请参阅 x86 标签 wiki。)
有些人,比如 Linus Torvalds,认为与其他商店重新订购商店对硬件没有多大帮助,because hardware already has to track store-ordering to support out-of-order execution of a single thread。 (单个线程始终运行,就好像它自己的所有存储/加载都按程序顺序发生一样。)如果您好奇,请参阅 realworldtech 上该线程中的其他帖子。和/或如果你觉得 Linus 的侮辱和明智的技术论点的混合很有趣:P
对于 Java,问题在于,存在硬件不提供这些排序保证的架构。 Weak memory ordering 是 ARM、PowerPC 和 MIPS 等 RISC ISA 的共同特征。 (但不是 SPARC-TSO)。该设计决策背后的原因与我链接的 realworldtech 线程中争论的原因相同:使硬件更简单,并让软件在需要时请求订购。
因此,Java 的架构师别无选择:为内存模型比 Java 标准更弱的架构实现 JVM 将需要在每个存储之后执行存储屏障指令,并且需要加载屏障每次加载之前。 (除非 JVM 的 JIT 编译器可以证明没有其他线程可以引用该变量。)一直运行屏障指令很慢。
Java 的强大内存模型将使 ARM(和其他 ISA)上的高效 JVM 变得不可能。证明不需要障碍几乎是不可能的,需要人工智能水平的全球程序理解。 (这远远超出了普通优化器的工作范围)。
为什么它可以帮助编译器
(另请参阅 Jeff Preshing 在 C++ compile-time reordering 上的出色博客文章。当您将 JIT 编译到本机代码作为流程的一部分时,这基本上适用于 Java。)
保持 Java 和 C/C++ 内存模型弱的另一个原因是允许更多优化。由于(弱内存模型)允许其他线程以任何顺序观察我们的存储和加载,因此即使代码涉及到内存的存储,也允许进行激进的转换。
例如在大卫的例子中:
c.a = 1;
c.b = 1;
c.a++;
c.b++;
// same observable effects as the much simpler
c.a = 2;
c.b = 2;
不要求其他线程能够观察中间状态。因此,编译器可以将其编译为c.a = 2; c.b = 2;,无论是在 Java 编译时还是在字节码被 JIT 编译为机器码时。
一种方法很常见,它增加了从另一个方法中多次调用的东西。如果没有这条规则,只有当编译器能够证明没有其他线程可以观察到差异时,才能将其转换为 c.a += 4。
C++ 程序员有时会错误地认为,由于他们正在为 x86 编译,他们不需要std::atomic<int> 来获得共享变量的一些排序保证。 这是错误的,因为优化是根据语言记忆模型的 as-if 规则进行的,而不是目标硬件。
更多技术硬件说明:
为什么 StoreLoad 重新排序有助于提高性能:
一旦将存储提交到缓存中,它就会对运行在其他内核上的线程全局可见(通过缓存一致性协议)。到那时,回滚它为时已晚(另一个核心可能已经获得了该值的副本)。因此,除非确定商店不会出错,而且之前的任何指令都不会出错,否则它不会发生。商店的数据就准备好了。并且在之前的某个时间点没有出现分支错误预测,等等等等。也就是说,我们需要在退出存储指令之前排除所有错误推测的情况。
如果没有 StoreLoad 重新排序,每次加载都必须等待所有先前的存储退出(即完全完成执行,已将数据提交到缓存),然后才能从缓存中读取值以供以后依赖于加载的值。 (加载将值从缓存复制到寄存器的时刻是它对其他线程全局可见的时刻。)
由于您无法知道其他内核上发生了什么,我认为硬件不能通过推测它不是问题来隐藏启动加载的延迟,然后在事后检测错误推测。 (并将其视为分支错误预测:丢弃所有依赖于该负载完成的工作,然后重新发出它。)核心可能允许来自处于Exclusive or Modified 状态的高速缓存行的推测性早期加载,因为它们不能出现在其他内核中。 (如果在推测性加载之前退出最后一个存储之前,如果对该高速缓存行的高速缓存一致性请求来自另一个 CPU,则检测错误推测。)无论如何,这显然是大量的复杂性,其他任何事情都不需要。
请注意,我什至没有提到商店的缓存未命中。这会将存储的延迟从几个周期增加到数百个周期。
实际 CPU 的工作方式(允许 StoreLoad 重新排序时):
在我对Deoptimizing a program for the pipeline in Intel Sandybridge-family CPUs 的回答的早期部分,我包含了一些链接作为计算机体系结构简介的一部分。如果您发现这很难理解,这可能会有所帮助,或者更令人困惑。
CPU 通过在 store queue 中缓冲它们来避免 WAR and WAW pipeline hazards 用于存储,直到存储指令准备好退出。来自同一个内核的加载必须检查存储队列(以保留单个线程的按顺序执行的外观,否则在加载任何可能最近存储的内容之前您需要内存屏障指令!)。存储队列对其他线程是不可见的;存储仅在存储指令退出时才全局可见,但加载在执行后立即变为全局可见。 (并且可以在此之前使用预取到缓存中的值)。
另请参阅this answer 我写了解释存储缓冲区以及它们如何将执行与缓存未命中存储分离 提交,并允许存储的推测执行。 wikipedia's article on the classic RISC pipeline 也有一些用于更简单 CPU 的东西。存储缓冲区固有地创建 StoreLoad 重新排序(以及 store-forwarding 所以a core can see its own stores before they become globally visible,假设核心可以进行存储转发而不是停滞。)
因此商店可以乱序执行,但它们只会在商店队列中重新排序。由于指令必须退出以支持精确的异常,因此让硬件强制执行 StoreStore 排序似乎根本没有什么好处。
由于加载在执行时变得全局可见,因此强制 LoadLoad 排序可能需要在缓存中未命中的加载之后延迟加载。当然,实际上 CPU 会推测性地执行以下加载,并在发生内存顺序错误推测时检测它。这对于良好的性能几乎是必不可少的:乱序执行的很大一部分好处是继续做有用的工作,隐藏缓存未命中的延迟。
Linus 的一个论点是,弱排序 CPU 需要多线程代码才能使用大量内存屏障指令,因此它们需要便宜,多线程代码才能不烂。这只有在您有硬件跟踪加载和存储的依赖顺序时才有可能。
但是,如果您有依赖关系的硬件跟踪,您可以让硬件始终强制执行排序,因此软件不必运行那么多屏障指令。如果你有硬件支持来降低屏障的成本,为什么不像 x86 那样在每次加载/存储时都隐含它们。
他的另一个主要论点是内存排序是困难的,并且是错误的主要来源。在硬件上做对一次比每个软件项目都必须做对要好。 (这个论点之所以有效,是因为它可以在硬件中实现,而不会产生巨大的性能开销。)