在.NET4.0之前,如果我们需要在多线程环境下使用Dictionary类,除了自己实现线程同步来保证线程安全外,我们没有其他选择。很多开发人员肯定都实现过类似的线程安全方案,可能是通过创建全新的线程安全字典,或者仅是简单的用一个类封装一个Dictionary对象,并在所有方法中加上锁机制,我们称这种方案叫“Dictionary+Locks”。
但是,我们有了ConcurrentDictionary,在MSDN中的Dictionary类文档的线程安全的描述中指出,如果你需要用一个线程安全的实现,请使用ConcurrentDictionary。所以,既然现在已经有了一个线程安全的字典类,我们再也不需要自己实现了,很棒,不是吗?
一、问题起源
事实上,我之前只使用过ConcurrentDictionary一次,就是在我测试其反应速度的测试中。因为在测试中它表现得很好,所以我立即把它替换到了我得类中,并做了些测试,然后,居然出了异常。那么,到底哪里出了问题?不是说线程安全吗?经过了更多得测试,我找到了问题得根源,但不知道为什么,MSDN的4.0版本中,关于GetOrAdd方法签名的描述没有包含一个需要传递一个委托类型参数的说明,在查看4.5版本后,我找到了这段备注:If you call GetOrAdd simultaneously on different threads, addValueFactory may be called multiple times, but its key/value pair might not be added to the dictionary for every call.
这就是我碰到的问题,因为之前的文档中并没有描述,所以我不得不做了更多的测试来确认这个问题,当然,我碰到的问题与我的使用方法有关,一般来说,我会使用字典类型来缓存一些数据:
1、这些数据创建起来非常慢。
2、这些数据只能创建一次,因为创建第二次会抛出异常,或者多次创建会导致资源泄露。
我就是在第二个条件上遇到了问题,如果两个线程同时发现某个数据不存在,都会创建一个该数据,但只有一个结果会被成功的保存,那么另一个怎么办?如果创建的过程会抛出异常,可以通过try……catch来解决(虽然不够优雅,但能解决问题)。但如果某个资源被创建后未被回收该怎么办?你可能会说,一个对象被创建后,如果已经对其没有任何引用,将会被垃圾回收掉,但,请在考虑以下,如果下面描述的情形发生了,会怎样:
1、使用Email动态生成代码,我在一个Remoting框架中使用了这种方式,并且将所有的实现都放到了一个不能被回收的程序集中,如果一个类型被创建了两次,第二个将一直存在,即使其从未被使用过。
2、直接的或间接地创建一个线程,比如我们需要创建一个组件,其使用专有地线程处理异步消息,并且依赖于消息地接受顺序。当实例化该组件时,会创建一个线程,当销毁这个组件时,线程也会被结束。但如果销毁组件后我们删除了对该对象地引用,但那个线程因某种原因未结束,并且持有这个对象地引用,那么,如果线程不死亡,这个对象也不会被回收。
3、进行P/Invoke操作,需要对所接受到地句柄地关闭次数必须与打开次数相同。
4、可以肯定的是,还可以列举出很多类似的情形,比如一个字典对象会持有一个远程服务上的一个服务的连接,该连接只能请求一次,如果请求第二次,对方服务会认为发生了某种错误,进而记录到日志中,(我工作过的一个公司,这种条件会遭到一些法律上的处罚),所以我们很容易的看到,并不能草率的将Dictionary+Locks直接替换成ConcurrentDictionary,即使文档上说它是线程安全的。
二、分析问题
还不明白?
的确,在Dictionary+Locks方式下可不会产生这个问题,因为这依赖于具体的实现,让我们来看下面一个简单的实例:
1 TValue result;
2 lock(dictionary)
3 {
4 if (!dictionary.TryGetValue(key, out result))
5 {
6 result = createValue(key);
7 dictionary.Add(key, result);
8 }
9 }
10 return result;
在上面的这段代码中,在开始查询键值之前,我们持有了对该字典的锁,如果指定的键值不存在,将会直接创建一个,同时,因为我们已经持有了对该字典的锁,可以直接将键值对添加到字典中,然后释放字典锁。如果两个线程同时在查询同一个键值,第一个得到字典锁的线程将会完成对象的创建工作,另一个线程会等待这个创建的完成,并在得到字典锁之后获取已创建的键值结果。
这样挺好的,不是吗?
真不是!我认为像这种在并行方式下创建对象,最后只有一个被使用的情况不会产生我所描述的问题。我想阐述的情况和问题可能并不总是能复现,在并行环境中,我们可以简单的创建两个对象,然后丢弃一个。那么,到底我们改如何比较Dictionary+Locks和ConcurrentDictionary呢?答案是:具体依赖于锁使用策略和字典的使用方式。
三、并行创建同一对象
首先,我们假设某个对象可以被创建两次,那么如果有两个线程在同时创建这个对象时,会发生什么?其次,在类似的创建过程中,我们会消耗多长时间?
我们可以简单的构建一个例子,比如实例化一个对象需要耗时10秒钟,当第一个线程创建对象5秒钟后,第二个实现尝试调用GetOrAdd方法来获取对象,因为对象仍然不存在,所以它也开始创建对象。在这种条件下,我们有两颗CPU在并行工作5秒钟,当第一个线程工作结束后,第二个线程仍然需要继续运行5秒钟来完成对象的创建,当第二个线程构建对象完毕后,发现已经有一个对象存在了,其选择使用已存在的对象,而将刚创建的对象直接丢弃。
假如第二个线程只是简单等待,而让第二颗CPU处理其他工作(运行其他线程或应用程序,节省了些资源消耗),在5秒钟之后其就可以获取到所需的对象,而不是10秒钟。所以,在这种条件下,Dictionary+Locks更优一些。
四、并行访问不同对象
不,你说的情况根本就不成立!
好吧,上面的例子有点特殊,但确实描述了问题,只是这种用法比较极端。那么,考虑下,如果当第一个线程正在创建对象时,第二个线程需要访问另一个键值对象,并且该键值对象已经存在,会发生什么?
在ConcurrentDictionary中,由于其没有对读操作进行加锁,也就是Lock-Free的设计会使读操作非常迅速,如果Dictionary+Locks方式,会对读操作进行锁互斥控制,即使需要读取的是另一个完全不同的键值,显然读取操作会变慢。这样看来,ConcurrentDictionary更好一些。
注:大家可以了解一下字典类中的 Bucket、Node、Entry等几个概念,可能对你理解更有帮助一些。
五、多读单写
在Dictionary+Locks中,如果使用多个读取方、单一写入的方式(Mutiple Readers and Single Writer)来取待对字典的完全锁,情况会如何?
如果一个线程正在创建对象,并且持有了一个可升级的锁,直到这个对象创建完毕,将该锁升级为写操作锁,那么读操作就可以在并行的环境下执行。我们也可以通过让一个读操作空闲等待10秒钟来解决问题。但如果读操作远远多于写操作,我们会发现,ConcurrentDictionary的速度仍然很快,因为它实现了Lock-Free模式的读取。
对Dictionary使用ReaderWriterLockSlim会使读操作变的更糟糕,通常更推荐对Dictionary使用完全锁,而不使用ReaderWriterLockSlim。所以在这种条件下,ConcurrentDictionary更优一些。
六、添加多个键值对
如果我们有多个键值需要添加,并且所有的键不会产生碰撞并会被分配在不同的Bucket中,情况如何?
起初,这个问题还是让我很好奇地,但我做了个不太合适地测试,我使用了<int,int>类型地字典,并且对象地构造工厂会直接返回一个负数地结果作为键。我本来期待ConcurrentDictionary应该是最快地,但它却是最慢地。而Dictionary+Locks却表现的更快,这是为什么呢?
这是因为,ConcurrentDictionary会分配Node并将它们放到不同的Bucket中,这种优化是为了满足于读操作的Lock-Free的设计,但是,在新增键值项时,创建Node的过程就会显得昂贵,即使在并行的条件下,分配Node所消耗的时间仍然比使用完全锁多。所以,这种情况下Dictionary+Locks更优一些。
七、读操作频率更高
坦白的说,如果有一个能快速实例化对象的委托,我们就不需要一个Dictionary了,我们可以直接调用委托来获取对象,对吧?其实答案也是,要看情况。
想象下,如果键类型为string,并且包含web服务器中各种页面的路径映射,而对应的值为一个对象类型,该类型包含对该页当前访问用户的记录和自服务器启动后所有对该页面的访问的数量。创建类似这种对象几乎是瞬间的事情,并且在此之后,你不需要再创建新的对象,仅需要更改其中保存的值。所以可以允许创建两次的方式,直到仅有一个实例被使用,然而,因为ConcurrentDictioanry分配Node资源更慢,使用Dictionary+Locks将会得到更快的创建时间。所以通过这个例子非常特殊,我们也看到了Dictionary+Locks在这种条件下表现的更好,花费了更少的时间。
虽然ConcurrentDictionary中Node分配要慢一些,我也没有尝试将1亿个数据项放入其中来测试时间,因为那显然很花费时间。但大部分情况下,一个数据项被创建后,其总是被读取,而数据项的内容是如何变化的就是另外的事情了。所以说,创建数据项的过程多花笑了多少毫秒不重要,因为读取操作更快(也是快了若干毫秒而已),但读操作发生的频率更高。所以,ConcurrentDictionary更优一些。
八、创建消耗不同时间的对象
针对不同数据项的创建所消耗的时间不同,将会怎样?
创建多个消耗不同时间的数据项,并且并行的添加至字典中,这是ConcurrentDictionary的最强点。
ConcurrentDictionary使用了多种不同的锁机制来允许并发地添加数据项,但是诸如决定使用哪个锁,为改变Bucket尺寸而请求锁等逻辑,并没有为此带来帮助,把数据项放入Bucket中地速度是机器快速的。真正使ConcurrentDictionary胜出的原因是因为它能够并行的创建对象。
不过,其实我们也可以做同样的事情,如果我们并不关心是否在并行的创建对象,或者其中的一些已经被丢弃,我们可以加锁,用来检测该数据项是否已经存在,然后释放锁,创建数据项,然后再获取锁,再次检查数据项是否存在,如果不存在,则添加数据项,代码可能类似于:
1 int result;
2 lock(_dictionary)
3 if (_dictionary.TryGetValue(i, out result))
4 return result;
5
6 int createdResult = _createValue(i);
7 lock(_dictionary)
8 {
9 if (_dictionary.TryGetValue(i, out result))
10 return result;
11
12 _dictionary.Add(i, createdResult);
13 return createdResult;
14 }
注:我使用了一个<int,int>类型的字典。
在上面的简单的结构中,当在并行条件下创建并添加数据项时,Dictionary+Locks的表现几乎和ConcurrentDictionary一样好,但也有同样的问题,就是某些值可能被生成来,但从没被使用过。
九、结论
那么,有结论吗?此时此刻,还是有一些的:
1、所有的字典速度都非常快,即使我已经创建了上百万的数据,速度依然很快,通常情况下,我们只是创建少量的数据项,并且读取还有一些时间间隔,所以我们一般不会察觉到读取数据项的时间开销。
2、如果相同的对象不能被创建两次,则不要使用ConcurrentDictionary。
3、如果你的确很关注性能问题,可能Dictionary+Locks仍然是一个很好的方案,重要的因素是,添加和删除数据项的数量,但如果是读操作过多 ,就建议用ConcurrentDictionary。
4、虽然我没有介绍,但其实使用Dictionary+Locks方案会有更大的自由性,比如你可以锁定一次,添加多个数据项,删除多个数据项,或者查询多次等等,之后再释放锁。
5、一般来说,如果读操作远远多于写操作,可避免使用ReaderWriterLockSlim,字典类型北河完全锁已经比获取一个读写锁中的读锁快很多了,当然,也依赖于在一个锁中创建对象锁消耗的时间。
所以,我认为尽管举的例子有些极端,但却表明了使用ConcurrentDictionary并不总是最好的方案。