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

本文关键字:何防范 阅读 | 更新日期: 2023-09-27 17:58:29

MSDN杂志上的一篇文章讨论了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"的注释——我从来不知道这是可能的。

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

我也非常感谢编译器决定在什么时候引入reads的解释,因为这篇文章没有包括它。

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

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

什么是"阅读介绍"?

"阅读介绍"是一种优化,其中的代码:

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的调用是OK的,另一个线程则认为相反,并且您正在运行Bar。这会带来灾难。

那么这里的最佳实践是什么呢?

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

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

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

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

您不能真正"保护"阅读介绍,因为它是一种编译器优化(当然,使用没有优化的调试构建除外)。优化器将维护函数的单线程语义,这一点已经得到了很好的证明,正如文章所指出的,这可能会在多线程情况下导致问题。

也就是说,我被他的榜样弄糊涂了。在Jeffrey Richter的著作《CLR via C#》(本例中为v3)中,他在事件部分介绍了这种模式,并指出在上面的示例片段中,在理论中它是不起作用的。但是,在.Net存在的早期,这是微软推荐的模式,因此,与他交谈的JIT编译器人员表示,他们必须确保这种代码片段永远不会中断。(不过,他们总是有可能因为某种原因而认为这是值得打破的——我想埃里克·利珀特可以解释这一点)。

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

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

我只是浏览了这篇文章,但作者似乎想要的是,您需要将_obj成员声明为volatile