【问题标题】:Read Introduction in C# - how to protect against it?阅读 C# 中的介绍 - 如何防止它?
【发布时间】:2013-02-10 16:31:09
【问题描述】:

MSDN 杂志中的一个article 讨论了阅读简介的概念,并提供了一个可以被它破坏的代码示例。

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”的评论 - 我从来不知道这是可能的。

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

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

【问题讨论】:

  • 一个改变代码语义的 JIT 编译器,我很担心。也许这是 .NET 4.5 之前的 JIT 错误。
  • 是的,这看起来绝对像 JIT 错误。事实上,无论如何我都会把它归类为错误。这篇文章有点没用——它提到了这个概念,但并没有真正说明它是如何发生的以及如何防范它。
  • @svick:即使使用多线程,这也不应该发生。没有其他线程可以影响局部变量。
  • @Konrad - _obj 变量是一个字段,而不是局部变量,这就是被攻击的那个。对事件的支持字段的访问受到 .NET
  • @KonradRudolph Specifically, all of Microsoft’s JIT compilers respect the invariant of not introducing new reads to heap memory and therefore, caching a reference in a local variable ensures that the heap reference is accessed only once. This is not documented and, in theory, it could change, which is why you should use the fourth version. But in reality, Microsoft’s JIT compiler would never embrace a change that would break this pattern because too many applications would break. This was actually told to me by a member of Microsoft’s JIT compiler team. CLR 通过 C# 第三版第 265 页。

标签: c# .net multithreading


【解决方案1】:

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

什么是“阅读介绍”?

“阅读介绍”是代码的优化:

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 的值可能已经是处理器缓存中的一个过时缓存值。我更喜欢引入内存屏障是使用锁。您还可以创建字段volatile,或使用VolatileRead,或使用Interlocked 方法之一。所有这些都引入了内存屏障。 (volatile 只介绍了一个“半栅栏”仅供参考。)

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

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

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

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

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

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

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

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

【讨论】:

  • 感谢您的出色回答!我仍然会争辩说,在某些情况下,阅读介绍是唯一的问题。例如,参见 Stephen Toub 的合作暂停 implementation 中的方法 WaitWhilePausedAsync
  • @Gebb:你的观点很好。这说明将线程原语的生产留给 Stephen Toub 和 Joe Duffy 等人是一个很好的理由!不要尝试编写自己的原语;使用了解这些内容的人编写的类型,如 Lazy<T>Task<T> 来提高抽象级别。
  • “我的意思是:阅读介绍很可怕,但坦率地说,如果您编写的代码可以轻松地跨线程共享内存,那么它们是您最不担心的。” -- 请允许我挖掘这个话题并且不同意。阅读关于字段的介绍不是一个理智的优化,它可以打破单线程执行的语义。如果将字段的值分配给局部变量,线程不得观察对变量的后续读取的变化,直到下一次写入变量(如果有)。 C# 编译器无法做到这一点,JIT 编译器不应该在当前 (N)UMA 架构下做到这一点。
  • @acelent 我不明白为什么引入的读取会破坏单线程代码。线程可以观察变量更改的唯一方法是另一个线程是否引入了这些更改。根据定义,这不再是单线程代码。这是一个理智的优化,假设你有对抗它的工具,比如 Volatile.Read。不过,这个答案对这些工具提出了质疑。 那是疯狂的 IMO。
  • @relatively_random,问题是,除非该字段位于寄存器中,或者堆栈 and 不共享,也没有机会共享(即对象是从来没有参数),那么编译器就不能假设它不是共享的。例如,任何东西都可以触及堆:GC 线程、调试器、分析器、CLR 运行时主机、低级堆迭代器等。我也明白你在说什么,但采用单线程范式忽略潜在数据竞争的优化绝不是您想要的,尤其是当重复读取很少(如果有的话)比其他任何东西都快时。
【解决方案2】:

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

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

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

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

【讨论】:

  • 直截了当:在这里使用CompareExchange 的唯一原因是在_obj 的读取周围设置一个内存栅栏,以防止编译器进行恶作剧,对吧?
  • 正确。理想情况下,您会使用 Interlocked.VolatileRead,但它没有通用版本,因此 CompareExchange 是更好的解决方案。这也是一个比将 _obj 声明为 volatile 更好的解决方案,因为出于性能原因,您应该避免 volatile,除非您需要在使用它的任何地方都“安全”,这很少见。..
  • 为这个假设的案例实施“正确”的方式感觉有点过分了。如果我遇到执行此“阅读介绍”的 JIT 并且代码中断.. 它会比我的代码多很多,我会擦掉双手然后走开..
  • 2022 年是否会使用 Volatile.Read 而不是 Interlocked.CompareExchange?
【解决方案3】:

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

【讨论】:

  • 我的第一个想法也是,private volatile Object _obj 肯定不会失败。但也许我的直觉是错误的。
  • 如果您对此感到担心,请查看以下问题:stackoverflow.com/questions/394898/…
  • @erikkallen:Stephen Toub 似乎证实了你的假设。至少我从这个conversation和他那里得到了这样的印象。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2010-10-20
  • 2015-09-01
  • 2017-11-10
  • 1970-01-01
相关资源
最近更新 更多