为什么我需要内存屏障?

40

C# 4入门经典(强烈推荐)使用以下代码演示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。我不太明白为什么需要屏障2。也许线程2(运行B)已经运行到(但不包括)if(_complete),因此我们需要确保刷新_complete。但是,我看不出这有什么帮助。是否仍然可能在A中将_complete设置为true,但B方法将看到缓存的(false)版本_complete?即,如果线程2运行了B方法直到第一个MemoryBarrier然后线程1运行了A方法直到_complete = true,但没有进一步操作,然后线程1恢复并测试if(_complete)-那么那个if不能导致false吗?

7
@Chaos:CLR via C#一书(Richter著)对此有很好的解释-如果我没记错的话,它是指“volatile”意味着将所有对变量的访问视为volatile,并在两个方向上强制执行完整的内存屏障。 如果你只需要读取或写入屏障,并且仅在特定的访问中进行,那么这通常会带来比必要的性能损失更多的影响。 - James Manning
1
@Chaos:并不是重点,但一个原因是volatile在编译器优化方面有其自身的怪癖,可能会导致死锁,请参见http://www.bluebytesoftware.com/blog/2009/02/24/TheMagicalDuelingDeadlockingSpinLocks.aspx。 - hackerhasid
1
@statichippo:如果你正在处理这种代码(不仅仅是学习),请一定要购买Richter的书,我强烈推荐它。http://www.amazon.com/CLR-via-Dev-Pro-Jeffrey-Richter/dp/0735627045 - James Manning
2
@James:volatile关键字强制执行“半”屏障(load-acquire + store-release) - 而不是完整的屏障。如果你在引用Richter,那么他在这一点上是错误的。在Joe Duffy的“Windows并发编程”中有一个很好的解释。 - Joe Albahari
4
我开始怀疑是否有人曾经编写过需要Memory Barriers的代码而没有错误。 (“Memory Barriers”是一种用于同步多个线程之间内存访问的机制,这句话意味着如果使用了Memory Barriers,则很可能存在错误) - Martin Brown
显示剩余6条评论
2个回答

32

屏障 #2 保证写入 _complete 立即提交,否则它可能会保留在队列状态中,这意味着 B 中的 _complete 的读取将不会看到由 A 引起的更改,即使 B 实际上使用了一次易失性读取。

当然,这个例子并不能充分说明问题,因为 A 在写入 _complete 后没有做更多的事情,这意味着线程早期终止,因此写入将立即提交。

对于你的问题,是否 if 仍然可以评估为 false,答案是肯定的,原因正是你所说的。但请注意作者在这一点上的发言。

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

我强调“如果B在A之后运行”,作者显然忽略了这种情况,以简化他关于 Thread.MemoryBarrier 如何工作的观点。

顺带一提,我很难在自己的机器上创造一个例子,使得屏障1和2会改变程序的行为。这是因为我的环境中写入的内存模型非常强大。也许如果我有一台多处理器的机器、使用Mono或者其他不同的设置,我就能够证明它了。当然,去掉屏障3和4则很容易证明它们具有影响。


谢谢,这很有帮助。我想我并不像我想象的那么无知。 - hackerhasid
我不明白在B在A之后运行的情况下为什么需要屏障2和3。两者都是完整的屏障,所以任何一个都可以单独使用,不是吗? - Ohad Schneider
5
内存屏障只影响单个线程的行为。考虑到 A 和 B 可能在不同的 CPU 上运行。如果去掉屏障2,则写入可能不会被提交。如果去掉屏障3,则读取可能不会被刷新。A 中的屏障对 B 的执行没有影响,反之亦然。 - Brian Gideon
谢谢,我现在明白了。如果你有时间,请看一下我的问题,关于你在这里的回答:https://dev59.com/6ljUa4cB1Zd3GeqPVuUL#6575415 - Ohad Schneider
2
我不理解内存栅#4(是否必要?)。#3已经确保我们“使”内存缓存无效,并具有最新的值。而且保证_answer首先具有值。我错过了什么吗? - Erti-Chris Eelmaa
1
@Erti-ChrisEelmaa:屏障#4防止在_complete之前读取_answer,这可能会导致程序在A和B交错时打印0。 - Brian Gideon

5
例子存在两个不清晰的原因:
1. 这个例子太简单了,无法全面展示栅栏的作用。
2. Albahari 包含了非 x86 架构的要求。查看 MSDN: "MemoryBarrier 只在弱内存排序的多处理器系统上需要 (例如,使用多个 Intel Itanium 处理器的系统 [Microsoft 不再支持] )。"
如果你考虑以下内容,就会变得更加清晰:
1. 内存屏障(这里是完整的屏障——.Net 不提供半个屏障)防止读取/写入指令越过栅栏(由于各种优化)。这保证我们能够在栅栏之后执行代码之前执行代码。
2. "这个序列化操作保证了在 MFENCE 指令之前按程序顺序执行的每个加载和存储指令在 MFENCE 指令之后按程序顺序执行的任何加载或存储指令之前全局可见。" 详情请参阅 这里
3. x86 CPU 具有强大的内存模型,并保证写入对所有线程/内核都是一致的(因此在 x86 上不需要使用屏障 #2&#3)。但我们不能保证读取和写入将保持编码顺序,因此需要使用栅栏 #1 和 #4。
4. 内存屏障效率低下,无需使用(请参阅同一 MSDN 文章)。我个人使用 Interlocked 和 volatile(确保您知道正确的使用方法!!),这些工具高效且易于理解。
附注: 这篇文章 很好地解释了 x86 的内部工作原理。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接