挥发性围栏演示?

时间:2013-03-01 17:17:19

标签: c# .net multithreading volatile

我试图了解围栏是如何应用的。

我有这段代码(无限期阻止):

static void Main()
{
    bool complete = false;
    var t = new Thread(() => {
        bool toggle = false;
        while(!complete) toggle = !toggle;
    });
    t.Start();
    Thread.Sleep(1000);
    complete = true;
    t.Join(); // Blocks indefinitely
}

volatile bool _complete;解决问题。

获取围栏:

  

获取围栏可防止其他读/写在之前移动;

但是,如果我使用箭头 来说明它(想想箭头就是把所有东西推开。)

所以现在 - 代码看起来像:

 var t = new Thread(() => {
            bool toggle = false;
            while( !complete ) 
                    ↓↓↓↓↓↓↓     // instructions can't go up before this fence.  
               {
                 toggle = !toggle;
                }
        });

我不明白插图如何代表解决这个问题的解决方案。

我知道while(!complete)现在读取了真正的价值。但它如何与围栏的complete = true;位置相关?

2 个答案:

答案 0 :(得分:17)

complete volatile做两件事:

  • 它阻止C#编译器或抖动进行优化以缓存complete的值。

  • 它引入了一个围栏,告诉处理器需要对预先读取或延迟写入的其他读写的缓存优化进行去优化以确保一致性。

让我们考虑第一个。抖动完全在于它看到循环体的权利:

    while(!complete) toggle = !toggle;

不会修改complete,因此在循环开始时complete的值是它将永远存在的值。因此,允许抖动生成代码,就像您编写的那样

    if (!complete) while(true) toggle = !toggle;

或更可能:

    bool local = complete; 
    while(local) toggle = !toggle;

使complete volatile阻止两种优化。

但你要找的是挥发性的第二个影响。假设您的两个线程在不同的处理器上运行。每个都有自己的处理器缓存,这是主内存的副本。假设两个处理器都制作了complete为假的主内存副本。当一个处理器的缓存将complete设置为true时,如果complete不是易失性的,那么“切换”处理器不需要注意到这一事实;它有自己的缓存,complete仍然是假的,每次返回主内存都会很昂贵。

complete标记为volatile会消除此优化。如何消除它是处理器的实现细节。也许在每次易失性写入时,写入都会写入主存储器,而其他所有处理器都会丢弃其缓存。或许还有其他一些策略。处理器如何选择实现它取决于制造商。

关键在于,无论何时使字段变为易失性然后读取或写入,都会大大破坏编译器,抖动和处理器优化代码的能力。尽量不要首先使用volatile字段;使用更高级别的构造,并且不在线程之间共享内存。

  

我正在尝试将句子可视化:“获取栅栏阻止其他读/写在栅栏前移动......”在栅栏之前不应该有什么指令?

考虑指示可能适得其反。而不是考虑一堆指令只关注读写序列。其他一切都无关紧要。

假设您有一块内存,其中一部分被复制到两个缓存中。出于性能原因,您主要读取和写入缓存。您不时地将缓存与主内存重新同步。这对读写序列有什么影响?

假设我们希望这发生在一个整数变量中:

  1. Processor Alpha将0写入主内存。
  2. 处理器Bravo从主内存中读取0。
  3. 处理器Bravo将1写入主存储器。
  4. Processor Alpha从主内存中读取1。
  5. 假设真正发生的是:

    • 处理器Alpha将0写入缓存,并与主存储器同步。
    • 处理器Bravo从主内存同步缓存并读取0。
    • 处理器Bravo将1写入缓存并将缓存同步到主内存。
    • 处理器Alpha从其缓存中读取0 - 陈旧值。

    真正发生的事情与此有什么不同?

    1. Processor Alpha将0写入主内存。
    2. 处理器Bravo从主内存中读取0。
    3. Processor Alpha从主内存中读取0。
    4. 处理器Bravo将1写入主存储器。
    5. 没有什么不同。高速缓存将“写入读写入读取”转换为“写入读取写入”。它会及时向后移动其中一个读取,并且在这种情况下等效地向前移动其中一个写入。

      此示例仅涉及对一个位置的两次读取和两次写入,但您可以想象一个场景,其中有许多读取和许多位置的写入。处理器具有宽广的格度,可以及时向后移动读取并及时向前移动写入。什么动作的准确规则是合法的,哪些处理器与处理器没有区别。

      fence 是一个屏障,可防止读取向后移动或写入向前移动。所以,如果我们有:

      1. Processor Alpha将0写入主内存。
      2. 处理器Bravo从主内存中读取0。
      3. 处理器Bravo将1写入主内存。 FENCE HERE。
      4. Processor Alpha从主内存中读取1。
      5. 无论处理器使用何种缓存策略,现在都不允许将读取4移动到围栏之前的任何点。同样地,不允许将写入3提前移动到栅栏后的任何点。处理器如何实现围栏取决于它。

