.Net 中的字典是不是可能在并行读取和写入时导致死锁?

Posted

技术标签:

【中文标题】.Net 中的字典是不是可能在并行读取和写入时导致死锁?【英文标题】:Is it possible for a Dictionary in .Net to cause dead lock when reading and writing to it in parallel?.Net 中的字典是否可能在并行读取和写入时导致死锁? 【发布时间】:2016-01-14 04:53:01 【问题描述】:

我正在玩 TPL,并试图通过并行读取和写入同一个字典来找出我会造成多大的混乱。

所以我有这个代码:

    private static void HowCouldARegularDicionaryDeadLock()
    
        for (var i = 0; i < 20000; i++)
        
            TryToReproduceProblem();
        
    

    private static void TryToReproduceProblem()
    
        try
        
            var dictionary = new Dictionary<int, int>();
            Enumerable.Range(0, 1000000)
                .ToList()
                .AsParallel()
                .ForAll(n =>
                
                    if (!dictionary.ContainsKey(n))
                    
                        dictionary[n] = n; //write
                    
                    var readValue = dictionary[n]; //read
                );
        
        catch (AggregateException e)
        
            e.Flatten()
                .InnerExceptions.ToList()
                .ForEach(i => Console.WriteLine(i.Message));
        
    

确实很乱,抛出了很多异常,主要是key不存在,还有一些是index out of array的异常。

但是应用运行一段时间后就挂了,cpu百分比停留在25%,机器有8核。 所以我假设这是 2 个线程满负荷运行。

然后我在上面运行了 dottrace,得到了这个:

这与我的猜测相符,两个线程以 100% 运行。

都运行 Dictionary 的 FindEntry 方法。

然后我再次运行应用程序,使用dottrace,这次结果略有不同:

这一次,一个线程运行 FindEntry,另一个线程运行 Insert。

我的第一个直觉是它是死锁的,但后来我认为不可能,只有一个共享资源,它没有被锁定。

那么这应该怎么解释呢?

ps:我不是要解决这个问题,它可以通过使用 ConcurrentDictionary 或并行聚合来解决。我只是在寻找一个合理的解释。

【问题讨论】:

你可以猜到 Findentry 试图找到一个条目。它保留了一些稍后更改的局部变量,这导致循环结束条件永远不会终止,因为它假定由另一个线程更改的条目数不会改变。 所以不是死锁,而是内部状态混乱导致的死循环? 【参考方案1】:

所以你的代码正在执行Dictionary.FindEntry。这不是死锁 - 当两个线程以某种方式阻塞时会发生死锁,这使得它们彼此等待释放资源,但在你的情况下,你会得到两个看似无限的循环。线程未锁定。

我们来看看reference source中的这个方法:

private int FindEntry(TKey key) 
    if( key == null) 
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key);
    

    if (buckets != null) 
        int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF;
        for (int i = buckets[hashCode % buckets.Length]; i >= 0; i = entries[i].next) 
            if (entries[i].hashCode == hashCode && comparer.Equals(entries[i].key, key)) return i;
        
    
    return -1;

看看for 循环。 increment 部分是i = entries[i].next,猜猜看:entries 是在Resize method 中更新的字段。 next是内部Entry struct的一个字段:

public int next;        // Index of next entry, -1 if last

如果您的代码无法退出 FindEntry 方法,最可能的原因是您设法弄乱了条目,以至于当您遵循由next 字段。

至于Insert method,它有一个非常相似的for循环:

for (int i = buckets[targetBucket]; i >= 0; i = entries[i].next)

由于 Dictionary 类被证明是非线程安全的,因此无论如何您都处于未定义行为的领域。

使用ConcurrentDictionary 或诸如ReaderWriterLockSlim 之类的锁定模式(Dictionary 仅对并发读取是线程安全的)或普通的旧lock 可以很好地解决问题。

【讨论】:

如果一切都失败了,请阅读手册。如果这也失败了,请阅读源代码 -> 终极手册 @伟大的解释老兄! (+1) 这是一个了不起的解释。【参考方案2】:

看起来像一个竞争条件(不是死锁) - 正如您所评论的那样,这会导致内部状态混乱。

字典不是线程安全的,因此从单独的线程(即使每个线程只有一个)并发读取和写入同一个容器是不安全的。

一旦达到竞争条件,将会发生什么变得不确定;在这种情况下,这似乎是某种无限循环。

一般来说,一旦需要写访问,就需要某种形式的同步。

【讨论】:

【参考方案3】:

只是为了补充(和关联)前两个很好的答案:

Dictionary&lt;T&gt; 是一个 HashMap 实现,和大多数 HashMap 实现一样,它internally uses LinkedLists(用于存储多个元素,以防不同的键在经过散列和取散列模数后进入相同的桶位置)并使用内部数组它可以随着字典中元素数量的增长而增长。

由于代码以空字典开头并添加了很多元素,因此字典以small internal array(可能大小=3)开头并经常增加它。由于有多个线程尝试向字典中添加元素,不同线程很有可能同时尝试 Resize() 字典。由于 Dictionary 不是线程安全的类,如果两个线程同时尝试修改同一个 LinkedList,这可能会使 LinkedList 处于不一致状态(这是一个竞态条件 - 两个线程修改相同的数据,导致不可预知的结果)。

正如其他答案中所解释的,修改 LinkedList 时的竞争条件可能会使 LinkedList 进入无效状态,这将解释在迭代 LinkedList 的方法上发生的无限循环(FindEntryInsert) .这个无限循环解释了高 cpu(每个线程使用 100% cpu) - 如果它是死锁,则线程将处于低 cpu 状态,等待一些锁定的资源。

由于我们知道元素的数量,我们可以使用更大的大小(例如 1000000)预初始化字典,以减少出现竞争条件的机会。但这并不能解决问题 - 最好的解决方案仍然是使用线程安全类 (ConcurrentDictionary&lt;T&gt;)。

【讨论】:

以上是关于.Net 中的字典是不是可能在并行读取和写入时导致死锁?的主要内容,如果未能解决你的问题,请参考以下文章

如何在从 NodeJS 中的多个输入流中读取时写入单个文件

Matlab并行写入和读取

数据库并行读取和写入(Python实现)

HDF5 是不是支持并发读取或写入不同文件?

C++ 标准容器的线程安全

如何在 .Net 框架中长时间读取和写入 Azure 中的控制台?