今天我怀疑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用50,000个正键初始化.然后在不同的线程上添加50,000个额外的否定键,同时使用AddRange方法将字典添加到列表中.我预计词典最终将有ConcurrentDictionary,000个键,列表在50,000到ConcurrentDictionary,000个项目之间.事实上我得到了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()

My question is:为什么会发生这种情况?如何防止这种情况发生?有没有办法确保list.AddRange(dictionary);号线始终成功,没有例外?

想象一下,这本词典可能是以IEnumerable<T>的形式送给我的,而我不知道它的潜在类型.这种情况下也会引发相同的异常:

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

这种行为降低了我对一般使用List<T>.AddRange API的信心.

Context: this个问题中提到了类似的症状,但没有提供一个最小且可重复的例子,所以我不确定场景是否相同.另一个相关问题是warns8514/calling-tolist-on-concurrentdictionarytkey-tvalue-while-adding-items" title="Calling ToList() on ConcurrentDictionary<TKey, TValue> while adding items">this,关于在ConcurrentDictionary<TKey, TValue>上呼叫LINQ ToList.尽管如此,文档warns中关于在并发集合上使用扩展方法,但我没有看到任何针对将并发集合与List<T>.AddRange方法一起使用的警告.

推荐答案

正在发生的事情相当简单.

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>>及其实现CountCopyTo本身是安全的,但它们之间没有固有的同步.

因此,List<T>.AddRange向字典询问其大小,分配该数量的新元素,然后要求字典将自己复制到新分配的空间中.然而,到那时,字典已经长大了,并引发了异常here:

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

至于这里应该"责怪"谁,我不确定.List<T>正在进行的优化大多数时候都是明智的,并且作为一个非线程安全的集合,它并不试图实现线程安全.正如@shingo指出的那样,ConcurrentDictionary在通过其一个接口访问时并不保证线程安全,尽管它已经尽力了.如果要求复制的空间不够大,则ICollection<T>.CopyTodocumented as throwing.

至于变通办法,最简单、最明显正确的是创建一个中间集合:list.AddRange(dict.ToArray()).然而,这当然伴随着中间分配的成本,这可能很大.

您还可以在字典上包裹循环,并对每个元素使用Add(ConcurrentDictionaryGetEnumerator()是线程安全的),这实际上是您期望AddRange所做的事情.

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

Csharp相关问答推荐

如果没有中间变量,可空引用类型将无法工作

火鸟DBC驱动程序未加载ADF DLC

具有多个应用程序服务器的Azure Signal

在. NET Core 8 Web API中,当为服务总线使用通用消费者时,如何防止IServiceProvider被释放或空?"

解析需要HttpClient和字符串的服务

读取配置文件(mytest. exe. config)

Blazor. NET 8—阶段启动配置文件不启动网站VS2022

限制特定REST API不被访问,但部署代码

与C#中的Zip列表并行

S能够用DATETIME来计算,这有什么错呢?

Appsettings.json未加载.Net 8 Blaazor Web程序集

如何在毛伊岛应用程序中完美地同步视图模型和视图的加载?

正确处理嵌套的本机集合

在DoubleClick上交换DataGridViewImageColumn的图像和工具提示

我应该为C#12中的主构造函数参数创建私有属性吗?

C#System.Commandline:如何向命令添加参数以便向其传递值?

为什么Swashbakle/Swagger在参数中包含变量名?

Visual Studio如何使用当前的框架?

嵌套Blazor组件内的验证

我想我必须手动使用res1(字符串形式的PowerShell哈希表)