ConcurrentDictionary Keys 或 Values 属性是线程安全的吗

Posted

技术标签:

【中文标题】ConcurrentDictionary Keys 或 Values 属性是线程安全的吗【英文标题】:Is ConcurrentDictionary Keys or Values property threadsafe 【发布时间】:2012-05-07 09:42:50 【问题描述】:

ConcurrentDictionary 的线程安全有疑问。从 API 中,我看到枚举器是线程安全的,但对于键和值属性,我看不到相同的。我的问题是:

当有其他线程同时修改它时,循环遍历 KeysValues 集合是否安全?

【问题讨论】:

枚举器的行为与 Keys 和 Values 属性不同。虽然这些提供了字典的即时快照,但GetEnumerator(也用于使用字典作为源的 LINQ 查询中)返回的内容包含在调用 GetEnumerator 之后对字典所做的修改。 ConcurrentDictionary 并不总是线程安全的 - blog.i3arnon.com/2018/01/16/concurrent-dictionary-tolist . 我在这里看到两个不同的活动:1 - 从字典中获取对集合的引用(它可能不是原子的),2 - 通过集合枚举。当有问题的线程试图检索集合以进行枚举时,其他线程是否可能会修改字典。 【参考方案1】:

虽然我确实喜欢文档,但我倾向于在有疑问时使用小程序来验证事情,或者我觉得我可能假设太多了。

以下代码验证您确实可以安全地枚举值集合,同时将键从单独的线程添加或删除到进行枚举的线程。这不会导致通常的集合被修改异常。更详细地说,这里有几个测试用例

案例 1:枚举值并删除键

如果您遵循以下顺序:

开始从线程枚举值集合 从我们尚未枚举的不同线程中删除一个键 在原线程上继续枚举

观察到的行为是删除的键确实会被枚举,因为当我们开始枚举时它存在于值集合中。不会引发异常。

案例 2:枚举值并添加键

开始从线程枚举值集合 从我们尚未枚举的不同线程添加新密钥 在原线程上继续枚举

观察到的行为是添加的键将不会被枚举,因为当我们开始枚举它时它在值集合中不存在。无论我们使用 TryAdd 还是通过直接分配给字典即字典 [key] = value 来添加,都不会引发异常。

示例代码

这是演示这两种情况的示例程序:

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

// Seed the dictionary with some arbitrary values; 
for (int i = 0; i < 30; i++)

    dictionary.TryAdd(i, i);


// Reader thread - Enumerate the Values collection
Task.Factory.StartNew(
        () =>
        
            foreach (var item in dictionary.Values)
            
                Console.WriteLine("Item 0: count: 1", item, dictionary.Count);
                Thread.Sleep(20);
            

        
);

// writer thread - Modify dictionary by adding new items and removing existing ones from the end
Task.Factory.StartNew(
        () =>
        
            for (int i = 29; i >= 0; i--)
            
                Thread.Sleep(10);
                //Remove an existing entry 
                int removedValue;
                if (dictionary.TryRemove(i, out removedValue))
                    Console.WriteLine("Removed item 0", removedValue);
                else
                    Console.WriteLine("Did not remove item 0", i);

                int iVal = 50 + i*2;
                dictionary[iVal] = iVal;
                Thread.Sleep(10);
                iVal++;
                dictionary.TryAdd(iVal, iVal);
            
        
);

Console.ReadKey();

这是发布模式下的输出

【讨论】:

希望微软可以在 MSDN 中记录这一点。 您真正要说的是KeysValues 集合是您获得枚举器时字典的快照。几乎就像他们做了一个深度克隆并把它交还给你枚举。 本实验并未涵盖所有内容。并且可能具有误导性。这里的问题不止一个。首先,当ConcurrentDictionary 为属性调用者生成值集合时,它不会检查到底发生了什么。更重要的是,文档明确指出“对字典的读取操作是以无锁方式执行的”。 (docs.microsoft.com/en-us/dotnet/api/…)。这意味着当一个线程将生成键或值快照时,另一个线程可能会更改字典内容。 此类实验的另一个更普遍的问题可能会导致非常错误的结论。如果某个特定的框架实现在一个实验中以某种方式表现,这并不意味着如果文档没有明确“保证”它在另一个框架版本中表现相同。【参考方案2】:

ConcurrentDictionary 表示一个线程安全的键值集合 可以被多个线程同时访问的对。

来源:MSDN

【讨论】:

+1,简洁! :) 补充:是的,这两个属性都受锁保护,您将枚举的是字典的内容在您开始枚举时(因此您可能会看到一个键,例如,已从另一个线程中删除)。 所以你需要对键值对进行快照。 我认为截至 2021 年 7 月,引用的来源不再包含引用的块。【参考方案3】:

是的,它是线程安全的。 但是,即使它是线程安全的,您也不应该使用KeysValuesCount

尤其是当您使用ConcurrentCollections&lt;T&gt; 时,因为您希望尽量减少锁争用、线程阻塞和内存分配。如果您关心性能和效率,您确实想要这些东西。

查看 reference source 以了解原因 - Keys 立即调用 GetKeys() 帮助程序,并且在继续之前获取每一个锁。一旦获得了锁,它会将每一个密钥复制到new List&lt;TKey&gt;,并返回一个只读视图——这样就不会有人意外地改变实际上只是密钥集合的临时副本的内容.如果您的集合变得很大,这需要分配相当大的数组,并持有相当长的锁!

Values 锁定并复制每个值,类似于Keys。甚至Count 获取所有锁,不是为了复制,而是为了求和所有内部表段长度。所有这些只是为了获得集合中对象的瞬间“一致”计数,这仅在释放锁后用作粗略估计或历史脚注。

是的,叹息,如果您需要原子一致性,我想这可能是您必须付出的代价。但!也许你比那更幸运。然后,您可能会意识到,您的场景实际上并不需要一致性,并且您可以掌握针对那些坏的 API 的更高性能的 API - 例如使用 GetEnumerator() 来大致了解您的收藏中的项目! GetEnumerator()的文档中的注释:

从字典返回的枚举器可以安全使用 与字典的读取和写入同时进行,但确实如此 不代表字典的即时快照。这 通过枚举器暴露的内容可能包含所做的修改 调用 GetEnumerator 后到字典中。

换句话说,它根本不需要锁定或复制,因为它不需要确保一致性。万岁!

【讨论】:

我刚刚在ConcurrentDictionary 上使用LINQ 时发现了一个相当可怕的错误,所以在使用枚举器时要小心:github.com/dotnet/runtime/issues/48805。 @MikeMarynowski 不应将 ConcurrentDictionary 枚举器与 LINQ 一起使用的绝佳示例。

以上是关于ConcurrentDictionary Keys 或 Values 属性是线程安全的吗的主要内容,如果未能解决你的问题,请参考以下文章

.NET 锁定还是 ConcurrentDictionary?

.NET - 字典锁定与 ConcurrentDictionary

ConcurrentDictionary和线程

ConcurrentDictionary 的列表顺序是不是得到保证?

ConcurrentDictionary与Dictionary 替换

dapper.net,如何刷新 ConcurrentDictionary?