C# 中的空合并运算符 (??) 是否是安全的
本文关键字:安全 是否是 运算符 合并 | 更新日期: 2023-09-27 18:33:55
以下代码中是否存在可能导致NullReferenceException
的争用条件?
--或--
在 null合并运算符检查 null 值之后但在调用函数之前,是否可以将 Callback
变量设置为 null?
class MyClass {
public Action Callback { get; set; }
public void DoCallback() {
(Callback ?? new Action(() => { }))();
}
}
编辑
这是一个出于好奇而提出的问题。我通常不会以这种方式编码。
我不担心Callback
变量会过时。我担心Exception
被扔掉DoCallback
.
编辑#2
这是我的班级:
class MyClass {
Action Callback { get; set; }
public void DoCallbackCoalesce() {
(Callback ?? new Action(() => { }))();
}
public void DoCallbackIfElse() {
if (null != Callback) Callback();
else new Action(() => { })();
}
}
该方法DoCallbackIfElse
具有可能会引发NullReferenceException
的争用条件。DoCallbackCoalesce
方法是否具有相同的条件?
下面是 IL 输出:
MyClass.DoCallbackCoalesce:
IL_0000: ldarg.0
IL_0001: call UserQuery+MyClass.get_Callback
IL_0006: dup
IL_0007: brtrue.s IL_0027
IL_0009: pop
IL_000A: ldsfld UserQuery+MyClass.CS$<>9__CachedAnonymousMethodDelegate1
IL_000F: brtrue.s IL_0022
IL_0011: ldnull
IL_0012: ldftn UserQuery+MyClass.<DoCallbackCoalesce>b__0
IL_0018: newobj System.Action..ctor
IL_001D: stsfld UserQuery+MyClass.CS$<>9__CachedAnonymousMethodDelegate1
IL_0022: ldsfld UserQuery+MyClass.CS$<>9__CachedAnonymousMethodDelegate1
IL_0027: callvirt System.Action.Invoke
IL_002C: ret
MyClass.DoCallbackIfElse:
IL_0000: ldarg.0
IL_0001: call UserQuery+MyClass.get_Callback
IL_0006: brfalse.s IL_0014
IL_0008: ldarg.0
IL_0009: call UserQuery+MyClass.get_Callback
IL_000E: callvirt System.Action.Invoke
IL_0013: ret
IL_0014: ldsfld UserQuery+MyClass.CS$<>9__CachedAnonymousMethodDelegate3
IL_0019: brtrue.s IL_002C
IL_001B: ldnull
IL_001C: ldftn UserQuery+MyClass.<DoCallbackIfElse>b__2
IL_0022: newobj System.Action..ctor
IL_0027: stsfld UserQuery+MyClass.CS$<>9__CachedAnonymousMethodDelegate3
IL_002C: ldsfld UserQuery+MyClass.CS$<>9__CachedAnonymousMethodDelegate3
IL_0031: callvirt System.Action.Invoke
IL_0036: ret
在我看来,call UserQuery+MyClass.get_Callback
在使用??
运算符时只被调用一次,但在使用if...else
时被调用两次。我做错了什么吗?
public void DoCallback() {
(Callback ?? new Action(() => { }))();
}
保证等效于:
public void DoCallback() {
Action local = Callback;
if (local == null)
local = new Action(() => { });
local();
}
这是否会导致 NullReferenceException 取决于内存模型。Microsoft .NET 框架内存模型被记录为永远不会引入额外的读取,因此针对null
测试的值与将调用的值相同,并且代码是安全的。但是,ECMA-335 CLI 内存模型不太严格,允许运行时消除局部变量并访问 Callback
字段两次(我假设它是一个字段或访问简单字段的属性(。
您应该将Callback
字段标记为volatile
,以确保使用正确的内存屏障 - 这使得代码即使在弱 ECMA-335 模型中也是安全的。
如果它不是性能关键型代码,只需使用锁(将 Callback 读取到锁内的局部变量就足够了,在调用委托时不需要持有锁( - 任何其他内容都需要有关内存模型的详细了解才能知道它是否安全,并且确切的详细信息可能会在未来的 .NET 版本中更改(与 Java 不同, Microsoft尚未完全指定 .NET 内存模型(。
更新
如果我们排除了在您的编辑澄清时获取过时值的问题,那么 null 合并选项将始终可靠地工作(即使无法确定确切的行为(。但是,替代版本(如果不是null
则调用它(不会,并且冒着NullReferenceException
的风险。
零合并运算符导致Callback
仅计算一次。委托是不可变的:
合并操作(如"合并"和"删除"(不会改变 现有代表。相反,此类操作返回一个新委托 包含操作结果、未更改的委托或 零。当 操作是不引用至少一个方法的委托。一个 合并操作在请求时返回未更改的委托 操作无效。
此外,委托是引用类型,因此简单的读取或写入可以保证是原子的(C# 语言规范,第 5.5 段(:
以下数据类型的读取和写入是原子的:布尔值、字符、 字节、字节、短、短、ushort、uint、int、浮点数和引用类型。
这确认了 null 合并运算符不可能读取无效值,并且由于该值仅在读取一次,因此不可能出错。
另一方面,条件版本读取委托一次,然后调用第二次独立读取的结果。如果第一次读取返回非 null 值,但在第二次读取发生之前,委托(原子上,但这没有帮助(被 null
覆盖,则编译器最终会在 null 引用上调用 Invoke
,因此将引发异常。
所有这些都反映在这两种方法的 IL 中。
原始答案
在没有明确的相反文档的情况下,是的,这里有一个竞争条件,因为在更简单的情况下也会有
public int x = 1;
int y = x == 1 ? 1 : 0;
原理是一样的:首先计算条件,然后生成表达式的结果(然后使用(。如果发生使条件发生变化的事情,为时已晚。
我在此代码中看不到竞争条件。存在一些潜在问题:
-
Callback += someMethod;
不是原子的。简单的分配是。 -
DoCallback
可以调用过时的值,但它将是一致的。 - 过时值问题只能通过在整个回调期间保持锁来避免。但这是一种非常危险的模式,会导致死锁。
更清晰的DoCallback
写法是:
public void DoCallback()
{
var callback = Callback;//Copying to local variable is necessary
if(callback != null)
callback();
}
它也比原始代码快一点,因为它不会创建和调用无操作委托,如果null
Callback
。
您可能希望用事件替换该属性,以获得原子+=
并-=
:
public event Action Callback;
在属性上调用+=
时,发生的情况是Callback = Callback + someMethod
。这不是原子的,因为Callback
可能会在读取和写入之间更改。
在类似 event 的字段上调用 +=
时,发生的情况是对事件的 Subscribe
方法的调用。对于类似字段的事件,事件订阅保证是原子的。在实践中,它使用一些Interlocked
技术来做到这一点。
使用空合并运算符??
在这里并不重要,而且它本质上也不是线程安全的。重要的是你只读Callback
一次。还有其他类似的模式涉及??
,这些模式在任何方面都不是线程安全的。
我们假设它是安全的,因为它是一行?通常情况并非如此。您确实应该在访问任何共享内存之前使用 lock 语句。