实现线程安全的字典,最佳方式是什么?

110

通过从IDictionary派生并定义一个私有SyncRoot对象,我能够在C#中实现一个线程安全的字典:

public class SafeDictionary<TKey, TValue>: IDictionary<TKey, TValue>
{
    private readonly object syncRoot = new object();
    private Dictionary<TKey, TValue> d = new Dictionary<TKey, TValue>();

    public object SyncRoot
    {
        get { return syncRoot; }
    } 

    public void Add(TKey key, TValue value)
    {
        lock (syncRoot)
        {
            d.Add(key, value);
        }
    }

    // more IDictionary members...
}

我随后在我的消费者(多个线程)中锁定了这个SyncRoot对象:

示例:

lock (m_MySharedDictionary.SyncRoot)
{
    m_MySharedDictionary.Add(...);
}

虽然我成功让它工作了,但这导致了一些丑陋的代码。我的问题是,是否有更好、更优雅的方法来实现一个线程安全的字典?


3
你认为它有什么地方不美观? - Asaf R
1
我认为他指的是在SharedDictionary类的消费者中,他在整个代码中都有锁定语句 - 每次访问SharedDictionary对象上的方法时,他都会在调用代码中进行锁定。 - Peter Meyer
不要使用Add方法,而是通过赋值来实现,例如 m_MySharedDictionary["key1"]="item1",这样是线程安全的。 - testuser
8个回答

207

4
请标记此为响应,如果自己的.NET有解决方案,则无需使用自定义词典。 - Alberto León
28
请记住,另一个答案是在.NET 4.0存在之前写的(该版本于2010年发布)。 - Jeppe Stig Nielsen
2
不幸的是,它不是无锁解决方案,因此在SQL Server CLR安全程序集中无用。您需要像这里描述的那样的东西:http://www.cse.chalmers.se/~tsigas/papers/Lock-Free_Dictionary.pdf或者可能是这个实现:https://github.com/hackcraft/Ariadne - Triynko
2
虽然这已经是很老的话题了,但需要注意的是,使用ConcurrentDictionary而不是Dictionary可能会导致显著的性能损失。这很可能是昂贵的上下文切换造成的,因此在使用线程安全字典之前,请确保您确实需要它。 - outbred
ConcurrentDictionary 在某些情况下可能会出乎意料地慢。根据您的用例,一些替代 ThreadSafeDictionary 实现可能更好(免责声明:由我编写)。请参见页面底部的比较和推荐用例。(NuGet) - György Kőszeg

63

尝试在内部同步几乎肯定不足,因为它处于抽象层面太低。假设您按照以下方式使AddContainsKey操作单独具有线程安全性:

public void Add(TKey key, TValue value)
{
    lock (this.syncRoot)
    {
        this.innerDictionary.Add(key, value);
    }
}

public bool ContainsKey(TKey key)
{
    lock (this.syncRoot)
    {
        return this.innerDictionary.ContainsKey(key);
    }
}

那么当你从多个线程调用这段应该是线程安全的代码时,会发生什么?它总是能正常工作吗?

if (!mySafeDictionary.ContainsKey(someKey))
{
    mySafeDictionary.Add(someKey, someValue);
}
简单的回答是不行。在某个时刻,Add方法将抛出一个异常,指示字典中已经存在该键。你可能会问,既然是线程安全字典,为什么会这样呢?因为尽管每个操作都是线程安全的,但两个操作的组合并不是,另一个线程可能会在调用ContainsKey和Add之间对其进行修改。
这意味着要正确地编写此类型的场景,您需要在字典之外使用锁。
lock (mySafeDictionary)
{
    if (!mySafeDictionary.ContainsKey(someKey))
    {
        mySafeDictionary.Add(someKey, someValue);
    }
}

但现在,既然你需要编写外部锁定的代码,你混淆了内部和外部同步,这总会导致问题,比如代码不清晰和死锁。因此,你最好要么:

  1. 使用普通的Dictionary<TKey, TValue>并在外部同步,在其上封装复合操作;或者

  2. 编写一个新的线程安全包装器(具有不同的接口,即不是IDictionary<T>),它将组合操作(例如AddIfNotContained方法)结合在一起,这样您就永远不需要从中组合操作。

