在 .Net 中使用 Dictionary<int,int> 时如何保证线程安全性

10

我有这个函数:

static Dictionary<int, int> KeyValueDictionary = new Dictionary<int, int>();
static void IncreaseValue(int keyId, int adjustment)
{
    if (!KeyValueDictionary.ContainsKey(keyId))
    {
        KeyValueDictionary.Add(keyId, 0);
    }
    KeyValueDictionary[keyId] += adjustment;
}

我本来认为这不是线程安全的。然而,到目前为止,在多个线程同时调用它时,我没有看到任何异常。

我的问题是:它是线程安全的还是我到目前为止只是幸运?如果它是线程安全的,那么原因是什么?


2
正如Herb Sutter所说(从记忆中得知):“当我们犯下竞态条件时,它的典型行为是什么?它会在单元测试和系统测试中悄悄溜过去。我们举办发布派对。然后我们接到最重要的客户电话,报告非确定性崩溃。” - Just another metaprogrammer
@FuleSnabel:哈哈,好笑。 - squashed.bugaboo
4个回答

23
你运气不错。由于测试可能会让你产生错误的安全感,所以这种线程错误很容易出现。事实证明,在有多个编写器时,Dictionary<TKey, TValue> 不是线程安全的。文档明确说明:
“只要没有修改集合,Dictionary<TKey, TValue> 即可支持多个读者并发。即使如此,枚举一个集合从本质上来说也不是线程安全的过程。在一个枚举与写访问相互竞争的罕见情况下,必须在整个枚举过程中锁定集合。为了允许多个线程读取和写入集合,必须实现自己的同步。”
或者,使用ConcurrentDictionary。但是,您仍然必须编写正确的代码(请参阅下面的注释)。
除了你现在幸运避免的Dictionary<TKey, TValue>的缺乏线程安全性外,你的代码存在严重缺陷。以下是你可能会遇到的代码bug:
static void IncreaseValue(int keyId, int adjustment) {
    if (!KeyValueDictionary.ContainsKey(keyId)) {
        // A
        KeyValueDictionary.Add(keyId, 0);
    }
    KeyValueDictionary[keyId] += adjustment;
}
  1. 字典为空。
  2. 线程1使用 keyId = 17 进入该方法。由于字典为空,if 中的条件返回 true,线程1到达标有A的代码行。
  3. 线程1暂停,线程2使用 keyId = 17 进入该方法。由于字典为空,if 中的条件返回 true,线程2到达标有A的代码行。
  4. 线程2暂停,线程1恢复。现在线程1将 (17, 0) 添加到字典中。
  5. 线程1暂停,现在线程2恢复。现在线程2尝试将 (17, 0) 添加到字典中。由于键冲突而抛出异常。

还有其他情况可能会导致异常发生。例如,当线程1正在加载 KeyValueDictionary[keyId] 的值(假设它加载了 keyId = 17 并获得了值 42)时,线程2可能会进来并修改该值(假设它加载了 keyId = 17,并添加了调整值27),现在线程1恢复并将其调整值添加到加载的值中(特别是,它没有看到线程2对与 keyId = 17 关联的值所做的修改!)。

请注意,即使使用 ConcurrentDictionary<TKey, TValue> 也可能导致上述错误!您的代码存在与 Dictionary<TKey, TValue> 的线程安全或不安全无关的原因,而不是线程安全问题。

要使代码在使用并发字典时具有线程安全性,需要进行以下更改:

KeyValueDictionary.AddOrUpdate(keyId, adjustment, (key, value) => value + adjustment);

在这里,我们使用的是ConcurrentDictionary.AddOrUpdate方法。


5
+1:好的回答,感谢你付出的努力。特别喜欢你探讨“线程安全”集合为什么仍然允许“线程不安全”代码的部分。 - Just another metaprogrammer
感谢您花费时间和精力撰写答案。我的原始解决方案在整个解决方案的内容周围放置了锁,因为我不知道ConcurrentDictionary,这是一个更加优雅的解决方案。 - Guy

2

这段代码不是线程安全的,但它并没有进行检查,因此可能无法注意到静默损坏。

它在很长一段时间内似乎是线程安全的,因为只有当需要重新哈希(rehash())时,才会出现异常的可能性。否则,它只会破坏数据。


2
.NET库中有一个线程安全的字典,即ConcurrentDictionary<TKey, TValue>。详情请见http://msdn.microsoft.com/en-us/library/dd287191.aspx
更新:之前没有准确回答问题,以下是更全面的回答。根据MSDN的说明:http://msdn.microsoft.com/en-us/library/xfhwa508.aspx

一个字典可以支持多个读取器并发操作,只要集合没有被修改。即使如此,在枚举集合时本质上不是线程安全的过程。在罕见的情况下,当一个枚举正在与写入访问竞争时,整个枚举期间必须锁定集合。为了允许多个线程同时对集合进行读写访问,您必须实现自己的同步机制。

如果需要线程安全的替代方案,请参考ConcurrentDictionary。

此类型的公共静态成员(在Visual Basic中为Shared)是线程安全的。


5
这是极具误导性的。即使将 OP 代码中的字典替换为“ConcurrentDictionary”,该代码仍然是不安全的。 - Konrad Rudolph

1

你到目前为止只是运气好而已,它不是线程安全的。

《Dictionary<K,V>》文档中可以看出...

Dictionary<TKey, TValue>支持多个读取器并发访问,只要该集合未被修改。即使如此,枚举集合固有地不是线程安全的过程。在罕见的情况下,遍历与写访问竞争时,必须在整个枚举期间锁定集合。为了允许多个线程对集合进行读取和写入访问,您必须实现自己的同步。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接