为什么ConcurrentBag <T>在.Net(4.0)中很慢? 我做错了吗?

在开始一个项目之前,我写了一个简单的testing来比较来自(System.Collections.Concurrent)的ConcurrentBag相对于locking和列表的性能。 我感到非常惊讶的是,ConcurrentBag比locking一个简单的列表要慢10倍以上。 据我所知,当读者和作者是同一个线程时,ConcurrentBag效果最好。 但是,我没有想到它的性能会比传统的锁更糟糕。

我已经运行了一个testing,用两个Parallel for循环写入和从列表/包中读取。 但是,写本身就performance出巨大的差异:

private static void ConcurrentBagTest() { int collSize = 10000000; Stopwatch stopWatch = new Stopwatch(); ConcurrentBag<int> bag1 = new ConcurrentBag<int>(); stopWatch.Start(); Parallel.For(0, collSize, delegate(int i) { bag1.Add(i); }); stopWatch.Stop(); Console.WriteLine("Elapsed Time = {0}", stopWatch.Elapsed.TotalSeconds); } 

在我的盒子里,这需要3-4秒的时间才能运行,相比之下,这个代码是0.5 – 0.9秒:

  private static void LockCollTest() { int collSize = 10000000; object list1_lock=new object(); List<int> lst1 = new List<int>(collSize); Stopwatch stopWatch = new Stopwatch(); stopWatch.Start(); Parallel.For(0, collSize, delegate(int i) { lock(list1_lock) { lst1.Add(i); } }); stopWatch.Stop(); Console.WriteLine("Elapsed = {0}", stopWatch.Elapsed.TotalSeconds); } 

正如我所提到的,并发读取和写入不会帮助并发包testing。 我做错了什么,或者这个数据结构真的很慢?

[编辑] – 我删除了任务,因为我不需要他们在这里(完整的代码有另一个任务阅读)

[编辑]非常感谢答案。 我很难挑选“正确的答案”,因为它似乎是几个答案的组合。

正如Michael Goldshteyn指出的那样,速度真的取决于数据。 Darin指出,ConcurrentBag应该有更多的争用速度,而Parallel.For并不一定会启动相同数量的线程。 有一点可以拿走,就是不要做任何你不必锁里面的东西。 在上面的例子中,除了可能将值分配给一个临时variables外,我没有看到自己正在执行任何操作。

此外,sixlettervariables指出,碰巧正在运行的线程的数量也可能会影响结果,虽然我试着以相反的顺序运行原始testing,ConcurrentBag仍然较慢。

我跑了一些testing,开始15个任务,结果取决于集合的大小等等。 但是,ConcurrentBag的performance几乎与locking列表一样好,甚至超过100万个插入。 一百万以上,锁有时似乎要快得多,但是我的项目可能永远不会有更大的数据结构。 这是我跑的代码:

  int collSize = 1000000; object list1_lock=new object(); List<int> lst1 = new List<int>(); ConcurrentBag<int> concBag = new ConcurrentBag<int>(); int numTasks = 15; int i = 0; Stopwatch sWatch = new Stopwatch(); sWatch.Start(); //First, try locks Task.WaitAll(Enumerable.Range(1, numTasks) .Select(x => Task.Factory.StartNew(() => { for (i = 0; i < collSize / numTasks; i++) { lock (list1_lock) { lst1.Add(x); } } })).ToArray()); sWatch.Stop(); Console.WriteLine("lock test. Elapsed = {0}", sWatch.Elapsed.TotalSeconds); // now try concurrentBag sWatch.Restart(); Task.WaitAll(Enumerable.Range(1, numTasks). Select(x => Task.Factory.StartNew(() => { for (i = 0; i < collSize / numTasks; i++) { concBag.Add(x); } })).ToArray()); sWatch.Stop(); Console.WriteLine("Conc Bag test. Elapsed = {0}", sWatch.Elapsed.TotalSeconds); 

