关于volatile和Memory-Barrier我不明白的是

时间:2014-02-24 13:52:35

标签: c# multithreading volatile memory-barriers memory-model

循环提升易失性读取

我已经阅读了很多地方,不能从循环中提取volatile变量或者如果,但是我找不到这提到C#规范中的任何地方。这是一个隐藏的功能吗?

所有写入在C#中都是易失的

这是否意味着所有写入都没有相同的属性,就像使用volatile关键字一样?例如,C#中的普通写入具有发布语义?并且所有写入都刷新处理器的存储缓冲区?

发布语义

这是一种正式的方式,说明当完成易失性写入时,处理器的存储缓冲区会被清空吗?

获取语义

这是一种正式的说法,不应该将变量加载到寄存器中,而是每次都从内存中获取它吗?

this article中,Igoro谈到“线程缓存”。我完全明白这是想象的,但他实际上指的是:

  1. 处理器存储缓冲区
  2. 将变量加载到寄存器中,而不是每次从内存中取出
  3. 某种处理器缓存(这是L1和L2等)
  4. 或者这只是我的想象力?

    延迟撰写

    我已经阅读了许多可以延迟写入的地方。这是因为重新排序和存储缓冲区吗?

    Memory.Barrier

    据我所知,当JIT将IL转换为asm时,副作用是调用“lock or”,这就是为什么Memory.Barrier可以解决fx中对主内存(在while循环中)的延迟写入的原因这个例子:

    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
    }
    

    但总是这样吗?对Memory.Barrier的调用是否总是刷新存储缓冲区,将更新的值提取到处理器缓存中?我知道完整变量不会被提升到寄存器中,而是每次从处理器缓存中提取,但由于调用Memory.Barrier而更新了处理器缓存。

    我是在冰上这里,还是我对volatile和Memory.Barrier的某种理解?

1 个答案:

答案 0 :(得分:12)

那是一口......

我将从你的一些问题开始,并更新我的答案。


循环提升易变性

  

我已经阅读了很多地方,不能从循环中提取volatile变量或者如果,但是我找不到这提到C#规范中的任何地方。这是一个隐藏的功能吗?

MSDN说"声明为volatile的字段不受编译器优化的约束,这些优化假定单个线程访问"。这是一个广泛的声明,但它包括吊装或提升"循环中的变量。


所有写入在C#中都是易失的

  

这是否意味着所有写入都没有相同的属性,就像使用volatile关键字一样?例如,C#中的普通写入具有发布语义?并且所有写入都刷新处理器的存储缓冲区?

常规写入 volatile。它们确实具有发布语义,但它们不会刷新CPU的写缓冲区。至少,不是根据规范。

来自Joe Duffy的CLR 2.0 Memory Model

  

规则2:所有商店都有发布语义,即没有加载或商店可能会在一个商店之后移动。

我已经阅读了一些文章,说明C#中的所有写操作都是易失性的(就像你链接到的那样),但这是一个常见的误解。从马的嘴里(The C# Memory Model in Theory and Practice, Part 2):

  

因此,作者可能会说,“在.NET 2.0内存模型中,所有写入都是易失性的 - 甚至是那些非易失性字段。”(...)ECMA C#不保证这种行为规范,因此,可能不会在.NET Framework的未来版本和未来的体系结构中保留(事实上,在ARM上的.NET Framework 4.5中并不成立)。


发布语义

  

这是一种正式的方式,说明当完成易失性写入时,处理器的存储缓冲区会被清空吗?

不,这是两件不同的事情。如果指令具有"释放语义",那么将不会在所述指令下移动存储/加载指令。该定义没有说明有关刷写写缓冲区的内容。它只涉及指令重新排序。


延迟撰写

  

我已经阅读了许多可以延迟写入的地方。这是因为重新排序和存储缓冲区吗?

是。写入指令可以由编译器,抖动或CPU本身进行延迟/重新排序。


  

所以volatile写有两个属性:release语义和store buffer flush。

排序。我更喜欢这样想:

volatile关键字的C#规范保证一个属性:读取具有获取语义,写入具有释放语义。这是通过发射必要的释放/获取围栏来完成的。

实际 Microsoft的C#实现添加了另一个属性:读取将是新鲜的,写入将立即刷新到内存并使其他处理器可见。为了实现这一点,编译器发出OpCodes.Volatile,抖动选择它并告诉处理器将此变量存储在其寄存器中。

这意味着不能保证即时性的不同C#实现将是一个完全有效的实现。


记忆障碍

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

但总是这样吗?对Memory.Barrier的调用是否总是刷新存储缓冲区,将更新的值提取到处理器缓存中?

这里有一个提示:尝试将自己从诸如刷新存储缓冲区或直接从内存中读取等概念中抽象出来。记忆障碍(或全围栏)的概念与前两个概念无关。

内存屏障只有一个目的:确保栅栏下方的存储/加载指令不会移动到栅栏上方,反之亦然。 如果 C#' s Thread.MemoryBarrier恰好冲洗待处理的写入,您应该将其视为副作用,而不是主要意图。

现在,让我们谈谈。您发布的代码(在发布模式下编译并在没有调试器的情况下运行时阻塞)可以通过在while块内的任何位置引入完整范围来解决。为什么?让我们首先展开循环。以下是前几次迭代的结果:

if(complete) return;
toggle = !toggle;

if(complete) return;
toggle = !toggle;

if(complete) return;
toggle = !toggle;
...

由于complete未标记为volatile并且没有围栏,因此允许编译器 cpu移动complete字段的读取。 事实上,CLR's Memory Model (see rule 6)允许在合并相邻负载时删除负载(!)。所以,这可能发生:

if(complete) return;
toggle = !toggle;
toggle = !toggle;
toggle = !toggle;
...

请注意,这逻辑等同于提升循环的读出,这正是编译器可能做的事情。

通过在toggle = !toggle之前或之后引入全栅栏,您可以阻止编译器移动读取并将合并它们放在一起。

if(complete) return;
toggle = !toggle;
#FENCE
if(complete) return;
toggle = !toggle;
#FENCE
if(complete) return;
toggle = !toggle;
#FENCE
...

总之,解决这些问题的关键是确保以正确的顺序执行指令。它与其他处理器看到一个处理器的写入所需的时间无关。