内存栅栏如何影响数据的“新鲜度”?

时间:2009-11-14 19:52:25

标签: c# c++ multithreading lock-free memory-model

我对以下代码示例有疑问(摘自:http://www.albahari.com/threading/part4.aspx#_NonBlockingSynch

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。”

我理解使用记忆障碍如何影响指令的记录,但提到的“新鲜保证”是什么?

在本文后面,还使用了以下示例:

static void Main()
{
    bool complete = false; 
    var t = new Thread (() =>
    {
        bool toggle = false;
        while (!complete) 
        {
           toggle = !toggle;
           // adding a call to Thread.MemoryBarrier() here fixes the problem
        }

    });

    t.Start();
    Thread.Sleep (1000);
    complete = true;
    t.Join();  // Blocks indefinitely
}

此示例后面是这样的解释:

  

“此程序永远不会终止,因为完整变量缓存在CPU寄存器中。在while循环中插入对Thread.MemoryBarrier的调用(或锁定读取完成)可以修复错误。”

再次......这里发生了什么?

4 个答案:

答案 0 :(得分:6)

在第一种情况下,障碍1确保在_answer之前写入_complete。无论代码是如何编写的,或者编译器或CLR如何指示CPU,内存总线读/写队列都可以对请求进行重新排序。障碍基本上说“在继续之前冲洗队列”。同样,Barrier 4确保在_answer之后读取_complete。否则,CPU2可能会对内容进行重新排序,并看到旧版_answer带有“新”_complete

在某种意义上,障碍2和3是无用的。请注意,解释包含单词“after”:即“......如果B在A之后运行,......”。 B在A之后跑的意味着什么?如果B和A在同一个CPU上,那么肯定,B可以在之后。但在这种情况下,相同的CPU意味着没有内存屏障问题。

因此,考虑在不同的CPU上运行B和A.现在,非常像爱因斯坦的相对论,在不同位置/ CPU比较时间的概念并不真正有意义。 另一种思考方式 - 你能编写代码来判断B是否在A之后运行?如果是这样,你可能会使用记忆障碍来做到这一点。否则,你无法分辨,并且没有任何意义。它也类似于海森堡的原则 - 如果你能观察它,你就修改了实验。

但是将物理学放在一边,假设您可以打开机器的引擎盖,并且看到 _complete的实际内存位置是真的(因为A已经运行)。现在运行B.没有Barrier 3,CPU2可能仍然看不到_complete为真。即不是“新鲜”。

但您可能无法打开机器并查看_complete。也不会将您的发现传达给CPU2上的B.您唯一的沟通是CPU自己正在做的事情。因此,如果他们无法在没有障碍的情况下确定BEFORE / AFTER,那么询问“如果B在A之后运行会发生什么,没有障碍”没有任何意义

顺便说一下,我不确定你在C#中有什么可用,但是通常做了什么,Code sample#1真正需要的是写入时的单个释放障碍,以及读取时的单个获取障碍:

void A()
{
   _answer = 123;
   WriteWithReleaseBarrier(_complete, true);  // "publish" values
}

void B()
{
   if (ReadWithAcquire(_complete))  // subscribe
   {  
      Console.WriteLine (_answer);
   }
}

“subscribe”这个词通常不用于描述情况,但“发布”是。我建议你阅读Herb Sutter关于穿线的文章。

这会将中的障碍完全放在正确的位置。

对于代码示例#2,这实际上不是内存屏障问题,它是编译器优化问题 - 它将complete保留在寄存器中。内存屏障会强制它,就像volatile一样,但可能会调用外部函数 - 如果编译器无法判断外部函数是否被修改complete,它将重新读取它来自记忆。即可能将complete的地址传递给某个函数(在编译器无法检查其详细信息的地方定义):

while (!complete)
{
   some_external_function(&complete);
}

即使函数没有修改complete,如果编译器不确定,也需要重新加载其寄存器。

即代码1和代码2之间的区别在于,当A和B在不同的线程上运行时,代码1只有问题。即使在单线程机器上,代码2也可能存在问题。

实际上,另一个问题是 - 编译器可以完全删除while循环吗?如果它认为其他代码无法访问complete,为什么不呢?即如果它决定将complete移动到寄存器中,它也可以完全删除循环。

编辑:回答opc的评论(我的答案对于评论块来说太大了):

屏障3强制CPU清除任何挂起的读取(和写入)请求。

想象一下,在阅读_complete之前是否还有其他读物:

void B {}
{
   int x = a * b + c * d; // read a,b,c,d
   Thread.MemoryBarrier();    // Barrier 3
   if (_complete)
   ...

如果没有屏障,CPU可能会将所有这5个读取请求都置为“挂起”:

a,b,c,d,_complete

如果没有屏障,处理器可以重新排序这些请求以优化内存访问(即,如果_complete和'a'位于同一缓存行或其他位置)。

使用屏障,CPU会从内存中获取a,b,c,d,甚至将_complete作为请求放入。确保'b'(例如)在_complete之前读取 - 即没有重新排序。

问题是 - 它有什么不同?

如果a,b,c,d独立于_complete,则无关紧要。所有的障碍都是慢下来。所以是的,_complete稍后会被读取。所以数据更新。在读取之前将睡眠(100)或一些忙等待循环放在那里也会使它“更新鲜”! : - )

所以关键是 - 保持相对。是否需要在相对于其他一些数据之前/之后读/写数据?这是个问题。

并没有放下文章的作者 - 他确实提到“如果B在A之后跑了......”。目前还不清楚他是否想象A之后的B对代码至关重要,可以通过代码观察,或者只是无关紧要。

答案 1 :(得分:1)

代码示例#1:

每个处理器核心都包含一个带有部分内存副本的缓存。可能需要一些时间来更新缓存。内存屏障保证缓存与主内存同步。例如,如果您在此处没有障碍2和3,请考虑以下情况:

处理器1运行A()。它将_complete的新值写入其缓存(但不一定是主内存)。

处理器2运行B()。它读取_complete的值。如果此值以前在其缓存中,则可能不是新鲜的(即,与主存储器不同步),因此它不会获得更新的值。

代码示例#2:

通常,变量存储在内存中。但是,假设在单个函数中多次读取一个值:作为优化,编译器可能决定将其读入CPU寄存器一次,然后在每次需要时访问该寄存器。这要快得多,但是阻止函数检测来自另一个线程的变量的更改。

此处的内存屏障强制函数从内存中重新读取变量值。

答案 2 :(得分:0)

调用Thread.MemoryBarrier()会立即使用变量的实际值刷新寄存器缓存。

在第一个示例中,_complete的“新鲜度”是通过在设置之后立即调用方法并在使用之前提供的。在第二个示例中,变量false的初始complete值将缓存在线程自己的空间中,需要重新同步才能立即从“内部”看到实际的“外部”值运行线程。

答案 3 :(得分:0)

“新鲜度”保证只是意味着障碍2和3强制_complete的值尽快显示,而不是在碰巧写入内存时。

从一致性的角度来看,这实际上是不必要的,因为障碍1和4确保在阅读answer后将会读取complete