7

众所周知,与 Java 的 volatile 不同,.NET 的 volatile 允许使用来自另一个位置的以下 volatile 读取来重新排序 volatile 写入。当出现问题时 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 

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

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

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

问题是:

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

4

4 回答 4

3

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

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

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

如此清晰地我们可以看到允许读和写交换位置。你是对的。

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

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

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

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

正确的解决方案是在访问之间进行调用Thread.MemoryBarrier。您可以看到这是如何用箭头符号概念化的。

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

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


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

于 2013-07-13T14:18:14.627 回答
2

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

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

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

在我们的案例中,最终结果是,由于从本地存储(存储缓冲区)中满足了B处的读取,因此它不被视为易失性读取,并且可以通过从另一个内存位置(C)的进一步易失性读取来重新排序。

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

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

于 2015-09-24T12:07:48.167 回答
0

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

我当然不是“授权”的人,但我认为您没有正确理解内存模型。

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

对于易失性读取和写入,副作用的顺序得以保留。

易失性读取和写入被定义为“副作用”,本段声明保留了副作用的顺序。

因此,我的理解是,您的整个问题是基于一个不正确的假设:无法重新排序易失性读写。

我认为您对这样一个事实感到困惑,即关于非易失性内存操作,易失性读写只是半栅栏。

编辑这篇文章:理论与实践中的 C# 内存模型,第 2 部分正好相反,并支持您的断言,即易失性读取可以向上移动到不相关的易失性写入。建议的解决方案是在重要的地方引入 MemoryBarrier。

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

现在我发现我上面引用的 C# 规范令人困惑!但是考虑到在 x86 上,相同的指令用于易失性内存访问和常规内存访问,因此它们受到相同的半栅栏重新排序问题是完全合理的。结束编辑

于 2013-06-01T19:06:19.377 回答
0

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

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

另外,伙计们,Brian 和 OmariO,请不要将内存操作与获取/释放语义和获取/释放内存栅栏混为一谈。例如,读取-获取操作与获取栅栏不同。它们具有相似的语义,但它们之间的区别非常重要。我所知道的对这些术语的最佳解释是在 Jeff Preshing 的精彩博客中:
Acquire and Release Semantics
Acquire and Release Fences

于 2013-11-28T18:25:05.190 回答