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

2 c# c++ multithreading memory-model lock-free

我对以下代码示例有疑问(摘自: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);
       }
    }
 }
Run Code Online (Sandbox Code Playgroud)

接下来是以下解释:

"障碍1和障碍4阻止这个例子编写"0".障碍2和3提供了新鲜度保证:他们确保如果B在A之后运行,则读取_complete将评估为真."

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

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

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
}
Run Code Online (Sandbox Code Playgroud)

这个例子后面是这样的解释:

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

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

ton*_*ony 6

在第一种情况下,Barrier 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自己正在做的事情.因此,如果他们无法在没有障碍的情况下确定之前/之后,那么问"如果B在A之后运行会发生什么,没有障碍" 会毫无意义.

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

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

void B()
{
   if (ReadWithAcquire(_complete))  // subscribe
   {  
      Console.WriteLine (_answer);
   }
}
Run Code Online (Sandbox Code Playgroud)

单词"subscribe"并不常用于描述情况,但"发布"是.我建议你阅读Herb Sutter关于穿线的文章.

这将障碍放在恰当的位置.

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

while (!complete)
{
   some_external_function(&complete);
}
Run Code Online (Sandbox Code Playgroud)

即使函数没有修改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)
   ...
Run Code Online (Sandbox Code Playgroud)

如果没有屏障,CPU可能会将所有这5个读取请求"挂起":

a,b,c,d,_complete
Run Code Online (Sandbox Code Playgroud)

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

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

问题是 - 它有什么不同?

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

所以重点是 - 保持相对.是否需要在相对于其他一些数据之前/之后读/写数据?这就是问题所在.

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