【问题标题】:Is Thread.yield guaranteed to flush reads/writes to memory?Thread.yield 是否保证刷新对内存的读/写?
【发布时间】:2021-11-09 03:17:43
【问题描述】:

让我们保存一下,我有这段代码,它展示了一个线程读取过时的缓存,这会阻止它退出它的 while 循环。

class MyRunnable implements Runnable {
    boolean keepGoing = true; // volatile fixes visibility
    @Override public void run() {
        while ( keepGoing ) {
            // synchronized (this) { } // fixes visibility
            // Thread.yield(); // fixes visibility
            System.out.println(); // fixes visibility 
        }
    }
}
class Example {
    public static void main(String[] args) throws InterruptedException{
        MyRunnable myRunnable = new MyRunnable();
        new Thread(myRunnable).start();
        Thread.sleep(100);  
        myRunnable.keepGoing = false;
    }
}

我相信 Java 内存模型保证了对 volatile 变量的所有写入与来自任何线程的所有后续读取同步,从而解决了问题。

如果我的理解是正确的,同步块生成的代码也会清除所有挂起的读取和写入,作为一种“内存屏障”并解决问题。

从实践中我看到插入yieldprintln 也会使变量更改对线程可见并且它正确退出。我的问题是:

yield/println/io 是由 JMM 以某种方式保证的内存屏障,还是不能保证有效的幸运副作用?


编辑:至少我在这个问题的措辞中做出的一些假设是错误的,例如关于同步块的假设。我鼓励问题的读者阅读下面发布的答案中的更正。

【问题讨论】:

  • Yield() 放弃运行线程,让其他人运行,所以它会看到变化。 JMM 的目的是对线程的执行设置 时间 约束,以便应用程序在执行中保持一致(JMM),所以我猜测延迟+ println 中的代码会导致您提到的效果.
  • 说实话,我也在纠结这个问题,发现了一些相关的问题:link1,link2,link3
  • @AliasCartellano JMM 不会提供任何实时约束。正确同步的程序(没有数据竞争的程序)只会给出顺序一致的执行。但是顺序一致性没有任何实时限制;为此,您需要查看线性化。所以简单来说,对于 SC 来说,阅读陈旧的内容是完全可以的;只要不违反 PO。如果你愿意,我可以给你更多细节。

标签: java multithreading caching java-memory-model


【解决方案1】:

没有。对于您在其中使用的类或方法,JLS 或 javadocs 都没有保证

当前实现中,在实践中yield()println 中有memory barriers。 (如果您要深入研究实现代码,您应该能够弄清楚它们是如何产生的以及它们的用途。)

但是,不能保证所有平台上的所有 Java1 实现都存在这些内存屏障。规范没有指定发生在关系存在之前2,因此它们不需要3内存屏障插入。

假设:

  • 假设Thread.yield() 被实现为no-op。 (就像System.gc() 可以是空操作一样。)

  • 假设输出流堆栈经过优化,其内部不再需要同步。例如,假设JVM 可以推断出特定的输出流是线程受限的,并且在写入其缓冲区时不需要内存屏障。

现在我个人认为这些变化不太可能发生。 (而且它们甚至可能不可行。)但如果它们真的发生了,那么目前依赖于那些偶然的内存屏障的相当多“损坏”的应用程序很可能会停止工作。

重点是:如果您想要保证,请依赖规格说明。规范是唯一真正的保证……如果您的代码需要可移植。


1 - 特别是未来的。
2 - 事实上,正如 Holger 的回答所解释的,Thread 的 javadocs 明确指出您不能假设或依赖于 yield() 发生的任何同步行为。这显然意味着在yield() 和任何其他线程上的任何操作之间没有发生
3 - 内存屏障实际上是​​一个实现细节。 典型编译器使用它们来实现 JMM 的可见性保证。关键是保证,而不是用于实现它们的策略。因此,当您尝试确定多线程代码是否正确时,任何关于内存屏障、缓存、寄存器等的讨论都是题外话

【讨论】:

【解决方案2】:

让我们保存一下我有这段代码,它展示了一个线程读取过时的缓存,这会阻止它退出它的 while 循环。

如果您指的是 CPU 缓存,那么这是一个糟糕的心智模型(除了不适合 JMM 的心智模型)。现代 CPU 上的缓存始终是一致的。

我相信 Java 内存模型保证了对 volatile 变量的所有写入与来自任何线程的所有后续读取同步,从而解决了问题。

没错。在 volatile 变量的写入和同一 volatile 变量的所有后续读取之间存在一个发生前边缘。

Blockquote 如果我的理解是正确的,同步块生成的代码也会清除所有挂起的读取和写入,这充当一种“内存屏障”并解决了问题。

