核心缓存同步在硬件层面完成,为什么还需要volatile关键字?

Why do we need the volatile keyword when the core cache synchronization is done on the hardware level?

所以我目前正在列出 this talk。 在第 28:50 分钟,做出了以下声明:“事实上,在硬件上它可以在主内存中,在多个 3 级缓存中,在四个 2 级缓存中 [...] 这不是你的问题。这就是硬件设计师的问题。“

然而,在 java 中,我们必须将停止线程的布尔值声明为 volatile,因为当另一个线程调用停止方法时,不能保证 运行 线程会意识到这一点改变。

为什么会这样,硬件级别应该负责用正确的值更新每个缓存?

我确定我在这里遗漏了什么。

有问题的代码:

public class App {
    public static void main(String[] args) {
        Worker worker = new Worker();
        worker.start();
        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        worker.signalStop();
        System.out.println(worker.isShouldStop());
        System.out.println(worker.getVal());
        System.out.println(worker.getVal());
    }

    static class Worker extends Thread {
        private /*volatile*/ boolean shouldStop = false;
        private long val = 0;

        @Override
        public void run() {
            while (!shouldStop) {
                val++;
            }
            System.out.println("Stopped");
        }

        public void signalStop() {
            this.shouldStop = true;
        }

        public long getVal() {
            return val;
        }

        public boolean isShouldStop() {
            return shouldStop;
        }
    }
}

你的意思是硬件设计师只是为了你让世界充满了小马和彩虹。

他们不能那样做 - 你想要的东西使得 on-core 缓存的概念完全不可能。 CPU 核心怎么可能知道给定的内存位置在进一步访问它之前需要与另一个核心同步,而不是仅仅保持整个缓存永久同步,从而使整个想法完全无效on-core 缓存?

如果谈话强烈建议您作为一名软件工程师只能责怪硬件工程师没有让您的生活变得轻松,那将是一个可怕而愚蠢的谈话。我敢打赌它带来了比这更细微的差别。

无论如何,你吸取了错误的教训。

这是一条 two-way 街道。硬件工程团队与 JVM 团队有效地合作,建立了一个一致的模型,该模型在 'With these constraints and limited guarantees to the software engineer, the hardware team can make reliable and significant performance improvements' 和 'A software engineer can build multicore software with this model without tearing their hair out' 之间取得了良好的平衡。

java中的这种平衡是JMM(Java内存模型),它主要归结为:所有字段访问可能有或没有本地线程缓存,你不知道,你无法测试它是否存在。从本质上讲,JVM 有一个邪恶的硬币,它会在您每次读取字段时抛出它。尾巴,你得到本地副本。头,它首先同步。硬币是邪恶的,因为它是不公平的,并且在整个开发、测试和第一周,每次,即使你抛一百万次,都会正面朝上。然后,重要的潜在客户演示了您的软件,您开始遇到麻烦。

解决方案是让 JVM 永远不会翻转它,这意味着您需要在代码中任何地方遇到一个线程写入一个字段而另一个线程读取它的情况时,随时建立 Happens-Before/Happens-After 关系。 volatile 是一种方法。

换句话说,为了给硬件工程师一些工作,你,软件工程师,有效地做出了承诺,如果你关心线程之间的同步,你将建立 HB/HA。这就是 'deal' 中你的部分。他们的交易部分是硬件保证行为如果你遵守交易的规定,并且硬件非常非常快。

您假设如下:

  • 编译器不对指令重新排序
  • CPU 按照程序指定的顺序执行加载和存储

那么你的推理是有道理的,这个consistency model is called sequential consistency (SC):总序超过了loads/stores并且与每个线程的程序顺序一致。简单来说:只是 loads/stores 的一些交错。 SC的要求稍微严格一点,但是这个抓住了本质。

如果 Java 和 CPU 是 SC,那么就没有任何目的使某些东西变得不稳定。

问题是你会得到糟糕的表现。许多编译器优化依赖于将指令重写为更高效的内容,这可能导致加载和存储的重新排序。它甚至可以决定优化加载或存储,以免发生这种情况。只要只涉及一个线程,这一切都很好,因为该线程将无法观察到 loads/stores.

