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

时间:2011-01-24 18:26:43

标签: c# .net concurrency locking concurrent-collections

在我开始一个项目之前,我写了一个简单的测试来比较来自(System.Collections.Concurrent)的ConcurrentBag相对于lock&amp;的性能。名单。我非常惊讶ConcurrentBag比使用简单的List锁定慢10倍。据我所知,当读写器是同一个线程时,ConcurrentBag效果最好。但是,我没想到它的性能会比传统的锁更糟糕。

我已经运行了一个测试,其中有两个Parallel for循环写入和读取列表/包。然而,写作本身显示出巨大的差异:

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);
       }

正如我所提到的,进行并发读写并不能帮助并发包测试。我做错了还是这个数据结构真的很慢?

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

[编辑] 非常感谢您的回答。我很难选择“正确答案”,因为它似乎是一些答案的混合。

Michael Goldshteyn指出,速度实际上取决于数据。 Darin指出应该有更多争用ConcurrentBag更快,而Parallel.For不一定会启动相同数量的线程。要点一点就是不要做任何你在锁内没有必须的事情。在上面的例子中,我没有看到自己在锁内做任何事情,除非可能将值赋给临时变量。

此外,六个变量指出,碰巧运行的线程数也可能影响结果,尽管我尝试以相反的顺序运行原始测试并且ConcurrentBag仍然较慢。

我在启动15个任务时运行了一些测试,结果取决于集合大小等。但是,ConcurrentBag的表现几乎与锁定列表一样好或更好,最多可达100万次插入。超过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);

11 个答案:

答案 0 :(得分:43)

让我问你一个问题:你有一个不断添加到并且永远不会从中读取的应用程序的应用程序是多么现实?这样的收藏品有什么用? (这不是一个纯粹的修辞问题。我可以想象有用的地方,例如,你只在关闭时(用于记录)或用户请求时从集合中读取。我相信这些场景是但是很少见。)

这是您的代码模拟的内容。除了偶尔需要调整其内部数组大小的情况外,调用List<T>.Add将会闪电般快速。但是很快就会发生所有其他增加的问题。因此,您不太可能在此上下文中看到大量争用,尤其是在个人PC上进行测试,例如,甚至包含8个内核(正如您所声称的那样在某个地方有评论)。 也许你可能会看到更多类似于24核计算机的争论,其中许多核心可能会同时尝试添加到列表

从您的收藏中读取的地方,争用更有可能蔓延,尤其是。在foreach循环中(或LINQ查询,它们总是foreach循环)需要锁定整个操作,以便在迭代时不修改集合。

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


更新Here是我编写的一个程序,用于比较上述场景中的这些集合(多个编写器,多个读者)。运行25个试验,收集大小为10000和8个读取器线程,我得到了以下结果:

Took 529.0095 ms to add 10000 elements to a List<double> with 8 reader threads.
Took 39.5237 ms to add 10000 elements to a ConcurrentBag<double> with 8 reader threads.
Took 309.4475 ms to add 10000 elements to a List<double> with 8 reader threads.
Took 81.1967 ms to add 10000 elements to a ConcurrentBag<double> with 8 reader threads.
Took 228.7669 ms to add 10000 elements to a List<double> with 8 reader threads.
Took 164.8376 ms to add 10000 elements to a ConcurrentBag<double> with 8 reader threads.
[ ... ]
Average list time: 176.072456 ms.
Average bag time: 59.603656 ms.

很明显,这取决于你对这些系列的确切做法。

答案 1 :(得分:15)

在.NET Framework 4中似乎有一个错误,微软在4.5中修复了它,似乎他们没想到ConcurrentBag会被大量使用。

有关详细信息,请参阅以下Ayende帖子

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

答案 2 :(得分:9)

作为一般答案:

  • 如果对其数据(即锁定)的争用很少或没有,则使用锁定的并发集合可以非常快。这是因为这样的集合类通常使用非常便宜的锁定原语构建,尤其是在没有条件的情况下。
  • 无锁集合可能会变慢,因为用于避免锁定的技巧和由于其他瓶颈(例如虚假共享),实现其无锁性质导致缓存未命中所需的复杂性等等...

总而言之,哪种方式更快的决定在很大程度上取决于所采用的数据结构以及锁定在其他问题中的争用程度(例如,num读者与共享/排他型排列中的作者)。 / p>

您的特定示例具有非常高的争用程度,因此我必须说我对此行为感到惊讶。另一方面,在保持锁定时完成的工作量非常小,因此可能毕竟没有争用锁本身。 ConcurrentBag的并发处理的实现也可能存在缺陷,这使得您的特定示例(频繁插入和无读取)成为一个糟糕的用例。

答案 3 :(得分:9)

使用MS的争用可视化工具查看该程序表明ConcurrentBag<T>与并行插入相关的成本要高得多,而不是简单地锁定List<T>。我注意到的一件事是,在启动第一个ConcurrentBag<T>运行(冷运行)时,似乎需要花费6个线程(在我的机器上使用)。然后将5或6个线程与List<T>代码一起使用,这更快(热运行)。在列表后添加另一个ConcurrentBag<T>运行表明它比第一个(热运行)花费的时间更少。

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

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

答案 4 :(得分:5)

这已在.NET 4.5中解决。根本问题是ConcurrentBag使用的ThreadLocal并不期望有很多实例。这已得到修复,现在可以相当快地运行。

source - The HIGH cost of ConcurrentBag in .NET 4.0

答案 5 :(得分:3)

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

答案 6 :(得分:1)

您基本上只有很少的并发写入且没有争用(Parallel.For并不一定意味着许多线程)。尝试并行化写入,您将观察到不同的结果:

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);
    }
}

答案 7 :(得分:1)

我的猜测是锁没有太多争用。我建议阅读以下文章:Java theory and practice: Anatomy of a flawed microbenchmark。本文讨论了锁定微基准测试。正如文章中所述,在这种情况下需要考虑很多事情。

答案 8 :(得分:0)

看到两者之间的比例很有意思。

两个问题

1)行李与列表的阅读速度有多快,记得锁定列表

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

答案 9 :(得分:0)

因为循环体很小,你可以尝试使用Partitioner类Create方法......

  

使您能够提供   委托机构的顺序循环,   这样只调用委托   每个分区一次,而不是一次   每次迭代

How to: Speed Up Small Loop Bodies

答案 10 :(得分:0)

ConcurrentBag似乎比其他并发集合慢。

我认为这是一个实现问题 - ANTS Profiler显示它在几个地方陷入困境 - 包括数组副本。

使用并发字典要快几千倍。