【问题标题】:Is this understanding correct for these code about java volatile and reordering?对于这些关于 java volatile 和 reordering 的代码,这种理解是否正确?
【发布时间】:2021-12-02 21:09:34
【问题描述】:

根据此重新排序规则

reorder Rules

如果我有这样的代码

volatile int a = 0;

boolean b = false;

foo1(){ a= 10; b = true;}

foo2(){if(b) {assert a==10;}}

让线程 A 运行 foo1,让线程 b 运行 foo2,因为 a= 10 是一个易失性存储,并且 b = true 是一个正常的存储,那么这两个语句可能会被重新排序,这意味着在线程 B 中可能有 b = true 而 a!= 10?对吗?

添加:

感谢您的回答!
我刚开始学习java多线程,一直被关键字volatile困扰。

许多教程都在讨论 volatile 字段的可见性,就像“在对它的写操作完成后,所有读者(特别是其他线程)都可以看到 volatile 字段”。我怀疑已完成的字段写入如何对其他线程(或 CPUS)不可见?

据我了解,完成写入意味着您已成功将文件写回缓存,并且根据 MESI,如果该文件已被所有其他线程缓存,则所有其他线程都应具有无效的缓存行。一个例外(由于我对硬核不是很熟悉,这只是一个猜想)可能结果会被写回寄存器而不是缓存,我不知道在这种情况下是否有一些协议可以保持一致性或 volatile 使其不写入在 java 中注册。

在某些看起来像“隐形”的情况下会发生示例:

    A=0,B=0; 
    thread1{A=1; B=2;}  
    thread2{if(B==2) {A may be 0 here}}

假设编译器没有对其重新排序,我们在thread2中看到的原因是由于存储缓冲区,我不认为存储缓冲区中的写操作意味着完成写入。由于存储缓冲区和无效队列策略,这使得对变量 A 的写入看起来不可见,但实际上在 thread2 读取 A 时写入操作尚未完成。即使我们使字段 B 易失性,而我们将字段 B 的写入操作设置为带有内存屏障的存储缓冲区,线程 2 可以读取 0 的 b 值并完成。对我来说, volatile 看起来与其声明的文件的可见性无关,而更像是一个边缘,以确保所有写入发生在 ThreadA 中的 volatile 字段写入之前对 volatile 字段读取之后的所有操作都是可见的( volatile read在 ThreadA 中的 volatile 字段写入完成后发生)在另一个 ThreadB 中。

顺便说一句,由于我不是母语人士,我看到可能有我母语的教程(还有一些英文教程)说 volatile 会指示 JVM 线程从主内存中读取 volatile 变量的值,而不是在本地缓存它,我认为这不是真的。我说的对吗?

无论如何,谢谢你的回答,因为不是母语人士,我希望我已经表达清楚了。

【问题讨论】:

  • 旁注:您需要为您的进程启用断言,否则foo2() 将只包含一个空的 if 块(即断言永远不会被执行)。
  • @Thomas 好的,谢谢。

标签: java multithreading volatile cpu-cache memory-barriers


【解决方案1】:

