阅读C#中的介绍 – 如何防范它?
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”的注释 – 我从来不知道这是可能的。
所以我的问题是:我如何防止阅读介绍?
我也非常感谢编译器决定引入读取时的确切解释,因为该文章不包括它。
让我试着通过分解来澄清这个复杂的问题。
什么是“阅读介绍”?
“阅读介绍”是一种优化代码:
public static Foo foo; // I can be changed on another thread! void DoBar() { Foo fooLocal = foo; if (fooLocal != null) fooLocal.Bar(); }
通过消除局部变量来优化。 编译器可以fooLocal
, 如果只有一个线程,则foo
和fooLocal
是相同的。 显式允许编译器进行在单个线程上不可见的任何优化,即使它在multithreading场景中变得可见。 因此,允许编译器将其重写为:
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
只引入了“半围栏”FYI。)
仅仅因为存在内存障碍并不一定意味着不执行读取引入优化。 但是,对于追求影响包含内存屏障的代码的优化,抖动远没有那么积极。
这种模式还有其他危险吗?
当然! 我们假设没有阅读介绍。 你还有竞争条件 。 如果另一个线程在检查后将foo
设置为null, 并且还修改了Bar
将要使用的全局状态,该怎么办? 现在你有两个线程,其中一个认为foo
不是null,并且全局状态对于调用Bar
是正常的,而另一个线程认为相反,并且你正在运行Bar
。 这是灾难的秘诀。
那么这里最好的做法是什么?
首先, 不要跨线程共享内存 。 整个想法,你的程序的主线内有两个控制线程,开始时是疯狂的。 它本来就不应该是一件事。 使用线程作为轻量级进程; 给他们一个独立的任务来执行,根本不与程序主线的内存交互,只需使用它们来解决计算密集型工作。
其次,如果要跨线程共享内存,则使用锁定序列化对该内存的访问 。 如果它们没有争用,那么锁是便宜的,如果你有争用,那么解决这个问题。 众所周知,低锁和无锁解决方案很难做到。
第三,如果你要在线程之间共享内存,那么你所调用的涉及共享内存的每个方法必须在竞争条件下都是健壮的,或者必须消除竞争 。 这是一个沉重的负担,这就是为什么你不应该首先去那里。
我的观点是:阅读介绍是可怕的,但坦率地说,如果您编写的代码巧妙地在线程之间共享内存,那么它们是您最不担心的。 首先要担心一千零一个其他事情。
你不能真正“保护”读取引入,因为它是一个编译器优化(除了使用Debug构建,当然没有优化)。 值得记录的是,优化器将维护函数的单线程语义,正如本文所述,这可能会导致multithreading情况下出现问题。
那就是说,我对他的榜样感到困惑。 在Jeffrey Richter的书籍CLR via C#(本案例中为v3)中,他在事件部分介绍了这种模式,并注意到在上面的示例代码段中,在理论中它不起作用。 但是,这是微软早期在.Net存在时所推荐的模式,因此他所采访的JIT编译人员说,他们必须确保那种片段永远不会中断。 (总有可能他们可能会因为某种原因决定它值得打破 – 我想Eric Lippert可以说明这一点)。
最后,与文章不同,Jeffrey提供了在multithreading情况下处理这种情况的“正确”方法(我用示例代码修改了他的示例):
Object temp = Interlocked.CompareExchange(ref _obj, null, null); if(temp != null) { Console.WriteLine(temp.ToString()); }
我只浏览了这篇文章,但似乎作者正在寻找的是你需要将_obj
成员声明为volatile
。