(我自己倾向于选择#1)


10
值得一提的是,.NET 4.0 将包含许多线程安全的容器,如集合和字典,它们具有不同于标准集合的接口(即它们将为您执行上述的选项2)。 - Greg Beech
1
值得注意的是,即使是粗略的锁定提供的粒度通常也足够支持单写多读的方法,如果设计一个合适的枚举器让底层类知道何时被处理(当未处理枚举器存在时,希望写入字典的方法应该用副本替换字典)。 - supercat

43

正如Peter所说,您可以将所有的线程安全都封装在类内部。但需要注意的是,任何暴露或添加的事件必须确保在任何锁定之外调用。

public class SafeDictionary<TKey, TValue>: IDictionary<TKey, TValue>
{
    private readonly object syncRoot = new object();
    private Dictionary<TKey, TValue> d = new Dictionary<TKey, TValue>();

    public void Add(TKey key, TValue value)
    {
        lock (syncRoot)
        {
            d.Add(key, value);
        }
        OnItemAdded(EventArgs.Empty);
    }

    public event EventHandler ItemAdded;

    protected virtual void OnItemAdded(EventArgs e)
    {
        EventHandler handler = ItemAdded;
        if (handler != null)
            handler(this, e);
    }

    // more IDictionary members...
}

编辑:MSDN文档指出,枚举本质上不是线程安全的。这可能是在类外部公开同步对象的原因之一。另一种方法是提供一些方法以对所有成员执行操作,并在成员枚举周围加锁。这样做的问题是你不知道传递给该函数的操作是否调用字典的某个成员(这将导致死锁)。公开同步对象允许使用者做出这些决策,并且不会将死锁隐藏在你的类内部。


@fryguybob:枚举实际上是我暴露同步对象的唯一原因。按照惯例,只有在枚举集合时才会对该对象执行锁定操作。 - GP.
1
如果你的字典不太大,你可以在副本上进行枚举,并将其内置到类中。 - fryguybob
2
我的字典并不是很大,而且我认为这已经起作用了。我的做法是创建一个名为CopyForEnum()的新公共方法,该方法返回一个带有私有字典副本的新实例的字典。然后,对于枚举类型,调用了该方法并移除了SyncRoot。谢谢! - GP.
12
由于字典操作通常是粒度较细的,因此这也不是一个天生的线程安全类。以下类似这样的逻辑:如果(dict.Contains(whatever)){ dict.Remove(whatever); dict.Add(whatever,newval);} 绝对会产生竞争条件。 - plinth

6

不应该通过属性发布私有锁对象。锁对象应该存在于私有状态,只为充当约会点而存在。

如果使用标准锁的性能证明很差,则 Wintellect 的Power Threading锁集合非常有用。


5
您所描述的实现方法存在几个问题。
  1. 您不应该公开同步对象。这样做会让消费者获取对象并锁定它,然后您就会遇到麻烦。
  2. 您正在使用线程安全的类来实现非线程安全的接口。在我看来,这将会在后面给你带来麻烦。

就我个人而言,我发现实现线程安全的最佳方式是通过不可变性。这真的可以减少与线程安全相关的问题数量。请查看Eric Lippert的博客以获取更多详细信息。


3

您不需要在使用者对象中锁定SyncRoot属性。字典方法内的锁定足以保证线程安全。

详细说明: 实际上,您的字典被锁定的时间比必要的时间更长。

以下是您的情况:

假设在调用m_mySharedDictionary.Add之前,线程A获取了SyncRoot的锁定。然后,线程B尝试获取锁定但被阻止。实际上,所有其他线程都被阻止了。线程A可以调用Add方法。在Add方法的锁语句中,线程A再次获得锁定,因为它已经拥有锁定。在方法内和方法外部退出锁定上下文后,线程A已释放所有锁定,从而允许其他线程继续执行。

您可以简单地允许任何消费者调用Add方法,因为SharedDictionary类Add方法内的锁定语句将具有相同的效果。此时,您有多余的锁定。只有在必须对字典对象执行两个需要连续发生的操作时,才会在字典方法之外锁定SyncRoot。


1
不是这样的...如果您在一个代码块中连续执行两个内部线程安全的操作,那并不意味着整个代码块都是线程安全的。例如:if(!myDict.ContainsKey(someKey)) { myDict.Add(someKey, someValue); } 即使ContainsKey和Add是线程安全的,上述代码块也不是线程安全的。 - Tim
你的观点是正确的,但与我的回答和问题不相关。如果你看一下问题,它并没有讨论调用ContainsKey,我的回答也没有。我的回答提到了在原始问题中展示的在SyncRoot上获取锁定。在lock语句的上下文中,一个或多个线程安全操作确实会安全地执行。 - Peter Meyer
我猜如果他一直在向字典中添加内容,但由于他有“//更多IDictionary成员...”,我认为他也会想要从字典中读取数据。如果是这种情况,那么就需要一些外部可访问的锁定机制。无论是字典本身的SyncRoot还是仅用于锁定的另一个对象,都没有这样的方案,整个代码都不是线程安全的。 - Tim
外部锁定机制就像他在问题中展示的那样:lock (m_MySharedDictionary.SyncRoot) { m_MySharedDictionary.Add(...); } -- 如果这样做,下面的代码也是完全安全的:lock (m_MySharedDictionary.SyncRoot) { if (!m_MySharedDictionary.Contains(...)) { m_MySharedDictionary.Add(...); } } 换句话说,外部锁定机制就是作用于公共属性SyncRoot的锁语句。 - Peter Meyer

0

不妨重新创建字典?如果阅读是写作的众多,则锁定将同步所有请求。

例如:

    private static readonly object Lock = new object();
    private static Dictionary<string, string> _dict = new Dictionary<string, string>();

    private string Fetch(string key)
    {
        lock (Lock)
        {
            string returnValue;
            if (_dict.TryGetValue(key, out returnValue))
                return returnValue;

            returnValue = "find the new value";
            _dict = new Dictionary<string, string>(_dict) { { key, returnValue } };

            return returnValue;
        }
    }

    public string GetValue(key)
    {
        string returnValue;

        return _dict.TryGetValue(key, out returnValue)? returnValue : Fetch(key);
    }

-6

9
我将其投票否决,因为a)这只是一个没有解释的链接,以及b)这只是一个没有解释的链接! - El Ronnoco

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