阅读C#简介 - 如何防范它?

时间:2013-02-10 16:31:09

标签: c# .net multithreading

MSDN杂志中的article讨论了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"评论 - 我从来不知道这是可能的。

所以我的问题是:如何防止阅读介绍?

我还非常感谢编译器决定引入读取的确切解释,因为文章没有包含它。

3 个答案:

答案 0 :(得分:18)

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

  

什么是“阅读介绍”?

“阅读简介”是一种优化代码:

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在检查后从非null变为null,则可能第二次读取foo,第二次可能为null,然后崩溃。从诊断崩溃倾倒者的角度来看,这将是完全神秘的。

  

这真的会发生吗?

正如你所链接的文章所说:

  

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

x86 / x64芯片具有“强大”的内存模型,而jit编译器在这方面并不具有攻击性;他们不会做这个优化。

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

  

当你说“编译器”你指的是哪个编译器?

我指的是jit编译器。 C#编译器从不以这种方式引入读取。 (这是允许的,但在实践中它永远不会。)

  

在没有内存障碍的线程之间共享内存不是一个坏习惯吗?

是。这里应该做一些事情来引入内存障碍,因为 foo的值可能已经是处理器缓存中陈旧的缓存值。我对引入内存屏障的偏好是使用锁。您也可以创建字段volatile,或使用VolatileRead,或使用Interlocked方法之一。所有这些都引入了记忆障碍。 (volatile仅引入了“半围栏”,仅供参考。)

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

  

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

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

  

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

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

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

第三,如果你要跨越线程共享内存,那么你调用的每一个涉及共享内存的方法必须要么在竞争条件下都是健壮的,要么必须消除种族。这是一个沉重的负担,这就是为什么你不应该首先去那里。

我的观点是:阅读介绍是可怕的但坦率地说,如果您编写的代码巧妙地在线程之间共享内存,那么它们是您最不担心的。首先要担心一千零一个其他事情。

答案 1 :(得分:7)

你无法真正“保护”免于阅读介绍,因为它是一个编译器优化(除了使用Debug构建,当然没有优化)。值得记录的是,优化器将维护函数的单线程语义,正如本文所述,这可能会导致多线程情况下出现问题。

那就是说,我对他的榜样感到困惑。在Jeffrey Richter的书CLR via C#(本案例中为v3)中,他在事件部分介绍了这种模式,并注意到在上面的示例代码段中,在理论中它不起作用。但是,这是微软早期在.Net存在时推荐的模式,因此他所采访的JIT编译人员表示,他们必须确保这种代码片段永远不会中断。 (总有可能他们可能会因某种原因决定它值得打破 - 我想Eric Lippert可以说明这一点。)

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

Object temp = Interlocked.CompareExchange(ref _obj, null, null);
if(temp != null)
{
    Console.WriteLine(temp.ToString());
}

答案 2 :(得分:1)

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