答案 1 :(得分:5)

像我关于内存障碍的大多数答案一样,我将使用箭头符号,其中↓表示获取栅栏(易失性读取),↑表示释放栅栏(易失性写入)。请记住,没有其他读或写可以移过箭头(尽管它们可以越过尾部)。

让我们先分析写作线程。我将假设complete被声明为volatile 1 Thread.StartThread.SleepThread.Join将生成完整的围栏,这就是为什么我在每个呼叫的两侧都有向上和向下箭头。

↑                   // full fence from Thread.Start
t.Start();
↓                   // full fence from Thread.Start
↑                   // full fence from Thread.Sleep
Thread.Sleep(1000);
↓                   // full fence from Thread.Sleep
↑                   // release fence from volatile write to complete
complete = true;
↑                   // full fence from Thread.Join
t.Join();
↓                   // full fence from Thread.Join

这里需要注意的一件重要事情是,Thread.Join调用阻止了对complete的写入进一步向下浮动。这里的效果是写入立即被提交到主存储器。 complete本身的波动性不会导致它被刷新到主内存。它是Thread.Join调用以及它生成的导致该行为的内存屏障。

现在我们将分析阅读线程。由于while循环,这可视化有点棘手,但让我们从这开始。

bool toggle = false;
register1 = complete;
↓                           // half fence from volatile read
while (!register1)
{
  bool register2 = toggle;
  register2 = !register2;
  toggle = register2;
  register1 = complete;
  ↓                         // half fence from volatile read
}

如果我们unwind the loop,也许我们可以更好地想象它。为简洁起见,我只展示前4次迭代。

if (!register1) return;
register2 = toggle;
register2 = !register2;
toggle = register2;
register1 = complete;
↓
if (!register1) return;
register2 = toggle;
register2 = !register2;
toggle = register2;
register1 = complete;
↓
if (!register1) return;
register2 = toggle;
register2 = !register2;
toggle = register2;
register1 = complete;
↓
if (!register1) return;
register2 = toggle;
register2 = !register2;
toggle = register2;
register1 = complete;
↓

既然我们已经解开了循环,我想你可以看到complete读取的任何潜在动作将会受到严重限制。 2 是的,它可以被洗牌由编译器或硬件提供一点点,但它几乎被锁定在每次迭代时被读取。请记住,complete的读取仍可自由移动,但它创建的栅栏不随其移动。围栏被锁定到位。这就是导致这种行为通常被称为“新鲜阅读”的原因。如果在volatile上省略complete,则编译器可以自由使用称为“提升”的优化技术。这就是可以在循环外提取或提取内存地址的读取。在没有volatile的情况下,优化将是合法的,因为所有complete读取都将被允许浮动(或提升),直到它们最终都在环。此时,编译器会在启动循环之前将它们全部合并为一次性读取。 3

现在让我总结一些重点。

  • 调用Thread.Join导致写入complete被提交到主内存,以便工作线程最终将其提取。 complete的波动性与写作线程无关(这对大多数人来说可能是令人惊讶的)。
  • complete的易失性读取生成的获取栅栏阻止读取被提升到循环之外,从而产生“新鲜读取”行为。 complete在阅读主题上的波动性产生了巨大的差异(这对大多数人来说可能是显而易见的)。
  • “提交写入”和“新读取”不会直接导致易失性读写。但是,它们是偶然发生的间接后果,特别是在循环的情况下。

1 在写入线程上将complete标记为volatile不是必需的,因为x86写入已经具有volatile语义,但更重要的是因为创建了围栅通过它不会导致“提交写入”行为。

2 请记住,读取和写入可以通过箭头的尾部移动,但箭头已锁定到位。这就是为什么你不能冒出循环之外的所有读数。

3 提升优化还必须确保线程的实际行为与程序员最初的预期一致。在这种情况下,这个要求很容易满足,因为编译器可以很容易地看到complete永远不会写在该线程上。