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

Posted

技术标签:

【中文标题】为啥 ConcurrentBag<T> 在 .Net (4.0) 中这么慢?我做错了吗?【英文标题】:Why is ConcurrentBag<T> so slow in .Net (4.0)? Am I doing it wrong?为什么 ConcurrentBag<T> 在 .Net (4.0) 中这么慢?我做错了吗? 【发布时间】:2011-06-14 17:04:40 【问题描述】:

在开始一个项目之前,我编写了一个简单的测试来比较 (System.Collections.Concurrent) 中的 ConcurrentBag 相对于锁定 & 列表的性能。我非常惊讶 ConcurrentBag 比使用简单列表锁定要慢 10 倍以上。据我了解,当读者和作者是同一个线程时,ConcurrentBag 效果最好。不过没想到性能比传统锁差这么多。

我已经运行了一个测试,其中两个并行 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);

【问题讨论】:

在这个基准测试中,您真的不需要将 Parallel.For() 包装在 Task inserter 中。 @Henk,你是对的。我将它包装在一个任务中,因为在完整的代码中,还有另一个任务在该任务写入时读取。 ------------ @Rauhotz,我有一个双核盒子 @Henk,我已经修复了代码。谢谢。 【参考方案1】:

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

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

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

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


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

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

很明显,这完全取决于您对这些集合所做的工作。

【讨论】:

我确实读过它,但正如我在原帖中提到的,它所花费的时间是成比例的。但是,它可能不是一个有效的测试,因为 Parallel.For 为这两种情况创建新线程的方式不同 @TriArc:对不起,我是从你发布的代码而不是你写的文字出发的(我经常这样做)。我现在看到您您测试了并发读/写,但没有看到代码,很难说那里发生了什么。您是否使用 1 位读者和 1 位作者进行了测试?我只能告诉你的是,我对System.Collections.Concurrent 中的集合的理解是,它们被设计为可扩展,因此衡量其优势的最佳测试将是涉及大量读者的测试和/或作家。 @TriArc:但无论如何,我建议至少看看我在 pastebin 上发布的程序。也许将其与您正在做的事情进行比较,看看是什么让我们的测试与众不同,这将对这个主题有所了解。 嘿,刚刚在研究 ConcurrentBag 的问题时发现了这一点,有趣的是,我从未从集合中读取过(直到线程完成写入并重新加入父级之后)。特殊情况涉及将数据划分为多个集合——因此,可能并不像您想象的那么罕见;-)【参考方案2】:

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

查看以下 Ayende 帖子了解更多信息

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

【讨论】:

【参考方案3】:

作为一般答案:

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

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

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

【讨论】:

【参考方案4】:

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

根据我在争用中看到的情况,ConcurrentBag&lt;T&gt; 实现分配内存花费了很多时间。从List&lt;T&gt; 代码中删除显式分配大小会减慢它的速度,但不足以产生影响。

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

【讨论】:

【参考方案5】:

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

source - The HIGH cost of ConcurrentBag in .NET 4.0

【讨论】:

不是我,但可能是因为它的答案重复? 那篇文章很糟糕;作者正在测试创建大量 ConcurrentBag 并将它们放入对象列表中,而不是实际测试制作 1 个袋子并将大量对象放入袋子本身。【参考方案6】:

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

【讨论】:

它们确实使用相同的线程数。要验证,请将 lst1.Add(i); 替换为 lst1.Add(ThreadId); 并在结果上执行 .Distinct() 我曾想过这种可能性,但还没有测试过(除了用调试器粗略地看一眼)。我尝试为每个案例专门启动 40 个任务。 ConcurrentBag 在高达一百万的情况下速度更快,但对于任何更大的情况,它的速度都会减慢。它的速度实际上是合理的。【参考方案7】:

您基本上只有很少的并发写入并且没有争用(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);
    

【讨论】:

这是一个如此细粒度的操作,每次添加创建任务都会增加太多开销。 @Darin 您的新示例现在以 5 毫秒等待为主 - 这显然会以无意义的方式扭曲结果? 5 毫秒是永恒的。 @Darin 是的线程被争用,但 5 毫秒是永远的。如果我们推断这一点,我可以采用任何类似的算法,等待 10 年,然后说结果具有可比性。 @Darin 那是因为您已将 5ms 等待放置在列表的锁内。这不是一个可比的测试。您需要将等待放置在锁之外以使其具有可比性。 @Darin 您需要将等待放在锁外才能进行比较。【参考方案8】:

我的猜测是锁不会经历太多争用。我建议阅读以下文章:Java theory and practice: Anatomy of a flawed microbenchmark。这篇文章讨论了一个锁微基准。如文章所述,在这种情况下需要考虑很多事情。

【讨论】:

同意 - 非常值得一读,看看在尝试进行小规模基准测试时会出现什么问题。【参考方案9】:

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

两个问题

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

2) 当另一个线程正在写入时,bag 与 list 的读取速度有多快

【讨论】:

【参考方案10】:

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

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

How to: Speed Up Small Loop Bodies

【讨论】:

【参考方案11】:

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

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

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

【讨论】:

以上是关于为啥 ConcurrentBag<T> 在 .Net (4.0) 中这么慢?我做错了吗?的主要内容,如果未能解决你的问题,请参考以下文章

C# ConcurrentBag与list

线程安全ConcurrentBag

c# 并行运算

Parallel ForEach For 多线程并行计算使用注意

为啥 `IList<T>` 不从 `IReadOnlyList<T>` 继承?

为啥 Kotlin Array<T> 不实现 Iterable<T>