13

以下代码中是否存在可能导致NullReferenceException?

- 或者 -

Callback在 null 合并运算符检查 null 值之后但在调用函数之前,是否可以将变量设置为 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. 难道我做错了什么?

4

4 回答 4

11

更新

如果我们在您的编辑澄清时排除了获取陈旧值的问题,那么 null-coalescing 选项将始终可靠地工作(即使无法确定确切的行为)。然而,替代版本(如果没有null,则调用它)不会,并且冒着NullReferenceException.

null-coalescing 运算符导致Callback只被评估一次。代表是不可变的

组合操作(例如 Combine 和 Remove)不会改变现有的委托。相反,此类操作会返回一个包含操作结果的新委托、未更改的委托或 null。当操作的结果是不引用至少一个方法的委托时,组合操作返回 null。当请求的操作无效时,组合操作返回未更改的委托。

此外,委托是引用类型,因此简单的读取或写入保证是原子的(C# 语言规范,第 5.5 段):

以下数据类型的读写是原子的:bool、char、byte、sbyte、short、ushort、uint、int、float 和引用类型。

这证实了 null-coalescing 运算符无法读取无效值,并且因为只有在没有出错的可能性时才会读取该值。

另一方面,条件版本读取委托一次,然后调用第二次独立读取的结果。如果第一次读取返回非空值,但委托在第二次读取发生之前(原子地,但这无济于事)被覆盖null,编译器最终会调用Invoke空引用,因此将引发异常。

所有这些都反映在这两种方法的 IL 中。

原始答案

相反,如果没有明确的文档,那么是的,这里有一个竞争条件,因为在更简单的情况下也会有

public int x = 1;

int y = x == 1 ? 1 : 0;

原理是一样的:首先计算条件,然后产生表达式的结果(然后使用)。如果发生了使情况发生变化的事情,那就太晚了。

于 2012-05-12T17:17:01.560 回答
11
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 模型中也是安全的。

如果它不是性能关键代码,只需使用锁(将回调读入锁内的局部变量就足够了,您不需要在调用委托时持有锁) - 其他任何事情都需要有关内存模型的详细知识才能知道是否它是安全的,并且确切的细节可能会在未来的 .NET 版本中发生变化(与 Java 不同,Microsoft 尚未完全指定 .NET 内存模型)。

于 2012-05-12T20:29:25.733 回答
3

我在这段代码中没有看到竞争条件。有几个潜在的问题:

  • Callback += someMethod;不是原子的。简单的分配是。
  • DoCallback可以调用一个陈旧的值,但它会是一致的。
  • 只有在回调的整个过程中保持锁定才能避免陈旧值问题。但这是一种非常危险的模式,会导致死锁。

更清晰的写作DoCallback方式是:

public void DoCallback()
{
   var callback = Callback;//Copying to local variable is necessary
   if(callback != null)
     callback();
}

它也比您的原始代码快一点,因为它不会创建和调用无操作委托,如果Callbacknull.


您可能希望用事件替换属性,以获得原子+=-=

 public event Action Callback;

调用+=属性时,会发生Callback = Callback + someMethod. 这不是原子的,因为Callback可能会在读取和写入之间更改。

当调用+=像事件这样的字段时,会发生Subscribe对事件方法的调用。对于类似字段的事件,事件订阅保证是原子的。在实践中,它使用一些Interlocked技术来做到这一点。


空合并运算符的使用在??这里并不重要,而且它本身也不是线程安全的。重要的是您Callback只阅读一次。还有其他类似的模式??以任何方式都不是线程安全的。

于 2012-05-12T17:22:47.997 回答
0

我们假设它是安全的,因为它是一条线?通常情况并非如此。您确实应该在访问任何共享内存之前使用 lock 语句。

于 2012-05-12T17:17:23.237 回答