最近我看到一些在Dictionary
上使用双重检查锁定模式的C#项目。像这样:
private static readonly object _lock = new object();
private static volatile IDictionary<string, object> _cache =
new Dictionary<string, object>();
public static object Create(string key)
{
object val;
if (!_cache.TryGetValue(key, out val))
{
lock (_lock)
{
if (!_cache.TryGetValue(key, out val))
{
val = new object(); // factory construction based on key here.
_cache.Add(key, val);
}
}
}
return val;
}
此代码不正确,因为Dictionary
可以“增长”_cache.Add()
中的集合,而_cache.TryGetValue
(锁定之外)正在迭代集合。在许多情况下这可能是极不可能的,但仍然是错误的。
是否有一个简单的程序来证明此代码失败了?
将其纳入单元测试是否有意义?如果是这样,怎么样?
答案 0 :(得分:20)
显然,代码不是线程安全的。我们这里有一个明显的例子,说明过早优化的危害。
请记住,双重检查锁定模式的目的是通过消除锁定成本来提高代码的性能。如果锁是无可争议的,它已经非常便宜了。因此,双重检查的锁定模式仅在锁定将受到严重争议的情况下(1)或(2)代码对性能非常敏感 性能敏感的情况下是合理的。一个无可争议的锁定仍然太高。
显然,我们不是第二种情况。你是为了天堂而使用字典。即使没有锁定,它也会进行查找和比较,这比避免无争议锁定的成本高出数百或数千倍。
如果我们处于第一种情况,那么找出导致争用的原因并消除。如果你在锁定上做了很多等待,那么找出原因并用一个超薄的读写器锁替换锁定或重组应用程序,这样就不会有太多的线程在同一个锁上敲打时间。
在任何一种情况下,都没有理由采用危险的,实现敏感的低锁技术。你应该只在极少数情况下使用低锁技术,你真的无法承担无争议锁定的成本。
答案 1 :(得分:13)
在此示例中,异常#1几乎立即在我的机器上抛出:
var dict = new Dictionary<int, string>() { { 1234, "OK" } };
new Thread(() =>
{
for (; ; )
{
string s;
if (!dict.TryGetValue(1234, out s))
{
throw new Exception(); // #1
}
else if (s != "OK")
{
throw new Exception(); // #2
}
}
}).Start();
Thread.Sleep(1000);
Random r = new Random();
for (; ; )
{
int k;
do { k = r.Next(); } while (k == 1234);
Debug.Assert(k != 1234);
dict[k] = "FAIL";
}
但是,未设计为线程安全的代码的确切行为是不可预测。
你不能依赖它。所以双重检查代码确实破了。
我不确定我是否会对此进行单元测试,因为测试并发代码(并且正确)比编写并发代码要复杂得多。
答案 2 :(得分:8)
我真的不认为你需要来证明这一点,你只需要将人们引荐到documentation for Dictionary<TKey, TValue>
:
一个字典可以支持多个读者同时,的只要集合不被修改。强>即便如此,通过集合枚举本质的不是一个线程安全的过程。强>在枚举与写访问争用的极少数情况下,必须在整个枚举期间锁定该集合。 要允许多个线程访问集合以进行读写,您必须实现自己的同步。
实际上这是一个众所周知的事实(或应该是),当另一个线程正在写入字典时,您无法从字典中读取。我在SO上看到了一些“奇怪的多线程问题”类型的问题,结果发现作者没有意识到这不安全。
问题与双重检查锁定没有特别关系,只是字典不是线程安全的类,即使对于单一编写器/单读取器场景也是如此。
我会更进一步向你展示为什么,在Reflector中,这不是线程安全的:
private int FindEntry(TKey key)
{
// Snip a bunch of code
for (int i = this.buckets[num % this.buckets.Length]; i >= 0;
i = this.entries[i].next)
// Snip a bunch more code
}
private void Resize()
{
int prime = HashHelpers.GetPrime(this.count * 2);
int[] numArray = new int[prime];
// Snip a whole lot of code
this.buckets = numArray;
}
看看如果Resize
方法恰好在一个读者调用FindEntry
时正在运行会发生什么:
这正是dtb的例子中失败的原因。线程A搜索事先已知的密钥在字典中,但是找不到它。为什么?由于FindValue
方法采摘它认为正确的桶,但在此之前它甚至有机会进去看看,线程B改变了水桶,现在线程A正在寻找一些不含有或完全随机桶甚至导致正确的进入。
故事的道德:TryGetValue
不是原子操作,Dictionary<TKey, TValue>
不是线程安全的类。这不仅仅是您需要担心的并发写入;你不能同时进行读写。
在现实中的问题,实际运行比这更深了很多,由于抖动和CPU,陈旧缓存等指令重新排序 - 有任何这里没有使用内存屏障 - 但是这应该证明的超越怀疑如果Add
调用与TryGetValue
调用同时运行,则存在明显的竞争条件。
答案 3 :(得分:3)
我猜这个问题一次又一次出现的原因是:
Pre-2.0,在Generics(B.G。)之前,
Hashtable
是.NET中的主要关联容器,它确实提供了一些线程保证。来自MSDN:
“Hashtable是线程安全的,可供多个读取器线程和单个写入线程使用。当只有一个线程执行写入(更新)操作时,它对多线程使用是线程安全的,这允许提供无锁读取作者被序列化为Hashtable。“在任何人极度兴奋之前,有一些限制 参见例如this post from Brad Abrams,拥有
Hashtable
可以找到Hashtable
上的更多历史背景here (...near the end: "After this lengthy diversion - What about Hashtable?").
为什么Dictionary<TKey, TValue>
在上述情况下失败:
为了证明它失败了,找到一个例子就足够了,所以我会试着这样做 随着表格的增长,会发生调整大小 在调整大小时,会发生重新散列,并将其视为最后两行:
this.buckets = newBuckets;
//One of the problems here.
this.entries = newEntries;
buckets
数组将索引保存到entries
数组中。 假设到目前为止我们有10个条目,现在我们正在添加一个新条目 让我们进一步假装为了简单起见,我们没有也不会发生碰撞 在旧buckets
中,如果我们没有碰撞,我们的索引从0到9运行 现在,新buckets
数组中的索引从0到10(!)运行 我们现在将私有buckets
字段更改为指向新存储桶 如果此时有一个读者正在执行TryGetValue()
,它会使用 new 存储桶来获取索引,但之后会使用 new 索引来读取< em> old 条目数组,因为entries
字段仍指向旧条目 人们可以得到的一件事 - 除了错误的阅读 - 是友好的IndexOutOfRangeException
获得此问题的另一个“好方法”是@Aaronaught's解释。 (......并且两者都可能发生,例如在dtb's示例中)。这只是一个例子,Dictonary没有设计,也从未意味着线程安全。然而,它设计得很快 - 这意味着锁定不会持久。
答案 4 :(得分:1)
在问题中包含代码,您可以使用以下代码对其进行测试。
//using System.Collections.Generic;
//using System.Threading;
private static volatile int numRunning = 2;
private static volatile int spinLock = 0;
static void Main(string[] args)
{
new Thread(TryWrite).Start();
new Thread(TryWrite).Start();
}
static void TryWrite()
{
while(true)
{
for (int i = 0; i < 1000000; i++ )
{
Create(i.ToString());
}
Interlocked.Decrement(ref numRunning);
while (numRunning > 0) { } // make sure every thread has passed the previous line before proceeding (call this barrier 1)
while (Interlocked.CompareExchange(ref spinLock, 1, 0) != 0){Thread.Sleep(0);} // Aquire lock (spin lock)
// only one thread can be here at a time...
if (numRunning == 0) // only the first thread to get here executes this...
{
numRunning = 2; // resets barrier 1
// since the other thread is beyond the barrier, but is waiting on the spin lock,
// nobody is accessing the cache, so we can clear it...
_cache = new Dictionary<string, object>(); // clear the cache...
}
spinLock = 0; // release lock...
}
}
这个程序只是试图让Create
遍历集合,因为它正在“增长”。它应该在具有至少两个核心(或两个处理器)的计算机上运行,并且很可能在一段时间后因此异常而失败。
System.Collections.Generic.Dictionary`2.FindEntry(TKey key)
添加此测试很困难,因为它是一个概率测试,您不知道失败需要多长时间(如果有的话)。我想你可以选择一个10秒的值,让它运行那么久。如果在这段时间内没有失败,则测试通过。不是最好的,而是一些东西。您还应该在运行测试之前验证Environment.ProcessorCount > 1
,否则它失败的可能性是微不足道的。