【问题标题】:Why non volatile variable is updated on CPU shared cache?为什么非易失性变量在 CPU 共享缓存上更新?
【发布时间】:2020-09-13 19:42:06
【问题描述】:

标志变量不是易失的,所以我希望在线程 1 上看到一个无限循环。但我不明白为什么线程 1 可以看到标志变量上的线程 2 更新。

为什么非易失性变量在 CPU 共享缓存上更新?这里 volatile 和 non volatile 标志变量有区别吗?

static boolean flag = true;

public static void main(String[] args) {

    new Thread(() -> {
        while(flag){
            System.out.println("Running Thread1");
        }
    }).start();

    new Thread(() -> {
        flag = false;
        System.out.println("flag variable is set to False");
    }).start();

}

【问题讨论】:

  • 1.非volatile 并不意味着更改将不可见;只是没有担保。 2.println涉及同步,影响可见性。
  • out.println() 在打印前缓冲文本。您可以使用System.err.println() 立即获得结果
  • "这里的 volatile 和 non volatile 标志变量有区别吗?"也许。也许不吧。如果不是volatile,那么JVM可以选择。
  • 关于非同步线程交互的问题是它们是*indeterminate.* 这意味着你无法确定它们将要做什么。 flag 是否可见。它可能在一次代码运行时可见,而在下一次运行时不可见。如果硬件提供某种可见性保证(i86 硬件通常提供),JVM 没有义务删除它。关键是不保证非易失性字段不可见。他们的知名度可以是任何东西,这就是问题所在。
  • 顺便说一句,Brian Goetz 在 Java 并发实践中详细介绍了这个(不确定的代码执行)。一本好书,你应该阅读它。

标签: java multithreading


【解决方案1】:

零保证这样一个简单的程序将显示可感知的结果。我的意思是,至少不能保证哪个线程会先启动。

但总的来说,可见性效果仅由java language specification 保证,它精心构建了所谓的“先发生关系”。这是您拥有的唯一保证,并且确切地说:

对 volatile 字段的写入发生在对该字段的每次后续读取之前。

没有volatile,安全网消失了。你可能会说 - “但我无法复制”。答案是:

  • ...在这次运行中

  • ...在这个平台上

  • ...用这个编译器

  • ...在这个 CPU 上

等等。


您在其中添加一个System.out.println(内部将有一个synchronized 部分)的事实只会使事情变得更糟;从某种意义上说,它剥夺了更多机会让一个线程永远运行。


我花了一段时间,但我认为我可以想出一个例子来证明这可以打破。为此,您需要一个合适的工具:designed for these kind of things

@JCStressTest
@State
@Outcome(id = "0", expect = Expect.ACCEPTABLE)
@Outcome(id = "3", expect = Expect.ACCEPTABLE_INTERESTING, desc = "racy read!!!")
@Outcome(id = "4", expect = Expect.ACCEPTABLE, desc = "reader thread sees everything that writer did")
public class NoVolatile {

    private int y = 1;
    private int x = 1;

    @Actor
    public void writerThread() {
        y = 2;
        x = 2;
    }

    @Actor
    public void readerThread(I_Result result) {
        if(x == 2) {
            int local = y;
            result.r1 = x + local;
        }
    }
}

您不需要了解代码(尽管这会有所帮助),但总体而言,它构建了两个“参与者”或两个线程来更改两个独立的值:xy。有趣的是:

if(x == 2) {
     int local = y;
     result.r1 = x + local;
}

如果x == 2,我们输入if分支和result.r1应该总是4,对吧?如果result.r13,这是什么意思?

这意味着x == 2 肯定是(否则根本不会写到r1,因此result.r1 将为零),这意味着y == 1

这意味着ThreadA(或writerThread)执行了一次写入(我们确定x == 2,因此y也应该是2),但是ThreadB (readerThread) 没有观察到y2;它仍然将y 视为1

这些是@Outcome(....)定义的案例,显然我关心的是3。如果我运行它(由你决定如何运行),我会看到输出中确实存在 ACCEPTABLE_INTERESTING 案例。

如果我做一个改变:

 private volatile int x = 1;

通过添加volatile,我开始遵循 JLS 规范。特别是该链接的 3 点:

如果 x 和 y 是同一线程的操作,并且 x 在程序顺序中位于 y 之前,则为 hb(x, y)。

对 volatile 字段的写入发生在对该字段的每次后续读取之前。

如果 hb(x, y) 和 hb(y, z),则 hb(x, z)。

这意味着如果我看到x == 2,我也必须看到那个y == 2(不像没有volatile)。如果我现在运行该示例,3 将不会成为结果的一部分。


这应该证明non-volatile 读取可能是活泼的,因此会丢失,而volatile 读取 - 不会丢失。

【讨论】:

    【解决方案2】:

    在 X86 上,缓存始终是一致的。因此,如果 CPU1 在地址 A 上执行存储并且 CPU2 具有包含 A 的高速缓存行,则在存储可以提交到 CPU1 上的 L1D 之前,CPU2 上的高速缓存行无效。因此,如果 CPU2 想要在缓存行失效后加载 A,它将遇到一致性缺失,首先需要获取缓存行,例如在读取 A 之前共享或独占状态。因此它将看到 A 的最新值。

    因此,易失性加载和存储对缓存的一致性没有影响。在 X86 上,在 CPU1 将 A 提交给 L1D 之后,不会在 CPU2 上为 A 加载旧值。

    volatile 的主要目的是防止对其他加载和存储到其他地址的重新排序。在 X86 上,几乎所有的重新排序都是被禁止的;由于存储缓冲,只有较旧的存储可以用新的加载重新排序到不同的地址。防止这种情况最明智的方法是在写入后添加 [StoreLoad] 屏障。

    有关详细信息,请参阅: https://shipilev.net/blog/2014/on-the-fence-with-dependencies/

    在 JVM 上,这通常使用 'lock addl %(rsp),0' 来实现;意味着将 0 添加到堆栈指针。但是 MFENCE 也同样有效。在硬件级别上发生的情况是加载的执行停止,直到存储缓冲区被耗尽;因此,较旧的 store 需要成为全局可见的(将它们的内容存储在 L1D 中),然后新的加载才能成为全局可见的(从 L1D 加载它们的内容),因此可以防止旧的 store 和新的 load 之间的重新排序。

    PS:尤金上面所说的完全正确。最好从 Java 内存模型开始,它是对任何硬件的抽象(因此没有缓存)。除了 CPU 内存屏障,还有编译器屏障;所以我上面的故事只提供了硬件上发生的情况的高级概述。我发现对硬件级别发生的事情有一些线索非常有见地。

    【讨论】:

      猜你喜欢
      • 2018-06-06
      • 1970-01-01
      • 1970-01-01
      • 2023-03-21
      • 2021-05-12
      • 1970-01-01
      • 1970-01-01
      • 2013-09-12
      • 2015-12-04
      相关资源
      最近更新 更多