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时被调用两次。我做错了什么吗?

C# 中的空合并运算符 (??) 是否是安全的

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 语句。