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

时间:2013-05-15 18:34:41

标签: c# .net performance volatile memory-model

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

虽然有效但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 <重新排序/强>

问题是:

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

4 个答案:

答案 0 :(得分:3)

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

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

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

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

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

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

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

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

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

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

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


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

答案 1 :(得分:1)

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

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

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

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

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

所以答案是:不,虚拟读数( B )不会阻止 A C 之间的重新排序,并且无法使用作为轻量级记忆障碍。

答案 2 :(得分:0)

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

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

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

  

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

易失性读写被定义为“副作用”,本段规定了副作用的排序。

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

我认为你对这个事实感到困惑,就非易失性内存操作而言,易失性读写只是半个围栏。

编辑这篇文章:The C# Memory Model in Theory and Practice, Part 2完全相反,并支持您的断言,即易失性读取可以通过不相关的易失性写入。建议的解决方案是在重要的位置引入MemoryBarrier。

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

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

答案 3 :(得分:0)

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

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

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