让我问你:你有一个应用程序是不断添加到一个集合,永远不会读取它是多么的现实? 这样的collections有什么用? (这不是一个纯粹的修辞问题,我可以想象有什么用途,例如,你只能从关机(用于日志logging)或用户请求时才从集合中读取数据,但我相信这些场景相当罕见。

这就是你的代码正在模拟的东西。 调用List<T>.Add在所有情况下都将快如闪电,除了列表必须调整其内部arrays的偶然情况。 但是所有其他的增加很快就会被平滑掉。 所以在这种情况下,你不太可能看到大量的争用, 特别是在个人电脑上testing,甚至是8核(就像你在某个地方发表评论的那样)。 也许你可能会在24核心机器上看到更多的争论,许多内核可能会试图同时添加到列表中。

在你collections的地方,竞争更可能蔓延,特别是 在foreach循环(或LINQ查询,这等于foreach循环下的foreach )需要locking整个操作,以便您在迭代时不修改您的集合。

如果您能够真实地再现这种情况,我相信您会看到ConcurrentBag<T>比您当前的testing显示的好得多。


更新 : 这是我写的一个程序,用来比较上述场景(多个作者,许多读者)中的这些集合。 运行25个试验,收集10000个和8个阅读器线程,结果如下:

花了529.0095 ms将10000个元素添加到具有8个读取器线程的List <double>。
花了39.5237毫秒将10000个元素添加到具有8个读取器线程的ConcurrentBag <double>。
花了309.4475毫秒将10000个元素添加到具有8个读取器线程的List <double>。
花了81.1967 ms将10000个元素添加到具有8个读取器线程的ConcurrentBag <double>。
花了228.7669毫秒将10000个元素添加到具有8个读取器线程的List <double>。
花了164.8376毫秒将10000个元素添加到具有8个读取器线程的ConcurrentBag <double>。
 [...]
 平均上榜时间:176.072456毫秒。
 平均包装时间:59.603656毫秒。

很显然,这取决于你对这些藏品做了什么。

微软在4.5版中修复的.NET Framework 4似乎存在一个错误,似乎他们并不期望ConcurrentBag被大量使用。

有关更多信息,请参阅以下Ayendepost

http://ayende.com/blog/156097/the-high-cost-of-concurrentbag-in-net-4-0

作为一般答案:

  • 如果数据很less或没有争用(即locking),那么使用locking的并发集合可以非常快速。 这是因为这样的集合类通常使用非常便宜的locking基元来构build,尤其是在不受约束的情况下。
  • 无锁集合可能会比较慢,因为用于避免locking的技巧以及由于其他瓶颈(如错误共享,实现其无锁性质导致caching未命中所需的复杂性等)。

总之,关于哪种方式更快的决定高度依赖于所使用的数据结构以及锁之间争用的数量(例如,数量阅读器与共享/专用types安排中的作者)。

你的具体例子有很高的争议,所以我必须说我对这个行为感到惊讶。 另一方面,保持锁的工作量非常小,毕竟,对于锁本身来说,也许几乎没有什么争议。 ConcurrentBag的并发处理的实现也可能存在缺陷,这使得你的特定的例子(频繁的插入和没有读取)是一个糟糕的用例。

使用MS的争用可视化工具查看程序,显示ConcurrentBag<T>与并行插入相比成本要高得多,而不是简单地lockingList<T> 。 我注意到的一件事是看起来与旋转6个线程(用在我的机器上)开始第一个ConcurrentBag<T>运行(冷运行)相关的成本。 然后使用5或6个线程与List<T>代码,这是更快(温暖运行)。 在列表之后添加另一个ConcurrentBag<T>运行将显示比第一个(温度运行)花费更less的时间。

从我所看到的争论中,在ConcurrentBag<T>实现分配内存中花费了很多时间。 从List<T>代码中删除显式大小的分配会减慢它的速度,但不足以产生影响。

编辑:它似乎是ConcurrentBag<T>内部每个Thread.CurrentThread保持一个列表,locking2-4次,取决于它是否在新线程上运行,并执行至less一个Interlocked.Exchange 。 正如在MSDN中指出的那样:“针对同一线程既生成又消耗存储在数据包中的数据的情况进行了优化。 这是您的性能下降与原始列表最可能的解释。

这已经在.NET 4.5中解决了。 根本的问题是,ConcurrentBag使用的ThreadLocal没有期望有很多实例。 那已经被修复了,现在可以跑得相当快了。

源代码 – .NET 4.0中的ConcurrentBag的高成本

正如@ Darin-Dimitrov所说,我怀疑你的Parallel.For实际上并不是在这两个结果中产生相同数量的线程。 尝试手动创buildN个线程,以确保在两种情况下实际上都看到线程争用。

你基本上有很less的并发写入和没有争用( Parallel.For不一定意味着许multithreading)。 尝试并行写入,你会看到不同的结果:

 class Program { private static object list1_lock = new object(); private const int collSize = 1000; static void Main() { ConcurrentBagTest(); LockCollTest(); } private static void ConcurrentBagTest() { var bag1 = new ConcurrentBag<int>(); var stopWatch = Stopwatch.StartNew(); Task.WaitAll(Enumerable.Range(1, collSize).Select(x => Task.Factory.StartNew(() => { Thread.Sleep(5); bag1.Add(x); })).ToArray()); stopWatch.Stop(); Console.WriteLine("Elapsed Time = {0}", stopWatch.Elapsed.TotalSeconds); } private static void LockCollTest() { var lst1 = new List<int>(collSize); var stopWatch = Stopwatch.StartNew(); Task.WaitAll(Enumerable.Range(1, collSize).Select(x => Task.Factory.StartNew(() => { lock (list1_lock) { Thread.Sleep(5); lst1.Add(x); } })).ToArray()); stopWatch.Stop(); Console.WriteLine("Elapsed = {0}", stopWatch.Elapsed.TotalSeconds); } } 

我的猜测是,锁不会经历太多的争论。 我会推荐阅读下面的文章: Java理论和实践:解剖一个有缺陷的微基准 。 文章讨论了一个锁microbenchmark。 正如文章所述,在这种情况下需要考虑很多事情。

看到两者之间的缩放比较有趣。

两个问题

1)袋子和列表的阅读速度有多快,记得在列表上加锁

2)在另一个线程正在写入时,包与列表的读取速度有多快

因为循环体很小,所以可以尝试使用Partitioner类的Create方法…

这使您可以为委托主体提供一个顺序循环,以便每个分区仅调用一次委托,而不是每次迭代一次

如何:加快小循环机构

看来ConcurrentBag只是​​比其他并发集合慢。

我认为这是一个实现问题,ANTS Profiler显示它陷入了两个地方 – 包括一个数组副本。

使用并发字典的速度要快上千倍。