为啥需要内存屏障?

Posted

技术标签:

【中文标题】为啥需要内存屏障?【英文标题】:Why do I need a memory barrier?为什么需要内存屏障? 【发布时间】:2011-03-30 11:10:56 【问题描述】:

C# 4 in a Nutshell(强烈推荐顺便说一句)使用以下代码来演示 MemoryBarrier 的概念(假设 A 和 B 在不同的线程上运行):

class Foo
  int _answer;
  bool complete;
  void A()
    _answer = 123;
    Thread.MemoryBarrier(); // Barrier 1
    _complete = true;
    Thread.MemoryBarrier(); // Barrier 2
  
  void B()
    Thread.MemoryBarrier(); // Barrier 3;
    if(_complete)
      Thread.MemoryBarrier(); // Barrier 4;
      Console.WriteLine(_answer);
    
  

他们提到障碍 1 和 4 阻止此示例写入 0,障碍 2 和 3 提供了新鲜度保证:他们确保如果 B 在 A 之后运行,则读取 _complete 将评估为 true

我并没有真正明白。我想我理解为什么需要设置障碍 1 和 4:我们不希望对 _answer 的写入进行优化并放置在写入 _complete(障碍 1)之后,并且我们需要确保 _answer 没有被缓存(障碍 4)。我也认为我理解为什么需要设置屏障 3:如果 A 运行到刚刚写入 _complete = true 之后,B 仍需要刷新 _complete 以读取正确的值。

我不明白为什么我们需要屏障 2!我的一部分说这是因为线程 2(运行 B)可能已经运行到(但不包括)if(_complete),所以我们需要确保 _complete 被刷新.

但是,我看不出这有什么帮助。 _complete 是否仍然有可能在 A 中设置为 true,但 B 方法会看到 _complete 的缓存(错误)版本?即,如果线程 2 运行方法 B 直到第一个 MemoryBarrier 之后,然后线程 1 运行方法 A 直到 _complete = true 但没有进一步,然后线程 1 恢复并测试 if(_complete) -- if 会不会导致 false

【问题讨论】:

@Chaos:CLR via C# book (Richter) 有一个很好的解释 - IIRC 是'volatile' 意味着对 var 的所有访问都被视为 volatile 并在两个方向上强制执行完整的内存屏障。如果您只需要读取或写入屏障并且仅在特定访问中,那么这通常比必要的性能更高。 @Chaos:不是重点,但一个原因是 volatile 在编译器优化方面有其自己的怪癖,可能会导致死锁,请参阅 bluebytesoftware.com/blog/2009/02/24/… @statichippo:说真的,如果你正在处理这种代码(不仅仅是学习它),请阅读 Richter 的书,我再怎么推荐也不为过。 amazon.com/CLR-via-Dev-Pro-Jeffrey-Richter/dp/0735627045 @James:volatile 关键字强制执行“半”屏障(加载-获取 + 存储-释放)——而不是完整的屏障。如果你引用里希特的话,那么他在这一点上是错误的。 Joe Duffy 的“Windows 中的并发编程”中有很好的解释。 我开始怀疑是否有人写过一段代码,需要没有错误的 MemoryBarriers。 【参考方案1】:

屏障#2 保证对_complete 的写入立即被提交。否则它可能会保持在排队状态,这意味着即使B 有效地使用了易失性读取,B 中的_complete 的读取也不会看到由A 引起的变化。

当然,这个例子并不能完全解决这个问题,因为A 在写入_complete 之后什么都不做,这意味着无论如何都会立即提交写入,因为线程提前终止了。

对于if 是否仍可以评估为false 的问题的答案是肯定的,这正是您所说的原因。但是,请注意作者对这一点的看法。

障碍 1 和 4 阻止了此示例 从写“0”。障碍 2 和 3 提供新鲜度保证:他们 确保如果 B 在 A 之后运行,则读取 _complete 将评估为 true。

我强调“如果 B 跑在 A 之后”。这当然可能是两个线程交错的情况。但是,作者忽略了这种情况,大概是为了说明Thread.MemoryBarrier 的工作原理更简单。

顺便说一句,我很难在我的机器上设计一个示例,其中障碍 #1 和 #2 会改变程序的行为。这是因为关于写入的内存模型在我的环境中很强大。也许,如果我有一台多处理器机器,正在使用 Mono,或者有一些其他不同的设置,我可以演示它。当然,很容易证明移除障碍 #3 和 #4 会产生影响。

【讨论】:

谢谢,这很有帮助。我想我并没有我想的那么无知。 我不明白 B 在 A 后面跑的情况下需要屏障 2 3。两者都是完整的围栏,所以他们中的任何一个都会单独做,不会吗? @ohadsc:内存屏障仅影响单个线程的行为。考虑 A 和 B 可能在不同的 CPU 上运行。如果您删除了屏障 2,则可能不会提交写入。如果您删除了屏障 3,则可能不会刷新读取。 A中的障碍对B的执行没有影响,反之亦然。 我不明白内存屏障#4(有必要吗?)。 #3 已经确保我们“使”内存缓存“无效”并拥有最新的值。并且 _answer 保证首先具有价值。我错过了什么? @Erti-ChrisEelmaa:屏障 #4 阻止 _answer_complete 之前被读取,如果 A 和 B 交错,这可能导致程序打印 0。【参考方案2】:

这个例子不清楚有两个原因:

    要完全展示栅栏发生的情况太简单了。 Albahari 包括对非 x86 架构的要求。请参阅MSDN:“MemoryBarrier 仅在内存排序较弱的多处理器系统上需要(例如,使用多个 Intel Itanium 处理器 [Microsoft 不再支持] 的系统)。”。

如果你考虑以下几点,它会变得更清楚:

    内存屏障(此处为全屏障 - .Net 不提供半屏障)可防止读/写指令越界(由于各种优化)。这保证了我们在栅栏之后的代码将在栅栏之前的代码之后执行。 “此序列化操作可确保在程序顺序中位于 MFENCE 指令之前的每个加载和存储指令都是全局可见的,而随后 MFENCE 指令之后的任何加载或存储指令都是全局可见的。”见here。 x86 CPU 具有强大的内存模型,并保证写入对所有线程/内核都是一致的(因此在 x86 上不需要屏障 #2 和 #3)。但是,我们不能保证读取和写入将保持编码顺序,因此需要屏障 #1 和 #4。 内存屏障效率低下,不需要使用(参见同一篇 MSDN 文章)。我个人使用 Interlocked 和 volatile(确保您知道如何正确使用它!!),它们工作高效且易于理解。

附言。 This article 很好地解释了 x86 的内部工作原理。

【讨论】:

以上是关于为啥需要内存屏障?的主要内容,如果未能解决你的问题,请参考以下文章

内存屏障

解密内存屏障

解密内存屏障

内存屏障

Linux 内核 内存管理优化内存屏障 ④ ( 处理器内存屏障 | 八种处理器内存屏障 | 通用内存屏障 | 写内存屏障 | 读内存屏障 | 数据依赖屏障 | 强制性内存屏障 |SMP内存屏障 )

Linux 内核 内存管理优化内存屏障 ④ ( 处理器内存屏障 | 八种处理器内存屏障 | 通用内存屏障 | 写内存屏障 | 读内存屏障 | 数据依赖屏障 | 强制性内存屏障 |SMP内存屏障 )