我很确定断言可以触发。我认为易失性负载只是一个获取操作(https://preshing.com/20120913/acquire-and-release-semantics/)wrt。非易失性变量,因此没有什么可以阻止加载加载重新排序。

两个volatile 操作无法相互重新排序,但在一个方向上可以使用非原子操作重新排序,并且您选择了没有保证的方向。

(请注意,我不是 Java 专家;volatile 可能但不太可能有一些语义需要更昂贵的实现。)


更具体的推理是,如果断言在转换为某些特定架构的 asm 时可以触发,那么 Java 内存模型必须允许它触发。

Java volatile (AFAIK) 等效于 C++ std::atomic,默认为 memory_order_seq_cst。因此,foo2 可以针对 ARM64 进行 JIT 编译,对b 进行简单加载,对a 进行 LDAR 获取加载。

ldar 无法在 稍后 加载/存储时重新排序,但可以在较早时重新排序。 (stlr 发布存储除外;ARM64 专门设计用于使 C++ std::atomic<>memory_order_seq_cst / Java volatileldarldarstlr 高效,不必立即在 seq_cst 存储上刷新存储缓冲区,仅在看到 LDAR 时,该设计提供了仍然恢复 C++ 指定的顺序一致性所需的最少排序(我假设是 Java)。)

在许多其他 ISA 上,顺序一致性存储确实需要等待存储缓冲区自行耗尽,因此它们实际上是按顺序排列的。后来的非原子负载。同样在许多 ISA 上,获取或 SC 加载是在正常加载之前完成的,barrier 阻止加载在任一方向上穿过它,otherwise they wouldn't work。这就是为什么将 a 的可变负载编译为仅执行获取 操作 的获取加载指令是理解这在实践中如何发生的关键。

(在 x86 asm 中,所有加载都是获取加载,所有存储都是释放存储。但不是顺序释放;x86 的内存模型是程序顺序 + 存储缓冲区和存储转发,这允许 StoreLoad 重新排序,所以 Java @987654343 @stores 需要特殊的 asm。

所以断言不能在 x86 上触发,除非通过 compile/JIT-time reordering of the assignments 这是一个很好的例子,说明为什么很难测试无锁代码:失败的测试可以证明存在问题,但在某些硬件/软件组合上进行测试无法证明正确性。)

【讨论】:

  • 感谢您的回答,我在帖子中添加了一些关于 volatile 的问题
  • @pythonHua: volatile 与 C++ std::atomic<> 一样,强制 JVM 加载或存储到内存中,而不是将某些内容保存在寄存器中。 (寄存器是线程私有的;缓存是一致的)。有关 CPU 具有连贯缓存这一事实的更多信息,请参阅 When to use volatile with multi threading?(但请注意,这是关于 C++ volatile,它强制加载/存储,但 not 不会对其他代码进行排序。)或Java 版本:Myths Programmers Believe about CPU Caches
  • @pythonHua:如果不存在确切解释 Java volatile 如何强制线程间可见性的重复项,那么问题中的额外内容确实应该作为一个单独的问题发布。您可以在这个问题中添加一个指向您的后续问题的链接,但您不能只添加一个关于其他问题的单独问题,并期望回答者也编辑他们的答案来回答这个问题。 SO 的目的是为特定问题建立一个有用答案的数据库,而不是来回编辑导致关于多个主题的大篇幅文章。
  • 好的,我明白了,这将是提出单独问题的更好方法。您的答案中的句子“强制 JVM 加载或存储到内存”,这是否意味着“每次从主内存读取/写入 volatile 变量的值”?或者只是意味着不使用寄存器加载或存储,并且仍然可以正常使用 cpu 缓存,并且仅在需要时从/向主内存读取/写入。
  • link 单独问题的链接
【解决方案2】:

除了 Peter Cordes 他的出色答案之外,就 JMM 而言,b 上存在数据竞争,因为在 b 的写入和 b 的读取之间没有发生边缘之前,因为它是一个普通变量。只有在 edge 存在之前发生这种情况,才能保证如果 b=1 的负载也会看到 a=1 的负载。

您需要使 b 不稳定,而不是使 a volatile。

int a=0;
volatile int b=0;

thread1(){
    a=1
    b=1
}

thread2(){
  if(b==1) assert a==1;
}

因此,如果 thread2 看到 b=1,则在发生在 order 中(易失性变量规则)中,此读取在 b=1 写入之前排序。并且由于 a=1 和 b=1 的顺序发生在顺序之前(程序顺序规则),并且 b 的读取和 a 的读取是在顺序之前发生的顺序(再次程序顺序规则),那么由于传递性发生在关系之前,在 a=1 的写入和 a 的读取之间有一个发生在边缘之前;这需要看到值 1。

您指的是使用栅栏的 JMM 的可能实现。虽然它提供了一些关于幕后发生的事情的见解,但从栅栏的角度思考同样具有破坏性,因为它们不是一个合适的心智模型。请看下面的反例:

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

【讨论】:

  • 感谢您的回答,我在帖子中添加了一些关于 volatile 的问题。
【解决方案3】:

是的,断言可能会失败。

volatile int a = 0;

boolean b = false;

foo1(){ a= 10; b = true;}

foo2(){if(b) {assert a==10;}}

JMM 保证写入 volatile 字段 happen-before 从它们读取。在您的示例中,无论线程 a 在 a = 10 之前做了什么,都会 happen-before 无论线程 b 在读取 a 之后做什么(在执行 assert a == 10 时)。由于b = true 在线程a 的a = 10 之后执行(对于单个线程,happens-before 始终成立),因此不能保证会有排序保证。但是,请考虑一下:

int a = 0;

volatile boolean b = false;

foo1(){ a= 10; b = true;}

foo2(){if(b) {assert a==10;}}

在这个例子中,情况是:

a = 10 ---> b = true---|
                       |
                       | (happens-before due to volatile's semantics)
                       |
                       |---> if(b) ---> assert a == 10

                

既然你有一个总订单,那么断言肯定会通过。

【讨论】:

  • 写入 volatile 字段发生在从它们读取之前发生 - 写入/读取之前/之后的操作是按 wrt 排序的。彼此,而不是写入具有任何保证顺序的 volatile 字段本身。您的措辞意味着整个程序中对该字段的每次易失性写入都将始终在任何易失性读取之前完成。但我们知道它不是这样工作的。读取可以看到旧值,在这种情况下,在一个线程中写入之前的内容与在另一个线程中读取之后的内容之间没有创建同步连接。
  • (您的其余答案是正确的,只是该句子的措辞问题,您没有说出我确定您的意思。)
  • 感谢您的回答,我在帖子中添加了一些关于 volatile 的问题
  • 嗨,彼得,感谢您查看我的回答 :)。我的意思是,对 volatile 变量的写入会创建与任何后续读取的发生前关系。在图中,我试图说明这会在两个线程中的指令之间创建一个总排序。
【解决方案4】:

回答你的添加。

很多教程都在讨论 volatile 字段的可见性,就像 “ volatile 字段对所有读者可见(其他线程在 特别)在写操作完成后”。我有疑问 关于如何在字段上完成的写入对其他人不可见 线程(或 CPU)?

编译器可能会弄乱代码。

例如

boolean stop;

void run(){
  while(!stop)println();
}

第一次优化

void run(){
   boolean r1=stop;
   while(!r1)println();
}

二次优化

void run(){
   boolean r1=stop;
   if(!r1)return;
   while(true) println();
}

所以现在很明显这个循环永远不会停止,因为实际上将永远不会看到要停止的新值。对于商店,您可以做类似的事情,可以无限期地推迟它。

据我了解,完成写入意味着您已成功 将文件写回缓存,并根据MESI,所有其他 如果此文件已被保存,则线程应具有无效的缓存行 被他们缓存了。

正确。这通常称为“全局可见”或“全局执行”。

一个例外(由于我对硬核不是很熟悉,所以这个 只是一个猜想)是也许结果将被写回 寄存器而不是缓存,我不知道是否有一些 在这种情况下保持一致性的协议或易失性使其成为可能 在java中不要写注册。

所有现代处理器都是加载/存储架构(甚至是 uops 转换后的 X86),这意味着有明确的加载和存储指令在寄存器和内存之间传输数据,而像 add/sub 这样的常规指令只能与寄存器一起使用。所以无论如何都需要使用寄存器。关键部分是编译器应该尊重源代码的加载/存储并限制优化。

假设编译器没有重新排序它,是什么让我们在thread2中看到 是由于存储缓冲区,我不认为写操作 存储缓冲区意味着完成的写入。 由于存储缓冲区和无效队列策略,这使得 在变量 A 上写入看起来像不可见,但实际上写入 thread2 读取 A 时操作尚未完成。

在 X86 上,存储缓冲区中的存储顺序与程序顺序一致,并将按程序顺序提交到缓存中。但是在某些架构中,存储缓冲区中的存储可以无序提交到缓存,例如由于:

  • 写合并

  • 只要缓存行以正确的状态返回,就允许存储提交缓存,无论之前是否仍在等待。

  • 与一部分 CPU 共享存储缓冲区。

存储缓冲区可以成为重新排序的来源;但也可能是乱序和投机执行。

除了商店,重新排序加载也可能导致观察商店乱序。在 X86 上,负载不能重新排序,但在 ARM 上是允许的。当然,JIT 也会把事情搞砸。

即使我们使字段 B 易失性,同时我们在字段上设置了写操作 B到带有内存屏障的存储缓冲区,线程2可以读取b 值为 0 并完成。

重要的是要认识到 JMM 基于顺序一致性;因此,即使它是一个宽松的内存模型(普通加载和存储的分离与易失性加载/存储锁定/解锁等同步操作),如果程序没有数据竞争,它只会产生顺序一致的执行。为了顺序一致性,不需要尊重实时顺序。因此,加载/存储倾斜是完全可以的,只要:

  1. 内存顺序是所有加载/存储的总顺序

  2. 内存顺序与程序顺序一致

  3. 加载会在内存顺序中看到最近的写入。

对我来说,volatile 看起来与 提交它声明,但更像是一个优势,以确保所有 写入发生在 ThreadA 中的 volatile 字段写入可见之前 易失性字段读取后的所有操作(易失性读取发生在 ThreadA 中的 volatile 字段写入已完成)在另一个 ThreadB 中。

你在正确的道路上。

示例。

int a=0
volatile int b=;

thread1(){
   1:a=1
   2:b=1
}

thread2(){
   3:r1=b
   4:r2=a
}

在这种情况下,1-2(程序顺序)之间有一个发生在边缘之前。如果 r1=1,则发生在 2-3 之间的边缘之前(易失性变量),而 a 发生在 3-4 之间的边缘之前(程序顺序)。

因为happens before 关系是传递的,所以在1-4 之间有一个happens before 边。所以 r2 必须是 1。

volatile 负责以下事项:

  • 可见性:需要确保加载/存储不会被优化。

  • 也就是说加载/存储是原子的。所以加载/存储不应该被部分看到。

  • 最重要的是,它需要确保保留 1-2 和 3-4 之间的顺序。

顺便说一下,由于我不是母语人士,我见过可能 我的母语教程(还有一些英语教程)说 volatile 将指示 JVM 线程读取 volatile 的值 来自主内存的变量并且不在本地缓存它,我没有 认为这是真的。

你完全正确。这是一个非常普遍的误解。缓存是事实的来源,因为它们始终是连贯的。如果每次写入都需要进入主存,程序会变得非常慢。内存只是缓存中不适合的任何内容的溢出桶,并且可能与缓存完全不一致。普通/易失性加载/存储存储在缓存中。可以在 MMIO 等特殊情况下或在使用时绕过缓存。 SIMD 指令,但与这些示例无关。

无论如何,谢谢你的回答,因为不是母语人士,我希望我已经表达清楚了。

这里的大多数人都不是母语人士(我当然不是)。你的英语足够好,而且很有前途。

【讨论】:

  • 如果对问题的编辑引入了这样一个单独的问题,以至于发布单独的答案似乎是处理它的最佳方式,这表明实际上对问题的编辑不是澄清,而是添加第二个单独的问题。所以处理这个问题的实际方法是告诉询问者发布一个新的 SO 问题,并在那里发布答案。如果它们是相关的,它们可以相互链接,但是 SO 问题仍然应该是关于一件事的。 (由于您已经在这里发布了,所以当OP单独发布时,您需要移植您的答案)
  • 是的,我知道 JIT 会做一些奇怪的优化,包括编译器重新排序或上面描述的东西。但是就像您的示例一样,在优化之后,发生的情况是程序没有从现场停止读取。对我来说,这并不意味着在 ThreadB 上发生的写入停止写入对 ThreadA 是不可见的,但是在 JIT 优化之后,ThreadA 不会从归档停止中读取,这将使它看起来像“不可见”。所以这里的 volatile 会告诉编译器,filed 很重要,而且是针对多线程的,所以不对它做这种优化。
  • 我知道无论如何都需要使用寄存器。我的意思是,在某些情况下,写入或读取操作可能只发生在寄存器中的数据上。比如一个cpu直接从寄存器中读取一个旧值,而不是从缓存中加载一个新值到寄存器再读取寄存器,其他线程完成的写操作只维护缓存并发,寄存器是线程私有的,就是这样的情况在不同的 cpu 之间对字段的写入是不可见的,因此 volatile 应该强制它向/从缓存而不是寄存器写入/读取值(但仍然使用寄存器)
猜你喜欢
  • 1970-01-01
  • 2016-08-20
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多