【问题标题】:ConcurrentBag<T> and lock(List<T>) which is faster to add or remove?ConcurrentBag<T> 和 lock(List<T>) 添加或删除哪个更快?
【发布时间】:2015-03-27 17:48:52
【问题描述】:

我需要对List&lt;T&gt; 执行一些线程安全操作。通常我只是简单地使用:

lock(List<T>)
{
   List<T>.Add();
   List<T>.Remove();
}

我知道还有另一种方法,使用ConcurrentBag&lt;T&gt;。但我不知道哪个更快,或任何其他差异。

编辑:

有些人只是建议我使用ConcurrentBag,因为这样更安全。但我担心这会使我的操作变慢。

我有很多线程需要在List&lt;T&gt; 中添加或删除对象,我想知道哪种方式对性能更好。

【问题讨论】:

  • horses 的强制链接 - 请确保更新您的问题与测量/具体要求。
  • 请注意,recommended 创建一个专门用于锁定而不是锁定共享对象或类型的对象(由于lock(List&lt;T&gt;) 甚至不会编译,所以你锁定的内容并不明显)。
  • 为什么不试试ConcurrentStackConcurrentQueue
  • @Gabe,我以为它是有序的,更慢。

标签: c# .net concurrency


【解决方案1】:

您只需尝试一下就可以轻松衡量不同方法的性能!这就是我刚刚得到的:

锁定列表:2.162s
ConcurrentBag:7.264s
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;

public class Test
{
    public const int NumOfTasks = 4;
    public const int Cycles = 1000 * 1000 * 4;

    public static void Main()
    {
        var list = new List<int>();
        var bag = new ConcurrentBag<int>();

        Profile("lock list", () => { lock (list) list.Add(1); });
        Profile("ConcurrentBag", () => bag.Add(1));
    }

    public static void Profile(string label, Action work)
    {
        var s = new Stopwatch();
        s.Start();

        List<Task> tasks = new List<Task>();

        for (int i = 0; i < NumOfTasks; ++i)
        {
            tasks.Add(Task.Factory.StartNew(() =>
            {
                for (int j = 0; j < Cycles; ++j)
                {
                    work();
                }
            }));
        }

        Task.WaitAll(tasks.ToArray());

        Console.WriteLine(string.Format("{0}: {1:F3}s", label, s.Elapsed.TotalSeconds));
    }
}

【讨论】:

  • +1 表示实际做科学,有趣的是在 2021 年在 dotnet core 5.0 上尝试此示例并获得以下完全不同的结果:锁定列表:1.681s ConcurrentBag:0.199s! dotnet 团队似乎一直在忙于优化 ConcurrentBag
【解决方案2】:

除非您确定线程的访问模式,否则不要使用ConcurrentBag&lt;T&gt; 替换锁定的List&lt;T&gt;,因为它在后台使用线程本地存储。

MSDN 谈论首选用法:

ConcurrentBag&lt;T&gt; 是一个线程安全的包实现,针对同一线程将同时生产和使用包中存储的数据的场景进行了优化。”

同样重要的是要注意List&lt;T&gt;有序ConcurrentBag&lt;T&gt;无序。如果您不关心收藏中的顺序,我会使用ConcurrentQueue&lt;T&gt;

关于性能,下面是来自ConcurrentBag&lt;T&gt; 的一些代码。但是要考虑的主要事情是,如果您执行 Take 并且您的线程本地存储为空,它将从其他线程窃取,这很昂贵。

当它需要窃取时,注意它是锁定的。另请注意,它可以多次锁定一个Take,因为TrySteal 可能会失败并从Steal 多次调用(未显示)。

private bool TrySteal(ConcurrentBag<T>.ThreadLocalList list, out T result, bool take)
{
    lock (list)
    {
        if (this.CanSteal(list))
        {
            list.Steal(out result, take);
            return true;
        }
        result = default (T);
        return false;
    }
}

CanSteal 期间也可能有旋转等待。

private bool CanSteal(ConcurrentBag<T>.ThreadLocalList list)
{
    if (list.Count <= 2 && list.m_currentOp != 0)
    {
        SpinWait spinWait = new SpinWait();
        while (list.m_currentOp != 0)
            spinWait.SpinOnce();
    }
    return list.Count > 0;
} 

最后,即使添加也可能导致锁定。

private void AddInternal(ConcurrentBag<T>.ThreadLocalList list, T item)
{
    bool lockTaken = false;
    try
    {
        Interlocked.Exchange(ref list.m_currentOp, 1);
        if (list.Count < 2 || this.m_needSync)
        {
            list.m_currentOp = 0;
            Monitor.Enter((object) list, ref lockTaken);
        }
        list.Add(item, lockTaken);
    }
    finally
    {
        list.m_currentOp = 0;
        if (lockTaken)
            Monitor.Exit((object) list);
    }
}

【讨论】:

  • 想解释一下否决票? ConcurrentBag&lt;T&gt; 不是锁定列表的合适替代品。
  • 为什么要关注 TLS 的使用?
  • @Gabe 请阅读msdn.microsoft.com/en-us/library/dd997305(v=vs.110).aspx,了解有关 TLS 为何值得关注的更多信息。要点是,如果从袋子中拉出的同一根线与插入袋子的同一根线不同,则会产生很大的间接成本。
  • 更一般的建议可能是:除非您知道自己在做什么,否则不要尝试多线程代码。不完全了解幕后情况的人不太可能偶然发现提高性能的解决方案。
  • 我看到 ConcurrentBag 是如何针对同线程场景进行优化的,但我没有看到任何表明非同线程使用会很慢或为什么使用 TLS 将是一个问题。
【解决方案3】:

List 操作 addremove 是 O(n),这意味着锁定的持续时间将取决于列表的大小。您的列表越大,您的并发性就越少。但是,如果您总是在末尾添加并从末尾删除,那么您实际上有一个堆栈。在这种情况下,addremove 操作是 O(1) 并且您将拥有更短的锁。

ConcurrentBag 被实现为链表的链表(每个线程一个。操作 addtake 是 O(1) 并且在一般情况下不需要锁。事实上锁通常可以避免意味着它可能会更快。

【讨论】:

  • 不是List.Add O(1)吗?
  • @DaveZych:是的,但只在每个add 中摊销。任何给定的add 可能需要一个操作或可能需要 N 个操作。如果你连续调用add 1,048,576 次,最后一次调用将需要执行 1,048,576 次操作。
  • @DaveZych 再说一次,这只是添加到最后。添加其他任何地方涉及将列表中的每个项目移动到它之后,即使它不需要需要分配一个新的后备数组,它也会变成 O(n)。
  • @Servy 没错,但那是Insert,我特意引用了Add
猜你喜欢
  • 2011-03-13
  • 1970-01-01
  • 2010-12-21
  • 1970-01-01
  • 2023-04-11
  • 1970-01-01
  • 1970-01-01
  • 2014-04-14
  • 1970-01-01
相关资源
最近更新 更多