null 条件运算符与委托和常规对象的功能是否相同?

Does the null-conditional operator function the same with delegates and regular objects?

Reference

我目前正在处理一些线程敏感代码。

在我的代码中,我有一个由两个不同线程操作的对象列表。一个线程可以将对象添加到此列表,而另一个线程可以将其设置为空。

在上面的参考资料中,它特别提到代表:

myDelegate?.Invoke()

相当于:

var handler = myDelegate;
if (handler != null)
{
    handler(…);
}

我的问题是,这种行为是否与 List<> 相同?例如:

是:

var myList = new List<object>();    
myList?.Add(new object());

保证等同于:

var myList = new List<object>();

var tempList = myList;
if (tempList != null)
{
    tempList.Add(new object());
}

?


编辑:

请注意(代表的工作方式)之间存在差异:

var myList = new List<int>();
var tempList = myList;
if (tempList != null)
{
    myList = null; // another thread sets myList to null here
    tempList.Add(1); // doesn't crash
}

var myList = new List<int>();
if (myList != null)
{
    myList = null; // another thread sets myList to null here
    myList.Add(1); // crashes
}

答案是肯定的。

var myList = new List<object>();    
myList?.Add(new object());

编译为以下 (as seen here)

List<object> list = new List<object>();
if (list != null)
{
    list.Add(new object());
}

是的,它们是一样的。您还可以在下面看到由 Ildasm:

生成的底层 IL
public void M()
{
    var myList = new List<object>();
    myList?.Add(new object());
}

这将是:

.method public hidebysig instance void  M() cil managed
{
  // Code size       25 (0x19)
  .maxstack  2
  .locals init (class [System.Collections]System.Collections.Generic.List`1<object> V_0)
  IL_0000:  nop
  IL_0001:  newobj     instance void class [System.Collections]System.Collections.Generic.List`1<object>::.ctor()
  IL_0006:  stloc.0
  IL_0007:  ldloc.0
  IL_0008:  brtrue.s   IL_000c
  IL_000a:  br.s       IL_0018
  IL_000c:  ldloc.0
  IL_000d:  newobj     instance void [System.Runtime]System.Object::.ctor()
  IL_0012:  call       instance void class [System.Collections]System.Collections.Generic.List`1<object>::Add(!0)
  IL_0017:  nop
  IL_0018:  ret
} // end of method C::M

并且:

public void M2()
{
    List<object> list = new List<object>();
    if (list != null)
    {
        list.Add(new object());
    }
}

这将是:

.method public hidebysig instance void  M2() cil managed
{
  // Code size       30 (0x1e)
  .maxstack  2
  .locals init (class [System.Collections]System.Collections.Generic.List`1<object> V_0,
           bool V_1)
  IL_0000:  nop
  IL_0001:  newobj     instance void class [System.Collections]System.Collections.Generic.List`1<object>::.ctor()
  IL_0006:  stloc.0
  IL_0007:  ldloc.0
  IL_0008:  ldnull
  IL_0009:  cgt.un
  IL_000b:  stloc.1
  IL_000c:  ldloc.1
  IL_000d:  brfalse.s  IL_001d
  IL_000f:  nop
  IL_0010:  ldloc.0
  IL_0011:  newobj     instance void [System.Runtime]System.Object::.ctor()
  IL_0016:  callvirt   instance void class [System.Collections]System.Collections.Generic.List`1<object>::Add(!0)
  IL_001b:  nop
  IL_001c:  nop
  IL_001d:  ret
} // end of method C::M2

this answer, Eric Lippert confirms that a temporary variable is used in all cases, which will prevent the "?." operator from causing a NullReferenceException or accessing two different objects. There are, however, many other factors which can make this code not thread safe, see .

UPD:解决未创建临时变量的声明:无需为局部变量引入临时变量。但是,如果您尝试访问可能会被修改的内容,则会创建一个变量。使用相同的 SharpLab 并稍微修改代码,我们得到:

using System;
using System.Collections.Generic;

public class C {
    public List<Object> mList;

    public void M() {
        this.mList?.Add(new object());
    }
}

变成

public class C
{
    public List<object> mList;

    public void M()
    {
        List<object> list = mList;
        if (list != null)
        {
            list.Add(new object());
        }
    }
}

这是一个需要仔细分析的微妙问题。

首先,问题中提出的代码毫无意义,因为它对保证不为空的局部变量进行空检查。据推测,真正的代码是从一个 non-local 变量中读取的,该变量可能为 null 也可能不为 null,并且可能在多个线程上被更改。

这是一个非常危险的位置,我强烈建议您不要追求这个架构决策。找到另一种在工作人员之间共享内存的方法。

解决您的问题:

问题的第一个版本是:?. 运算符是否与您引入临时变量的版本具有相同的语义?

是的,确实如此。但我们还没有完成。

您没有问的第二个问题是:C# 编译器、抖动或 CPU 是否可能导致带有临时文件的版本引入额外的读取?也就是说,我们是否保证

var tempList = someListThatCouldBeNull;
if (tempList != null)
    tempList.Add(new object());

永远不会执行,就好像你写

var tempList = someListThatCouldBeNull;
if (tempList != null) 
    someListThatCouldBeNull.Add(new object());

"introduced reads"的问题在C#中比较复杂,但是简而言之就是:一般来说,你可以假设不会以这种方式引入读取。

我们好吗?当然不是。该代码完全不是线程安全的,因为 Add 可能会在多个线程上调用,这是未定义的行为!

假设我们以某种方式修复它。现在情况好了吗?

没有。我们仍然不应该对这段代码有信心。

为什么不呢?

楼主没有显示任何机制可以保证 someListThatCouldBeNull 的 up-to-date 值正在被读取。 它是在锁下访问的吗?它易挥发吗?是否引入了内存屏障? C# 规范非常清楚,如果不涉及锁或易失性等特殊效果,读取可能会在时间上任意向后移动。您可能正在读取缓存值。

同样,我们还没有看到写的代码;这些写入可以任意移动到未来。读取移至过去或写入移至未来的任何组合都可能导致读取 "stale" 值。

现在假设我们解决了那个问题。这能解决整个问题吗?当然不是。 我们不知道涉及多少个线程,或者这些线程中是否有任何线程也在读取相关变量,以及这些读取是否有任何假设的顺序约束。 C# 要求对所有读写顺序有一个全局一致的视图!两个线程可能 不同意 读取和写入 volatile 变量的顺序。也就是说,如果内存模型允许两种可能的观察顺序,则一个线程观察一个,另一个线程观察另一个是合法的。 如果您的程序逻辑隐含地依赖于单个观察到的读写顺序,那么您的程序是错误的

现在您可能明白为什么我强烈建议不要以这种方式共享内存。这是一个充满微妙错误的雷区。

那你该怎么办?

  • 如果可以:停止使用线程。寻找一种不同的方式来处理异步。
  • 如果您不能这样做,使用线程作为解决问题的工作者,然后返回池。让两个线程同时对同一个内存进行锤击很难做到正确。让一个线程停止并计算一些东西,return 完成后的值更容易正确,你可以...
  • ...使用任务并行库或其他旨在正确管理 inter-thread 通信的工具。
  • 如果您做不到,尝试变异尽可能少的变量。不要将变量设置为空。如果您要填写列表,请使用线程安全列表类型 初始化列表一次,然后只从该变量中读取。让列表对象为您处理线程问题。