内存模型:防止存储释放和负载获取重新排序

众所周知,与Java的易失性不同,.NET的版本允许使用来自另一个位置的以下易失性读取来重新排序易失性写入。 当它出现问题时,建议将MemoryBarier置于它们之间,或者可以使用Interlocked.Exchange而不是volatile write。

它可以工作,但MemoryBarier在高度优化的无锁代码中使用时可能会成为性能杀手。

我想了一下,想出了一个主意。 我希望有人告诉我,我是否采取了正确的方式。

所以,想法如下:

我们希望防止这两种访问之间的重新排序:

  volatile1 write volatile2 read 

从.NET MM我们知道:

  1) writes to a variable cannot be reordered with a following read from the same variable 2) no volatile accesses can be eliminated 3) no memory accesses can be reordered with a previous volatile read 

为了防止写入和读取之间不必要的重新排序,我们从刚刚写入的变量中引入了一个虚拟易失性读取:

  A) volatile1 write B) volatile1 read [to a visible (accessible | potentially shared) location] C) volatile2 read 

在这种情况下, B不能用A重新排序,因为它们都访问相同的变量, C不能用B重新排序,因为两个易失性读取不能相互重新排序,并且传递C不能用A重新排序。

问题是:

我对吗? 这种虚拟易失性读取是否可以用作这种情况的轻量级内存屏障?

在这里,我将使用箭头符号来概念化内存障碍。 我使用向上箭头↑和向下箭头↓分别表示易失性写入和读取。 把箭头想象成推开任何其他读或写。 因此,没有其他内存访问可以移过箭头,但它们可以移过尾部。

考虑你的第一个例子。 这就是概念化的方式。

 ↑ volatile1 write // A volatile2 read // B ↓ 

很明显,我们可以看到允许读取和写入切换位置。 你是对的。

现在考虑你的第二个例子。 您声称引入虚拟读取会阻止A的写入和B的读取被交换。

 ↑ volatile1 write // A volatile1 read // A ↓ volatile2 read // B ↓ 

我们可以看到B的虚拟读取阻止了B浮动。 我们还可以看到, A的读取不能向下浮动,因为通过推断,这将与BA之前向上移动相同。 但是,请注意我们没有↑箭头可以阻止写入A向下浮动(记住它仍然可以移过箭头的尾部)。 因此,至少在理论上,注入A的虚拟读取不会阻止A的原始写入和B的读取被交换,因为仍然允许写入A向下移动。

我必须真正考虑这种情况。 我沉思了一段时间的一件事是A读写是否被串联在一起。 如果是这样,那么这将阻止写入A向下移动,因为它必须用它来读取我们已经说过的被禁止的读取。 因此,如果你选择那种思想流派,那么你的解决方案可能会起作用。 但是,我再次阅读了规范,并且我没有看到关于同一变量的volatile访问的特别提及。 显然,线程必须以与原始程序序列逻辑一致的方式执行(在规范中提到)。 但是,我可以想象编译器或硬件可以优化(或以其他方式重新排序) A串联访问的方式,并且仍然得到相同的结果。 因此,我只需要谨慎对待并假设写入A可以向下移动。 请记住,易失性读取并不意味着“从主存储器中读取新内容”。 对A的写入可以缓存在寄存器中,然后读取来自该寄存器,将实际写入延迟到以后的时间。 据我所知,易失性语义并不能阻止这种情况。

正确的解决方案是在访问之间调用Thread.MemoryBarrier 。 您可以使用箭头符号查看这是如何构思的。

 ↑ volatile1 write // A ↑ Thread.MemoryBarrier ↓ volatile2 read // B ↓ 

现在您可以看到不允许读取浮动并且不允许写入向下浮动以防止交换。


您可以在此处使用此箭头符号查看我的其他一些内存障碍答案, 此处仅举几例。

我忘了将很快找到的答案发回SO。 迟到总比不到好..

事实certificate,由于处理器(至少x86-x64类型)如何优化内存访问,这是不可能的。 在阅读英特尔手册时,我找到了答案。 例8-5:“允许处理器内部转发”看起来很可疑。 谷歌搜索“商店缓冲区转发”导致乔达菲的博客文章( 第一和第二 – 阅读他们)。

为了优化写入,处理器使用存储缓冲区(每个处理器队列的写操作)。 在本地缓冲写入允许它进行下一次优化:满足从先前缓冲的写入到同一存储器位置的读取以及尚未离开处理器的读取。 该技术称为存储缓冲区转发(或存储到加载转发)。

在我们的情况下,最终结果是,当从本地存储(存储缓冲区)满足B的读取时,它不被认为是易失性读取,并且可以与来自另一个存储器位置( C )的进一步易失性读取重新排序。

这似乎违反了规则“易失性读取不会相互重新排序”。 是的,这是违规行为,但非常罕见和充满异国情调。 为什么会这样? 可能是因为英特尔在.NET(及其JIT编译器)看到阳光之后几年发布了第一份关于其处理器内存模型的正式文档。

所以答案是:不,虚拟读数( B )不会阻止AC之间重新排序,也不能用作轻量级内存屏障。

编辑我从C#规范中得出的结论是错误的,见下文。 结束编辑

我肯定不是’授权’的人,但我认为你还没有正确理解记忆模型。

引用C#规范,第105页的§10.10 执行顺序 ,第三个项目符号点:

关于易失性读取和写入,保留了副作用的顺序。

易失性读写被定义为“副作用”,本段指出副作用的排序是保留的。

所以我的理解是你的整个问题是基于一个不正确的假设: 不能重新排序易失性读写。

我认为你对这一事实感到困惑,就非易失性存储器操作而言,易失性读写仅仅是半栅栏。

编辑本文: 理论与实践中的C#内存模型,第2部分恰恰相反,并支持您的断言,即易失性读取可以通过不相关的易失性写入。 建议的解决方案是引入一个重要的MemoryBarrier。

Daniel下面的评论也说,CLI规范比C#规范更具体,允许这种重新排序。

现在我发现上面引用的C#规范令人困惑! 但鉴于在x86上,相同的指令用于易失性存储器访问和常规存储器访问,因此它们完全有意义,它们受到相同的半栅栏重新排序问题的影响。 结束编辑

让我不同意Brian Gideon接受的回答。

OmariO 你的问题解决方案(虚拟阅读)对我来说看起来非常正确 。 正如您正确提到的那样,对变量的写入不能通过从同一变量读取以下内容来重新排序。 如果可以进行重新排序,则会在单线程情况下使代码不正确(读取操作可能返回与先前写入操作所写的值不同)。 也就是说,它违反了任何内存模型的基本规则:程序的单线程执行不得在逻辑上改变。

另外,Brian和OmariO,请不要将内存操作与获取/发布语义混淆,并获取/释放内存防护。 例如,读取获取操作与获取栅栏不同。 它们具有相似的语义,但它们之间的区别非常重要。 我所知道的那些术语的最佳解释是在Jeff Preshing的伟大博客中:
获取和释放语义
获取并释放围栏