42

在开始一个项目之前,我写了一个简单的测试来比较 (System.Collections.Concurrent) 中的 ConcurrentBag 相对于锁定 & 列表的性能。我非常惊讶 ConcurrentBag 比使用简单列表锁定要慢 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 不一定启动相同数量的线程。要带走的一点是不要做任何你不必锁内做的事情。在上述情况下,我看不到自己在锁内做任何事情,除了可能将值分配给临时变量。

此外,sixlettervariables 指出,恰好正在运行的线程数也可能会影响结果,尽管我尝试以相反的顺序运行原始测试并且 ConcurrentBag 仍然较慢。

我从 15 个任务开始进行了一些测试,结果取决于集合大小等。但是,对于多达 100 万次插入,ConcurrentBag 的性能几乎与锁定列表一样好或更好。超过 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);
4

11 回答 11

43

让我问你这个问题:你有一个不断添加到集合中但从不读取的应用程序有多现实?这样的收藏有什么用?(这不是一个纯粹的修辞问题。我可以想象有一些用途,例如,您只在关闭时(用于记录)或用户请求时从集合中读取。不过,我相信这些情况相当罕见。)

这就是您的代码正在模拟的内容。List<T>.Add除了列表必须调整其内部数组大小的偶尔情况外,调用将是闪电般的快速;但这被所有其他很快发生的添加所消除。因此,您不太可能在这种情况下看到大量的争用,尤其是在具有例如 8 个内核的个人 PC 上进行测试(正如您在某处的评论中所说的那样)。也许您可能会在诸如 24 核机器之类的机器上看到更多争用,其中许多内核可以同时尝试添加到列表

争用更有可能蔓延到你从你的收藏中阅读的地方,尤其是。inforeach循环(或相当于foreach引擎盖下的循环的 LINQ 查询)需要锁定整个操作,这样您就不会在迭代它时修改您的集合。

如果您可以真实地重现此场景,我相信您会看到ConcurrentBag<T>比当前测试显示的规模要好得多。


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

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

很明显,这取决于您对这些集合所做的事情。

于 2011-01-24T20:01:01.437 回答
15

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

有关更多信息,请参阅以下 Ayende 帖子

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

于 2012-06-07T14:51:49.327 回答
10

作为一般答案:

  • 如果数据争用很少或没有争用(即锁),使用锁定的并发集合可以非常快。这是因为这样的集合类通常是使用非常便宜的锁定原语构建的,尤其是在不满足时。
  • 无锁集合可能会更慢,因为用于避免锁定的技巧以及其他瓶颈,例如错误共享,实现其无锁性质所需的复杂性导致缓存未命中等......

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

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

于 2011-01-24T18:29:41.310 回答
9

使用 MS 的争用可视化工具查看程序表明,ConcurrentBag<T>与简单地锁定List<T>. 我注意到的一件事是似乎与旋转 6 个线程(在我的机器上使用)开始第一个线程相关联ConcurrentBag<T>运行(冷运行)相关的成本。然后将 5 或 6 个线程与List<T>代码一起使用,这样更快(热运行)。在列表之后添加另一个ConcurrentBag<T>运行表明它比第一次(热运行)花费的时间更少。

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

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

于 2011-01-24T19:38:20.163 回答
5

这已经在 .NET 4.5 中得到解决。根本问题是 ConcurrentBag 使用的 ThreadLocal 并没有预料到会有很多实例。该问题已修复,现在可以运行得相当快。

来源 - .NET 4.0 中 ConcurrentBag 的高成本

于 2012-09-03T22:52:09.027 回答
3

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

于 2011-01-24T18:54:59.350 回答
1

我的猜测是锁不会经历太多争用。我建议阅读以下文章:Java 理论与实践:有缺陷的微基准的剖析。这篇文章讨论了一个锁微基准。如文章中所述,在这种情况下需要考虑很多事情。

于 2011-01-24T21:06:29.277 回答
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);
    }
}
于 2011-01-24T18:37:15.350 回答
0

由于循环体很小,您可以尝试使用 Partitioner 类的 Create 方法...

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

如何:加速小循环体

于 2011-02-24T00:30:53.653 回答
0

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

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

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

于 2011-04-01T21:02:01.920 回答
0

看到它们两者之间的缩放会很有趣。

两个问题

1)bag vs list读取速度有多快,记得在list上加个锁

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

于 2011-01-24T18:40:29.893 回答