9

我正在使用 C# 4.0。Visual Studio 中的“优化代码”已打开。

在一个类中考虑以下代码:

Dictionary<int, int> dictionary = new Dictionary<int, int>();

public void IncrementDictionary(int key) {
    if (!dictionary.ContainsKey(key)) {
        dictionary[key] = 1;
    } else {
        dictionary[key]++;
    }
}

在这里,调用IncrementDictionary做两件事之一:

  • 如果 不存在key值,则创建一个值并将其初始化为 1。
  • 如果存在值,则该值增加 1。

现在看看当我使用 ILSpy 反编译结果时会发生什么:

Dictionary<int, int> dictionary = new Dictionary<int, int>();

public void IncrementDictionary(int key) {
    if (!dictionary.ContainsKey(key)) {
        dictionary[key] = 1;
        return;
    }
    Dictionary<int, int> dictionary2;
    (dictionary2 = dictionary)[key] = dictionary2[key] + 1;
}

注意:在使用这个的实际生产代码中,优化器/编译器还在最后一行创建:int key2 = key;和使用key2

好的,var已替换为Dictionary<int, int>,这是预期的。并且该if语句被简化为添加 areturn而不是使用else.

但是,为什么要创建对原始字典的新引用呢?

4

2 回答 2

11

我猜这可能是为了避免竞争条件,如果你有:

dictionary[i] = dictionary[i] + 1

它不是原子的。在您获得值并递增dictionary,分配给的对象可能会发生变化。

想象一下这段代码:

public Dictionary<int, int> dictionary = new Dictionary<int, int>();

public void Increment()
{
    int newValue = dictionary[0] + 1;
    //meanwhile, right now in another thread: dictionary = new Dictionary<int, int>();
    dictionary[0] = newValue; //at this point, "dictionary" is actually pointing to a whole new instance
}

使用他们拥有的局部变量赋值,它看起来更像是为了避免这种情况:

public void IncrementFix()
{
    var dictionary2 = dictionary;
    //in another thread: dictionary = new Dictionary<int, int>();
    //this is OK, dictionary2 is still pointing to the ORIGINAL dictionary
    int newValue = dictionary2[0] + 1;
    dictionary2[0] = newValue;
}

请注意,它并不完全满足所有线程安全要求。例如,在这种情况下,我们开始增加值,但dictionary类上的引用已更改为全新的实例。但是如果您需要更高级别的线程安全,那么您需要实现自己的主动同步/锁定,这通常超出了编译器优化的范围。然而,据我所知,这并没有真正给处理增加任何重大影响(如果有的话),并且避免了这种情况。如果dictionary财产,情况尤其如此不是您示例中的字段,在这种情况下,不解析属性获取器两次肯定是一种优化。(无论如何,您的实际代码是否使用字典的属性而不是您发布的字段?)

编辑:好吧,对于一个简单的方法:

public void IncrementDictionary() 
{
    dictionary[0]++;
}

从 LINQPad 报告的 IL 是:

IL_0000:  nop         
IL_0001:  ldarg.0     
IL_0002:  ldfld       UserQuery.dictionary
IL_0007:  dup         
IL_0008:  stloc.0     
IL_0009:  ldc.i4.0    
IL_000A:  ldloc.0     
IL_000B:  ldc.i4.0    
IL_000C:  callvirt    System.Collections.Generic.Dictionary<System.Int32,System.Int32>.get_Item
IL_0011:  ldc.i4.1    
IL_0012:  add         
IL_0013:  callvirt    System.Collections.Generic.Dictionary<System.Int32,System.Int32>.set_Item
IL_0018:  nop         
IL_0019:  ret         

我不完全确定(我不是 IL wiz),但我认为该dup调用本质上将堆栈上的相同字典引用加倍,因此无论 get 和 set 调用都指向同一个字典。也许这正是 ILSpy 将其表示为 C# 代码的方式(至少与行为相同)。我认为。如果我错了,请纠正我,因为就像我说的那样,我还不知道我的手背上的 IL。

编辑:必须运行,但它的最后要点是:++并且+=不是原子操作,实际上执行指令比 C# 中描述的要复杂得多。因此,为了确保每个 get/increment/set 步骤都在同一个字典实例上执行(正如您在 C# 代码中所期望和要求的那样),对字典进行了本地引用以避免运行字段“ get" 操作两次,这可能导致指向一个新实例。ILSpy 如何描述索引 += 操作所涉及的所有工作取决于它。

于 2012-06-27T22:58:41.167 回答
1

您的编辑弄乱了您要显示的内容,但是dictionary[key]++制作临时副本的原因dictionary是它无法知道索引的 getter 是否Dictionary<int,int>会更改 field dictionary。规范表明,即使dictionary在索引 get 期间更改了字段,索引 put 仍将在同一个对象上执行。

顺便说一句,.net 确实应该(并且仍然应该)提供一种方法,通过该方法,类可以将属性公开为ref. 可以Dictionary提供方法ActOnValue(TKey key, ActionByRef<TValue> action)and ActOnValue<TParam>(TKey key, ActionByRef<TValue, TParam> action, ref TParam param)(假设ActionByRef<T>是 likeAction<T>但声明的参数ref,也是如此ActionByRef<T1,T2>)。如果确实如此,则可以对集合对象执行读-修改-写操作,而无需两次索引该集合。不幸的是,没有以这种方式公开属性的标准约定,也没有任何语言支持。

于 2012-06-27T23:26:44.570 回答