51

在“C# 4 in a Nutshell”中,作者展示了这个类有时可以写 0 而没有MemoryBarrier,尽管我无法在我的 Core2Duo 中重现:

public class Foo
{
    int _answer;
    bool _complete;
    public void A()
    {
        _answer = 123;
        //Thread.MemoryBarrier();    // Barrier 1
        _complete = true;
        //Thread.MemoryBarrier();    // Barrier 2
    }
    public void B()
    {
        //Thread.MemoryBarrier();    // Barrier 3
        if (_complete)
        {
            //Thread.MemoryBarrier();       // Barrier 4
            Console.WriteLine(_answer);
        }
    }
}

private static void ThreadInverteOrdemComandos()
{
    Foo obj = new Foo();

    Task.Factory.StartNew(obj.A);
    Task.Factory.StartNew(obj.B);

    Thread.Sleep(10);
}

这种需求对我来说似乎很疯狂。我如何识别所有可能发生这种情况的情况?我认为如果处理器改变操作顺序,它需要保证行为不会改变。

您是否费心使用障碍?

4

6 回答 6

74

您将很难重现此错误。事实上,我什至会说您永远无法使用 .NET Framework 重现它。原因是因为微软的实现使用强大的内存模型进行写入。这意味着写入被视为易失性。易失性写入具有锁定释放语义,这意味着必须在当前写入之前提交所有先前的写入。

但是,ECMA 规范的内存模型较弱。因此,从理论上讲,Mono 甚至 .NET Framework 的未来版本可能会开始出现错误行为。

所以我要说的是,移除障碍#1 和#2 不太可能对程序的行为产生任何影响。当然,这不是保证,而是仅基于当前 CLR 实现的观察结果。

消除障碍#3 和#4 肯定会产生影响。这实际上很容易重现。好吧,不是这个例子本身,而是下面的代码是比较知名的演示之一。它必须使用 Release 版本编译并在调试器之外运行。错误是程序没有结束。Thread.MemoryBarrier您可以通过在循环内调用while或标记stop为来修复错误volatile

class Program
{
    static bool stop = false;

    public static void Main(string[] args)
    {
        var t = new Thread(() =>
        {
            Console.WriteLine("thread begin");
            bool toggle = false;
            while (!stop)
            {
                toggle = !toggle;
            }
            Console.WriteLine("thread end");
        });
        t.Start();
        Thread.Sleep(1000);
        stop = true;
        Console.WriteLine("stop = true");
        Console.WriteLine("waiting...");
        t.Join();
    }
}

一些线程错误难以重现的原因是因为您用来模拟线程交错的相同策略实际上可以修复错误。Thread.Sleep是最显着的例子,因为它会产生内存屏障。您可以通过在循环中调用while并观察错误消失来验证这一点。

您可以在此处查看我的答案,以对您引用的书中的示例进行另一次分析。

于 2010-08-24T13:25:51.050 回答
10

第一个任务在第二个任务开始运行时完成的可能性非常好。如果两个线程同时运行该代码并且没有干预缓存同步操作,您只能观察到这种行为。您的代码中有一个,StartNew() 方法将在线程池管理器中的某处锁定。

让两个线程同时运行此代码非常困难。此代码在几纳秒内完成。您将不得不尝试数十亿次并引入可变延迟才能获得任何机会。当然,这并没有太多的意义,真正的问题是当你期望它随机发生时。

远离这个,使用 lock 语句来编写理智的多线程代码。

于 2010-08-24T12:48:33.813 回答
2

如果你使用volatileand lock,内存屏障是内置的。但是,是的,否则你确实需要它。话虽如此,我怀疑您需要的数量是示例显示的一半。

于 2010-08-24T12:28:16.440 回答
2

重现多线程错误非常困难 - 通常您必须多次(数千次)运行测试代码并进行一些自动检查以标记是否发生错误。您可能会尝试在某些行之间添加一个简短的 Thread.Sleep(10) ,但它并不总是保证您会遇到与没有它时相同的问题。

内存屏障是为需要对其多线程代码进行真正核心低级性能优化的人引入的。在大多数情况下,使用其他同步原语(即 volatile 或 lock)会更好。

于 2010-08-24T12:31:40.573 回答
2

我将引用一篇关于多线程的精彩文章:

考虑以下示例:

class Foo
{
  int _answer;
  bool _complete;

  void A()
  {
    _answer = 123;
    _complete = true;
  }

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

如果方法 A 和 B 在不同的线程上同时运行,B 是否有可能写入“0”?答案是肯定的——原因如下:

编译器、CLR 或 CPU 可能会重新排序程序指令以提高效率。编译器、CLR 或 CPU 可能会引入缓存优化,以便对变量的赋值不会立即对其他线程可见。C# 和运行时非常小心,以确保此类优化不会破坏普通的单线程代码 - 或正确使用锁的多线程代码。在这些场景之外,您必须通过创建内存屏障(也称为内存栅栏)来明确地破坏这些优化,以限制指令重新排序和读/写缓存的影响。

全围栏

最简单的内存屏障是完整的内存屏障(全栅栏),它可以防止任何类型的指令重新排序或围绕该栅栏进行缓存。调用 Thread.MemoryBarrier 生成一个完整的栅栏;我们可以通过应用四个完整的栅栏来修复我们的示例,如下所示:

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);
    }
  }
}

所有背后的理论Thread.MemoryBarrier以及为什么我们需要在非阻塞场景中使用它来使代码安全和健壮都在这里很好地描述了:http ://www.albahari.com/threading/part4.aspx

于 2017-09-21T05:19:48.913 回答
1

如果您曾经接触过来自两个不同线程的数据,则可能会发生这种情况。这是处理器用来提高速度的技巧之一——你可以构建没有这样做的处理器,但它们会慢得多,所以没有人再这样做了。您可能应该阅读Hennessey 和 Patterson之类的东西来识别所有各种类型的竞争条件。

我总是使用某种更高级别的工具,例如监视器或锁,但在内部它们正在做类似的事情或使用障碍实现。

于 2010-08-24T12:34:41.190 回答