在用于防止NullReferenceException的常见模式中,是否存在竞态条件?
在用于防止NullReferenceException的常见模式中,是否存在竞态条件?
我在这个问题上提问,并得到了这个有趣(并且有点令人不安)的答案。
根据丹尼尔在他的回答中所说(除非我理解错了),ECMA-335 CLI规范允许编译器从下面的DoCallback
方法中生成会抛出NullReferenceException
的代码。
class MyClass { private Action _Callback; public Action Callback { get { return _Callback; } set { _Callback = value; } } public void DoCallback() { Action local; local = Callback; if (local == null) local = new Action(() => { }); local(); } }
他说,为了确保不会抛出NullReferenceException
,在_Callback
上应该使用volatile
关键字,或者在local = Callback;
这行代码周围使用lock
。
有人能证实这一点吗?而且,如果是真的,Mono和.NET编译器在这个问题上是否有行为差异?
编辑
这是一个标准的链接。
更新
我认为这是规范中相关的部分(12.6.4):
CLI的符合实现可以使用任何保证在单个执行线程中,线程生成的副作用和异常按照CIL指定的顺序可见的技术来执行程序。对于这个目的,只有
volatile
操作(包括volatile
读取)构成可见的副作用。(注意,虽然只有volatile
操作构成可见的副作用,但volatile
操作也会影响非volatile
引用的可见性。)volatile
操作在§12.6.7中指定。对于线程由另一个线程注入的异常,不存在顺序保证(这样的异常有时被称为“异步异常”(例如,System.Threading.ThreadAbortException
)。[推理:一个优化编译器可以自由地重新排序副作用和同步异常,只要这种重新排序不改变任何可观察程序行为。结束推理][注意:CLI的实现允许使用优化编译器,例如,将CIL转换为本机机器代码,前提是编译器在每个单独的执行线程中保持相同的副作用和同步异常顺序。]
所以...我想知道这个声明是否允许编译器对Callback
属性(访问一个简单的字段)和local
变量进行优化,以产生以下具有相同行为的代码在单个执行线程中:
if (_Callback != null) _Callback(); else new Action(() => { })();
关于volatile
关键字的12.6.7部分似乎为希望避免优化的程序员提供了一个解决方案:
volatile
读取具有“获取语义”,意味着读取在CIL指令序列中的写入内存之前发生。volatile
写入具有“释放语义”,意味着写入在CIL指令序列中的读取内存之后发生。CLI的符合实现应保证volatile
操作的这种语义。这确保所有线程将按照它们被执行的顺序观察到其他线程执行的volatile
写入。但是,CLI的符合实现不需要提供volatile
写入的单个总排序,以便所有执行线程都能看到。将CIL转换为本机代码的优化编译器不得删除任何volatile
操作,也不得将多个volatile
操作合并为单个操作。
在上述代码中,第一个例子是线程安全的,不会抛出NullReferenceException异常。原因是在进行null检查/调用之前,将Callback复制到一个局部变量中。即使在null检查之后原始的Callback被设置为null,局部变量仍然有效。
然而,第二个例子是不同的:
在这个例子中,它查看一个公共变量Callback,当且仅当在if (null != Callback)
之后将Callback更改为null时,会在Callback();
处抛出异常。
第一个例子的线程安全性和不会抛出NullReferenceException的原因是,它将Callback复制到一个局部变量中,然后再进行null检查/调用。即使在null检查之后原始的Callback被设置为null,局部变量仍然有效。
解决该问题的方法是将Callback复制到一个局部变量中,然后再进行null检查/调用。这样可以确保即使在null检查之后,原始的Callback被更改为null,局部变量仍然有效,避免了NullReferenceException异常的抛出。
,为了避免NullReferenceException异常,可以将Callback复制到一个局部变量中,然后再进行null检查/调用。这个常见的模式在多线程环境下是线程安全的,可以确保即使在null检查之后,原始的Callback被更改为null,局部变量仍然有效。
这个问题的出现原因是,编译器可能会优化掉本地变量,导致代码与直接引用事件/回调的版本相同,仍然存在NullReferenceException的可能性。
解决方法是使用Interlocked.CompareExchange
public void DoCallback() { Action local = Interlocked.CompareExchange(ref _Callback, null, null); if (local != null) local(); }
然而,Richter承认微软的即时编译器(JIT)不会优化掉本地变量;尽管理论上可以改变这一点,但几乎肯定不会改变,因为这将导致太多应用程序出现问题。
这个问题在“Allowed C# Compiler optimization on local variables and refetching value from memory”中已经有详细的问答。请确保阅读xanatox的答案以及引用的“Understand the Impact of Low-Lock Techniques in Multithreaded Apps”文章。由于你特别提到了Mono,你应该注意引用的“[Mono-dev] Memory Model?”邮件列表消息。
Interlocked.Read(Int64)方法为什么没有其他类型的重载?这是一个疏忽还是有充分的理由?
Interlocked.Read是用来确保原子读取的,这是一个不同的目标。我怀疑你可以使用Volatile.Read