的这些重新排序

除了编译器,CPU也喜欢重新排序loads/store。想象一下 CPU 需要写入,而 cache-line for that write isn't in the right state. So the CPU would block and this would be very inefficient. Since the store is going to be made anyway, it is better to queue the store in a buffer so that the CPU can continue and as soon as the cache-line is returned in the right state, the store is written to the cache-line and then committed to the cache. Store buffering is a technique used by a lot of processors (e.g. ARM/X86). One problem with it is that it can lead to an earlier store to some address being reordering with a newer load to a different address. So instead of having a total order over all loads and stores like SC, you only get a total order over all stores. This model is called TSO (Total Store Order) and you can find it on the x86 and SPARC v8/v9。这种方法假定存储缓冲区中的存储将按程序顺序写入缓存;但是也有可能放宽,这样存储缓冲区中到不同缓存行的存储可以以任何顺序提交到缓存;这称为 PSO(部分存储订单),您可以在 SPARC v8/v9.

上找到它

SC/TSO/PSO 是强内存模型,因为每次加载和存储都是同步操作;所以他们订购周围 loads/stores。这可能非常昂贵,因为对于大多数指令,只要保留数据依赖顺序,任何顺序都可以,因为:

  • 大多数内存不在不同 CPU 之间共享。
  • 如果内存是共享的,通常会有一些外部同步,例如 unlock/lock 互斥锁或 release-store/acquire-load 负责同步。所以可以延迟同步。

CPU 的弱内存模型,如 ARM、Itanium 利用了这一点。他们在普通加载和存储之间进行分离并同步 loads/stores。对于普通装载和存储,任何排序都可以。现代处理器以任何方式乱序执行指令;单个 CPU.

中有很多并行性

现代处理器确实实现了高速缓存一致性。唯一不需要实现高速缓存一致性的现代处理器是 GPU。缓存一致性可以通过两种方式实现

  • 对于小型系统,缓存可以嗅探总线流量。这是您看到 MESI 协议的地方。这种技术被称为嗅探(或窥探)。
  • 对于较大的系统,您可以有一个目录,该目录知道每个缓存行的状态以及哪些 CPU 正在共享缓存行以及哪些 CPU 拥有缓存行(这里有一些类似 MESI 的协议)。所有对缓存行的请求都通过该目录。

缓存一致性协议确保缓存行在 CPU 秒之前失效,然后另一个 CPU 可以写入缓存行。缓存一致性将为您提供单个地址上的总顺序 loads/stores,但不会提供不同地址之间的任何顺序 loads/stores。

回到 volatile:

那么 volatile 的作用是:

  • 防止编译器重新排序加载和存储,CPU。
  • 确保 load/store 可见;因此编译器将优化加载或存储。
  • load/store 是原子的;所以你不会遇到撕裂 read/write 之类的问题。这包括编译器行为,例如字段的自然对齐。

我已经为您提供了一些关于幕后发生的事情的技术信息。但要正确理解 volatile,你需要理解 Java Memory Model。它是一个抽象模型,不关心上述任何实现细节。如果您不在示例中应用 volatile,则会出现数据竞争,因为并发冲突访问之间缺少 happens-before 边缘。

一本关于这个主题的好书是 A Primer on Memory Consistency and Cache Coherence, Second Edition.您可以免费下载。

我不能向您推荐任何关于 Java 内存模型的书,因为它的解释方式很糟糕。最好在深入了解 JMM 之前大致了解内存模型。最好的来源可能是 doctoral dissertation by Jeremy Manson, and Aleksey Shipilëv: One Stop Page.

PS:

有些情况下您不关心任何订购保证,例如

  • 线程的停止标志
  • 进度指标
  • 微基准测试的黑洞。

这就是 VarHandle.getOpaque/setOpaque 可以发挥作用的地方。它提供可见性和原子性,但不提供任何关于其他变量的排序保证。这主要是一个编译器问题。大多数工程师永远不需要这种级别的控制。