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

Geb*_*ebb 24 .net c# multithreading

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;
  }
}
Run Code Online (Sandbox Code Playgroud)

注意这个"可能抛出NullReferenceException"的注释 - 我从来不知道这是可能的.

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

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

Eri*_*ert 18

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

什么是"阅读介绍"?

"阅读简介"是一个优化代码:

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

通过消除局部变量来优化.编译器可以有理由相信,如果只有一个线程,然后foofooLocal是一样的.显式允许编译器进行任何在单个线程上不可见的优化,即使它在多线程场景中变得可见.因此,允许编译器将其重写为:

void DoBar() {
  if (foo != null) foo.Bar();
}
Run Code Online (Sandbox Code Playgroud)

现在有一个竞争条件.如果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仅介绍"半围栏"FYI.)

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

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

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

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

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

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

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

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

  • *“我的意思是:阅读的介绍很吓人,但坦率地说,如果您编写的代码在线程间共享内存,那么它们是您最省心的事情。” * –让我来探讨一下这个话题,不同意。阅读关于字段的介绍并不是理智的优化,它会破坏单个线程执行的语义。如果将字段的值分配给局部变量,则线程**不得**在随后对该变量的读取中观察更改,直到下一次写入该变量为止。C#编译器无法做到这一点,JIT编译器不应该在当前(N)UMA体系结构中做到这一点。 (2认同)

Gje*_*ema 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());
}
Run Code Online (Sandbox Code Playgroud)