25

MSDN Magazine 中的一篇文章讨论了 Read Introduction 的概念,并提供了一个可以被它破坏的代码示例。

public class ReadIntro {
  private Object _obj = new Object();
  void PrintObj() {
    Object obj = _obj;
    if (obj != null) {
      Console.WriteLine(obj.ToString()); // May throw a NullReferenceException
    }
  }
  void Uninitialize() {
    _obj = null;
  }
}

注意这个“可能会抛出 NullReferenceException”的评论——我从来不知道这是可能的。

所以我的问题是:我怎样才能防止阅读介绍?

我也非常感谢编译器决定引入读取的确切解释,因为文章不包括它。

4

3 回答 3

19

让我试着通过分解来澄清这个复杂的问题。

什么是“阅读介绍”?

“阅读介绍”是一种优化,其中代码:

public static Foo foo; // I can be changed on another thread!
void DoBar() {
  Foo fooLocal = foo;
  if (fooLocal != null) fooLocal.Bar();
}

通过消除局部变量进行优化。编译器可以推断,如果只有一个线程,那么foofooLocal是同一件事。编译器被明确允许进行任何在单个线程上不可见的优化,即使它在多线程场景中变得可见。因此允许编译器将其重写为:

void DoBar() {
  if (foo != null) foo.Bar();
}

现在有一个竞争条件。如果foo检查后从非空变为空,则可能foo第二次读取它,第二次读取它可能为空,然后会崩溃。从诊断崩溃转储的人的角度来看,这将是完全神秘的。

这真的会发生吗?

正如您链接到的文章所指出的:

请注意,您将无法在 x86-x64 上的 .NET Framework 4.5 中使用此代码示例重现 NullReferenceException。阅读介绍很难在 .NET Framework 4.5 中重现,但在某些特殊情况下确实会出现。

x86/x64 芯片具有“强”内存模型,并且 jit 编译器在这方面并不激进;他们不会做这个优化。

如果你碰巧在弱内存模型处理器上运行你的代码,比如 ARM 芯片,那么所有的赌注都没有了。

当您说“编译器”时,您指的是哪个编译器?

我的意思是 jit 编译器。C# 编译器从不以这种方式引入读取。(这是允许的,但实际上从不这样做。)

在没有内存屏障的线程之间共享内存不是一种不好的做法吗?

是的。这里应该做一些事情来引入内存屏障,因为的值foo可能已经是处理器缓存中的一个过时的缓存值。我更喜欢引入内存屏障是使用锁。您也可以制作 field volatile,或使用VolatileRead,或使用其中一种Interlocked方法。所有这些都引入了内存屏障。(volatile仅介绍“半栅栏”仅供参考。)

仅仅因为存在内存屏障并不一定意味着不执行读取引入优化。但是,对于影响包含内存屏障的代码的优化,抖动要小得多。

这种模式还有其他危险吗?

当然!假设没有阅读介绍。你仍然有一个竞争条件。如果另一个线程foo在检查后设置为 null,并且还修改了将要使用的全局状态Bar怎么办?现在您有两个线程,其中一个认为它foo不为 null 并且全局状态可以调用Bar,另一个线程认为相反,并且您正在运行Bar。这是灾难的秘诀。

那么这里的最佳做法是什么?

首先,不要跨线程共享内存。程序主线内部有两个控制线程的想法一开始就很疯狂。它本来就不应该是一件事。使用线程作为轻量级进程;给他们一个独立的任务来执行,它根本不与程序主线的内存交互,只是用它们来分出计算密集型的工作。

其次,如果您要跨线程共享内存,则使用锁来序列化对该内存的访问。如果没有争用锁,那么锁很便宜,如果你有争用,那么就解决这个问题。众所周知,低锁定和无锁定解决方案很难正确解决。

第三,如果您要跨线程共享内存,那么您调用的每个涉及共享内存的方法都必须在面对竞争条件时保持稳健,或者必须消除竞争。这是一个沉重的负担,这就是为什么你不应该首先去那里。

我的观点是:阅读介绍很可怕,但坦率地说,如果您编写的代码可以轻松地跨线程共享内存,那么它们是您最不担心的。首先要担心一千零一件事。

于 2013-02-11T16:18:04.777 回答
8

您不能真正“保护”免受阅读介绍,因为它是编译器优化(当然,使用没有优化的调试版本除外)。优化器将维护函数的单线程语义已经有很好的记录,正如文章指出的那样,这可能会在多线程情况下导致问题。

也就是说,我对他的例子感到困惑。在 Jeffrey Richter 的书 CLR via C#(在本例中为 v3)中,在事件部分他介绍了这种模式,并指出在上面的示例片段中,在 THEORY 中它不起作用。但是,在 .Net 存在的早期,这是 Microsoft 推荐的模式,因此与他交谈的 JIT 编译器人员表示,他们必须确保这种代码段永远不会中断。(尽管出于某种原因,他们总是可能认为值得打破 - 我想 Eric Lippert 可以阐明这一点)。

最后,与文章不同的是,Jeffrey 提供了在多线程情况下处理此问题的“正确”方法(我已使用您的示例代码修改了他的示例):

Object temp = Interlocked.CompareExchange(ref _obj, null, null);
if(temp != null)
{
    Console.WriteLine(temp.ToString());
}
于 2013-02-10T17:20:18.930 回答
1

我只是略读了这篇文章,但似乎作者正在寻找的是您需要将_obj成员声明为volatile.

于 2013-02-10T17:39:09.863 回答