结合 JMM 来推理内存障碍是很危险的。

https://shipilev.net/blog/2016/close-encounters-of-jmm-kind/#myth-barriers-are-sane

在释放监视器和随后获取同一监视器之间有一个发生之前的边缘。因此,如果您在受锁保护的情况下访问 keepGoing 变量,则不会出现数据争用。

yield/println/io 是 JMM 以某种方式保证的内存屏障,还是无法保证有效的幸运副作用?

检查JLS,您会看到在 2 个产量之间的边缘之前没有发生任何事情。也许涉及到 CPU 内存屏障,但问题可能在代码到达 CPU 之前发生。例如。 JIT 可能会将代码优化为:

if(!keepGoing){
   return;
}

while(true){
   Thread.yield();
   println();
}

因此,在这种情况下,代码在 CPU 上执行之前已经“损坏”,因为代码永远不会看到“keepGoing”变量的更新版本。

我不确定 Thread.yield() 是否有任何编译器障碍,是否存在编译器障碍,JIT 无法优化加载或存储。但这些都不是规范的一部分。

【讨论】:

  • 感谢您提供详细而明智的回答。我在用技术术语来表达这个问题时苦苦挣扎。我想采纳您的建议,即在关系发生之前而不是陈旧的缓存模型之前进行思考,但我觉得这非常困难。以我在该领域的所有经验“发生在之前”,关系仍然读起来很复杂,即使不是模糊和混乱,也是短暂的。问题可能出在我身上,而不是规格上。或许非连贯缓存模型的一个可取之处在于 JVM 可以在任何理论上的机器上运行,而不仅仅是现代 CPU。
  • 这并不容易 :) 从性能的角度来看,了解硬件的工作原理是件好事;这就是它变得非常糊状的地方,因为您需要处理多个抽象级别。我建议首先了解顺序一致性,并在进入 JMM 之前确保概念清晰。顺便说一句,这是一本很好的读物:hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf 这一篇也很好:course.ece.cmu.edu/~ece847c/S15/lib/exe/… Jeremy Manson 的 JMM 论文也很不错。
  • 这也是一本很好的读物。跳过非正式的语义;转到第 4 章。这里定义了发生前的关系。 download.oracle.com/otndocs/jcp/… 非正式的语义有点让人分心。
  • 超棒的答案!
【解决方案3】:

规范中的任何内容都不能保证任何形式的冲洗。这只是错误的心智模型,假设必须有像主内存这样的东西来维持全局状态。但是执行环境可能在每个 CPU 上都有本地内存,而根本没有主内存。因此 CPU 1 向 CPU 2 发送更新数据并不意味着 CPU 3 知道。

在实践中,系统有一个主存,但缓存可以在不需要将数据传输到主存的情况下进行同步。

此外,讨论内存传输最终会陷入狭隘的视野。 Java 的内存模型还规定了 JVM 可以执行哪些优化,哪些不可以执行。例如

nonVolatileVar = null;
Thread.sleep(100_000);
if(nonVolatileVar == null) {
  // do something
}

这里,编译器有权删除条件,并无条件执行块,因为前面的语句(忽略睡眠)写了null,其他线程的活动与非volatile变量无关,不管已经过去了多少时间。

所以当这个优化被执行时,有多少线程向这个变量写入一个新值并“刷新到内存”并不重要。此代码不会注意到。

所以我们来咨询the specification

需要注意的是,Thread.sleepThread.yield 都没有任何同步语义。特别是,编译器不必在调用Thread.sleepThread.yield 之前将缓存在寄存器中的写入刷新到共享内存,编译器也不必在调用Thread.sleep 或@ 之后重新加载缓存在寄存器中的值987654330@.

我认为,您的问题的答案再明确不过了。

为了完整性

我相信 Java 内存模型保证了对 volatile 变量的所有写入与来自任何线程的所有后续读取同步,从而解决了问题。

在写入volatile 变量之前进行的所有写入将对随后读取相同变量的线程可见。所以在你的情况下,将keepGoing 声明为volatile 将解决这个问题,因为两个线程都在使用它。

如果我的理解是正确的,同步块生成的代码也会清除所有挂起的读取和写入,作为一种“内存屏障”并解决问题。

离开synchronized 块的线程使用相同的对象与进入synchronized 块的线程建立happens-before 关系。如果在一个线程中使用 synchronized 块似乎可以解决问题,尽管您没有在另一个线程中使用 synchronized 块,那么您依赖于特定实现的副作用,不能保证继续工作。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2023-02-14
    • 2010-10-16
    • 2011-12-11
    • 2016-06-18
    • 2011-01-29
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多