代码之家  ›  专栏  ›  技术社区  ›  Theodor Zoulias

列出<T>。AddRange在将ConcurrentDictionary作为参数传递时引发ArgumentException

  •  3
  • Theodor Zoulias  · 技术社区  · 8 月前

    今天我怀疑 List<T>.AddRange 方法作为参数与并发集合一起使用可能不安全,所以我做了一个实验来找出:

    ConcurrentDictionary<int, int> dictionary = new();
    
    for (int i = 1; i <= 50_000; i++)
        dictionary.TryAdd(i, default);
    
    List<KeyValuePair<int, int>> list = new();
    
    Thread thread = new(() =>
    {
        for (int i = -1; i >= -50_000; i--)
            dictionary.TryAdd(i, default);
    });
    thread.Start();
    
    list.AddRange(dictionary); // Throws
    
    thread.Join();
    Console.WriteLine($"dictionary.Count: {dictionary.Count:#,0}, list.Count: {list.Count:#,0}");
    

    Online demo .

    这个 ConcurrentDictionary 使用50000个正密钥进行初始化。然后,在另一个线程上添加50000个额外的负密钥,同时使用 AddRange 方法我原以为这本字典最终会有10万个关键字,而列表中的条目在5万到10万个之间。事实上,我得到了 ArgumentException :

    Unhandled exception. System.ArgumentException: The index is equal to or greater than the length of the array, or the number of elements in the dictionary is greater than the available space from index to the end of the destination array.
       at System.Collections.Concurrent.ConcurrentDictionary`2.System.Collections.Generic.ICollection<System.Collections.Generic.KeyValuePair<TKey,TValue>>.CopyTo(KeyValuePair`2[] array, Int32 index)
       at System.Collections.Generic.List`1.InsertRange(Int32 index, IEnumerable`1 collection)
       at System.Collections.Generic.List`1.AddRange(IEnumerable`1 collection)
       at Program.Main()
    

    我的问题是: 为什么会发生这种情况,我该如何防止这种情况发生?是否有任何方法可以确保 list.AddRange(dictionary); 行是否总是成功的,不抛出任何异常?

    想象一下,这本词典可能是作为 IEnumerable<T> ,我不知道它的基本类型。在这种情况下也会引发相同的异常:

    IEnumerable<KeyValuePair<int, int>> enumerable = dictionary;
    list.AddRange(enumerable); // Throws
    

    这种行为降低了我使用 列表<T>。AddRange API一般情况。

    上下文 中提到了类似的症状 this 问题,但没有提供一个最小且可重复的例子,所以我不确定场景是否相同。另一个相关问题是 this ,关于调用LINQ ToList ConcurrentDictionary<TKey, TValue> 。尽管如此,文件 warns 关于在并发集合上使用扩展方法,但我没有看到任何关于使用的并发集合的警告 列表<T>。AddRange 方法

    2 回复  |  直到 8 月前
        1
  •  4
  •   canton7    8 月前

    发生的事情相当简单。

    List<T>.AddRange 检查一下它通过的东西是否是 ICollection<T> 。如果是,它可以通过使用 ICollection<T>.Count 一次性为新范围分配足够的空间(而不是可能多次调整列表大小),以及 ICollection<T>.CopyTo 一次性复制集合的元素,而不是一个接一个地添加它们。

    代码是 here :

    if (collection is ICollection<T> c)
    {
        int count = c.Count;
        if (count > 0)
        {
            if (_items.Length - _size < count)
            {
                Grow(checked(_size + count));
            }
    
            c.CopyTo(_items, _size);
            _size += count;
            _version++;
        }
    }
    

    ConcurrentDictionare<TKey, TValue> 机具 ICollection<KeyValuePair<TKey, TValue>> ,及其实现 Count CopyTo 它们本身是安全的,但它们之间没有固有的同步。

    所以 列表<T>。AddRange 询问字典的大小,分配该数量的新元素,然后要求字典将自己复制到新分配的空间中。然而,字典已经增长到了这个程度,并抛出了一个异常 here :

    int count = GetCountNoLocks();
    if (array.Length - count < index)
    {
        throw new ArgumentException(SR.ConcurrentDictionary_ArrayNotLargeEnough);
    }
    

    至于谁应该在这里受到“谴责”,我不确定。优化 List<T> 在大多数情况下,这样做是明智的,作为一个非线程安全的集合,它并不试图是线程安全的。正如@shingo所指出的, ConcurrentDictionary 虽然它尽了最大的努力,但不能保证通过其接口访问时线程的安全性。 I集合<T>。方法 documented as throwing 如果要求复制到的空间不够大。

    至于解决方法,最简单也是最明显正确的方法是创建一个中间集合: list.AddRange(dict.ToArray()) 然而,这当然伴随着中间分配的成本,这可能是巨大的。

    您也可以将循环包裹在字典上并使用 Add 每个元素( 并发字典 s GetEnumerator() 是线程安全的),这实际上是您所期望的 AddRange 无论如何都要做。

    我认为一般来说,在以这种方式混合线程安全和非线程安全类型时,您只需要小心。确保您确切地了解发生了什么,以及线程安全保证了所涉及的类型做什么和不做什么。

        2
  •  1
  •   Matthew Watson    8 月前

    作为Canton7答案的补充,您可以使用以下方法“隐藏”导致问题的优化类型:

    public static IEnumerable<T> Enumerate<T>(IEnumerable<T> sequence)
    {
        foreach (var item in sequence)
        {
            yield return item;
        }
    }
    

    然后您可以拨打: list.AddRange(Enumerate(dictionary)); 而且它不会抛出异常。这将避免必须复制该集合。

    推荐文章