【问题标题】:Memory Model: preventing store-release and load-acquire reordering内存模型:防止存储释放和加载获取重新排序
【发布时间】:2013-05-15 18:34:41
【问题描述】:

众所周知,与 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。

还有问题:

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

【问题讨论】:

  • 在“高度优化的无锁代码”中使用volatile 似乎很奇怪。您的易失性读取需要花费一百个或更多周期?因此,易失性读取的成本大约是非竞争锁的一半。可能还不止于此。我的建议是重新考虑您的设计以避免不稳定。 bluebytesoftware.com/blog/2010/12/04/SayonaraVolatile.aspx
  • @JimMischel 在 ARM 上(您的评论在那里有效)。在 x86 上,到目前为止,它只会阻止编译器/JIT 重新排序和消除访问。它不会导致发出不同的指令。
  • @Jim Mischel:我的问题是关于加载获取和存储释放障碍。 Volatile 只是一种强制执行它们的 C# 方式。顺便说一句,在 4.5 中,我们可以使用 Volatile 类的 Read 和 Write 方法代替 volatile 关键字。

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


【解决方案1】:

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

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

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

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

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

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

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

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

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

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

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


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

【讨论】:

  • 布莱恩,很好的解释。当我想到障碍时,我会想象牙齿:) 但我并不完全同意你的看法。编译器、运行时和 CPU 给我们提供了明确的障碍(因为它们不够聪明),并且当它们有信息时它们会强制执行隐含的障碍。尽管这些障碍仅在单个线程中起作用,但它们仍然是障碍。例如,不能重新排序对同一变量的写入和读取以保持正确性。从这个角度来看,它应该是这样的:↑ volatile1 write ↨ volatile1 read ↓ volatile2 read ↓
  • @OmariO:关于 same 变量的读/写重新排序,您提出了一个很好的观点。我需要考虑在未来的答案中是否值得一提。我担心重点可能会被忽视,试图强调一个一开始就应该很明显的观点。不过,好点。
  • Brian 我不接受您的回答,因为找到了正确的答案。您的回答对读者仍然有用。
【解决方案2】:

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

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

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

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

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

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

【讨论】:

  • 英特尔“允许”的数据竞赛已经是一场数据竞赛。如果这些排队的存储指令碰巧在几个周期后发出,那将是相同的。这就是为什么它被允许并且仍然可以称为强有序内存模型的原因。
【解决方案3】:

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

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

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

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

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

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

我认为您对非易失性内存操作而言,易失性读取和写入只是半栅栏这一事实感到困惑。

编辑这篇文章:The C# Memory Model in Theory and Practice, Part 2 正好相反,并支持您的断言,即易失性读取可以向上移动到不相关的易失性写入。建议的解决方案是在重要的地方引入 MemoryBarrier。

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

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

【讨论】:

  • 错了。在 C# 中,可以重新排序 volatile 写入后跟 volatile 读取。 C# 规范对此不是很清楚,但 CLI 规范 (ECMA-335) 有更多详细信息:volatile 只为您提供获取/释放语义,而不是 Java 的 volatile 提供的完整顺序一致性。
【解决方案4】:

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

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

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

【讨论】:

  • 感谢您提供有用的链接。
猜你喜欢
  • 2016-08-17
  • 1970-01-01
  • 2020-01-16
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2011-02-04
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多