【问题标题】:.NET multithreading, volatile and memory model.NET 多线程、易失性和内存模型
【发布时间】:2010-04-15 11:51:20
【问题描述】:

假设我们有以下代码:

class Program
 {
    static volatile bool flag1;
    static volatile bool flag2;
    static volatile int val;
    static void Main(string[] args)
    {
      for (int i = 0; i < 10000 * 10000; i++)
      {
        if (i % 500000 == 0)
        {
          Console.WriteLine("{0:#,0}",i);
        }

        flag1 = false;
        flag2 = false;
        val = 0;

        Parallel.Invoke(A1, A2);

        if (val == 0)
          throw new Exception(string.Format("{0:#,0}: {1}, {2}", i, flag1, flag2));
      }
    }

    static void A1()
    {
      flag2 = true;
      if (flag1)
        val = 1;
    }
    static void A2()
    {
      flag1 = true;
      if (flag2)
        val = 2;
    }
  }
}

错了!主要问题是为什么......我想CPU重新排序操作 flag1 = true;和 if(flag2) 语句,但变量 flag1 和 flag2 标记为 volatile 字段...

【问题讨论】:

    标签: c# .net volatile memory-model


    【解决方案1】:

    在 .NET 内存模型中,运行时 (CLI) 将确保对 volatile 字段的更改不会缓存在寄存器中,因此任何线程上的更改都会立即在其他线程上看到 (NB 这在包括 Java 在内的其他内存模型中并非如此)。

    但这并没有说明跨多个可变或非可变字段的操作的相对顺序。

    要跨多个字段提供一致的顺序,您需要使用锁(或内存屏障,显式或隐式地使用包含内存屏障的方法之一)。

    更多详情见"Concurrent Programming on Windows", Joe Duffy, AW, 2008

    【讨论】:

    • 谢谢你的比赛!但是在“A Relaxed Model: ECMA”一节中关于.net内存模型的文章(“Understand the Impact of Low-Lock Techniques in Multithreaded Apps”msdn.microsoft.com/en-us/magazine/cc163715.aspx)中我们可以看到,1. 读写之前不能移动易失性读取。 2. volatile write 后读写不能移动。这是错的吗?我只是可以理解...可能在文章中意味着其他东西?...
    • +1 用于提及这本书 - 目前正在自己​​阅读(已经读到一半了 ;-)
    • @fedor-serdukov:我可能记错了——但我尝试比要求的安全得多,以避免意外行为(尤其是在进行一些维护更改之后)。另外,我认为您的代码不需要乱序才能引发异常。 A1A2 可以交错,因此在检查任一条件之前设置了两个标志,事实上,一旦线程池启动,我希望不时在多核系统上发生这种情况。跨度>
    • 这里的重点是关于内存屏障的。否则,没有什么可以阻止多核 cpu 在各自的缓存中保存陈旧数据。易失性字段只会阻止编译器重用获取的值,并且它还会影响重新排序过程,但是一个 cpu/核心可能会看到写入以与您预期不同的顺序退休,直到您添加显式内存屏障。请参阅有关内存屏障的*文章:en.wikipedia.org/wiki/Memory_barrier
    • 好的,很高兴知道。当我处理多线程时,我有点手足无措,所以我倾向于过度防御性地编程。
    【解决方案2】:

    ECMA-335 规范说:

    易失性读取具有“获取语义”,这意味着 读取是 保证在发生任何对内存的引用之前发生 在 CIL 指令序列中的 read 指令之后。 一个 volatile write 具有“释放语义”,这意味着 write 是 保证在写入之前的任何内存引用之后发生 CIL 指令序列中的指令。一个符合的 CLI 的实现应保证 volatile 的这种语义 操作。这确保了所有线程都会观察到 volatile 任何其他线程按执行顺序执行的写入。但是一个符合要求的实现是 不需要提供易失性写入的单一总排序 从所有执行线程中可以看出。

    让我们画出它的样子:

    所以,我们有两个半栅栏:一个用于 volatile 写入,一个用于 volatile 读取。而且它们并没有保护我们免受它们之间指令的重新排序。
    此外,即使在像 AMD64 (x86-64) it is allowed stores to be reordered after loads 这样严格的架构上。
    对于其他硬件内存模型较弱的架构,您可以观察到更多有趣的东西。在 ARM 上,如果以非易失性方式分配引用,则可以观察到部分构造的对象。

    要修复您的示例,您应该在赋值和 if 子句之间放置 Thread.MemoryBarrier() 调用:

    static void A1()
    {
      flag2 = true;
      Thread.MemoryBarrier();
      if (flag1)
        val = 1;
    }
    static void A2()
    {
      flag1 = true;
      Thread.MemoryBarrier();
      if (flag2)
        val = 2;
    }
    

    这将通过添加全栅栏来保护我们免于对这些说明进行重新排序。

    【讨论】: