在.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并不总是最好的方案。

浅谈ConcurrentDictionary与Dictionary的更多相关文章

  1. 浅谈WebService的版本兼容性设计

    在现在大型的项目或者软件开发中,一般都会有很多种终端, PC端比如Winform.WebForm,移动端,比如各种Native客户端(iOS, Android, WP),Html5等,我们要满足以上所 ...

  2. 浅谈JAVA集合框架

    浅谈JAVA集合框架 Java提供了数种持有对象的方式,包括语言内置的Array,还有就是utilities中提供的容器类(container classes),又称群集类(collection cl ...

  3. [C#]6.0新特性浅谈

    原文:[C#]6.0新特性浅谈 C#6.0出来也有很长一段时间了,虽然新的特性和语法趋于稳定,但是对于大多数程序猿来说,想在工作中用上C#6.0估计还得等上不短的一段时间.所以现在再来聊一聊新版本带来 ...

  4. 浅谈Swift语法

    Apple 在2014年6月的WWDC公布了一款新型的开发语言,很多美国程序猿的价值观貌似和我们非常大的不同,在公布的时候我们能够听到,场下的欢呼声是接连不断的.假设换作我们,特别是像有Objecti ...

  5. 【ASP.NET MVC系列】浅谈表单和HTML辅助方法

    [01]浅谈Google Chrome浏览器(理论篇) [02]浅谈Google Chrome浏览器(操作篇)(上) [03]浅谈Google Chrome浏览器(操作篇)(下) [04]浅谈ASP. ...

  6. 浅谈C#常用集合类的实现以及基本操作复杂度

    List 集合类是顺序线性表,Add操作是O(1)或是O(n)的,由于List的容量是动态扩容的,在未扩容之前,其Add操作是O(1),而在需要扩容的时候,会拷贝已存在的那些元素同时添加新的元素,此时 ...

  7. 【ASP.NET MVC系列】浅谈ASP.NET MVC 路由

    ASP.NET MVC系列文章 [01]浅谈Google Chrome浏览器(理论篇) [02]浅谈Google Chrome浏览器(操作篇)(上) [03]浅谈Google Chrome浏览器(操作 ...

  8. 铁乐学Python_day07_集合and浅谈深浅copy

    1.[List补充] 在循环一个列表时,最好不要使用元素和索引进行删除操作,一旦删除,索引会随之改变,容易出错. 如果想不出错,可以采用倒着删除的方法,因为倒着删除进行的话,只是后面元素的位置发生了变 ...

  9. 【转】.NET(C#):浅谈程序集清单资源和RESX资源 关于单元测试的思考--Asp.Net Core单元测试最佳实践 封装自己的dapper lambda扩展-设计篇 编写自己的dapper lambda扩展-使用篇 正确理解CAP定理 Quartz.NET的使用(附源码) 整理自己的.net工具库 GC的前世与今生 Visual Studio Package 插件开发之自动生

    [转].NET(C#):浅谈程序集清单资源和RESX资源   目录 程序集清单资源 RESX资源文件 使用ResourceReader和ResourceSet解析二进制资源文件 使用ResourceM ...

随机推荐

  1. Vue项目中跨域问题解决

    后台更改header 使用http-proxy-middleware 代理解决(项目使用vue-cli脚手架搭建) Jquery jsonp 一.后台更改header header('Access-C ...

  2. tomcat更新class不生效

    替换线上lib里的class不生效,需要想想是不是前人为了图方便在classes里面扔了一份老版本class

  3. 35)PHP,关于PHP和html

    (1)其实无论是CSS还是js,又或者是html,都是可以随意的载入到我们的php文件中,其实这些文件就是一个外来的引入文件,所以,根本没有什么神奇的, 你要是想把php的结果有调理的展示,那么就直接 ...

  4. Linux的基础知识

    什么是操作系统? 操作系统是人与计算机的中介. 操作系统是干什么的? 控制所有资源{硬件资源和软件资源(驱动,应用软件)} 常用的操作系统:Unix Windows Linux Linux的哲学思想: ...

  5. HTML常用数据类型

    .数学函数: Math.ceil():天花板数 //大于当前小数的最小整数 Math.floor():地板数 //小于当前小数的最大整数 Math.round():四舍五入取整数 Math.rando ...

  6. [Python] 使用Python 3 下载麦子学院视频

    本文基于Python 3,下载麦子学院的视频课程. 本项目只是针对某个具体课程的链接,去寻找该课程所有课时的视频链接并进行下载. 整个项目是非常简单的. 主要涉及的Python: 网络相关:reque ...

  7. 安装rpm包时遇到error: Failed dependencies:错误

    在linux下安装rpm包时经常会遇到下面这个问题: error: Failed dependencies: ............................................. ...

  8. linux系统--C语言程序开发的基本步骤(包含gcc的基本步骤)

    1.使用vi或者vim编写程序文件 2.使用gcc把所有的源文件翻译成计算机认识的格式(编译) 3.使用./a.out作为命令执行得到的可执行文件 gcc编译器的工作步骤: 1.处理所有的预处理指令 ...

  9. freeRadius设置任意账号密码认证通过

    [root@wifi_radiusdproxy_16 raddb]# cat users # # Please read the documentation file ../doc/processin ...

  10. python3之scrapy数据存储问题(MySQL)

    这次我用的是python3.6,scrapy在python2.7,3.5的使用方法都不同所以要特别注意, 列如 在python3.5的开发环境下scrapy 的主爬虫文件可以使用 from urlli ...