【问题标题】:ConcurrentBag vs Custom Thread Safe ListConcurrentBag 与自定义线程安全列表
【发布时间】:2015-05-29 13:38:52
【问题描述】:

我有一个 .NET 4.5 单实例 WCF 服务,它维护一个列表中的项目集合,该列表将同时具有并发的读取器和写入器,但读取器的数量远多于写入器。

我目前正在决定是使用 BCL ConcurrentBag<T> 还是使用我自己的自定义泛型 ThreadSafeList 类(它扩展了 IList<T> 并封装了 BCL ReaderWriterLockSlim,因为这更适合多个并发阅读器)。

通过模拟 100 万读者(简单地运行 Sum Linq 查询)和只有 100 名作者(向列表中添加项目)的并发场景来测试这些实现时,我发现了许多性能差异。

对于我的性能测试,我有一个任务列表:

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

测试 1:如果我使用以下代码创建 1m 个读取器任务,然后创建 100 个写入器任务:

tasks.AddRange(Enumerable.Range(0, 1000000).Select(n => new Task(() => { temp.Where(t => t < 1000).Sum(); })).ToArray());
tasks.AddRange(Enumerable.Range(0, 100).Select(n => new Task(() => { temp.Add(n); })).ToArray());

我得到以下计时结果:

  • ConcurrentBag:~300ms
  • ThreadSafeList:~520ms

测试 2:但是,如果我创建 1m 个读取器任务和 100 个写入器任务(其中要执行的任务列表可能是 {Reader,Reader,Writer,Reader,Reader,Writer 等}

foreach (var item in Enumerable.Range(0, 1000000))
{
    tasks.Add(new Task(() => temp.Where(t => t < 1000).Sum()));
    if (item % 10000 == 0)
        tasks.Add(new Task(() => temp.Add(item)));
}

我得到以下计时结果:

  • ConcurrentBag:~4000ms
  • ThreadSafeList:~800ms

我获取每个测试的执行时间的代码如下:

Stopwatch watch = new Stopwatch();
watch.Start();
tasks.ForEach(task => task.Start());
Task.WaitAll(tasks.ToArray());
watch.Stop();
Console.WriteLine("Time: {0}ms", watch.Elapsed.TotalMilliseconds);

测试 1 中 ConcurrentBag 的效率更好,测试 2 中 ConcurrentBag 的效率比我的自定义实现差,因此我很难决定使用哪一个。

第一季度。当我唯一改变的是列表中任务的顺序时,为什么结果如此不同?

第二季度。有没有更好的方法来更改我的测试以使其更公平?

【问题讨论】:

  • ConcurrentBag 针对写入包的同一线程进行了优化,从包中读取。如果您没有从包中读取相同的线程,请切换到 ConcurrentQueue。阅读this MSDN page,在您的示例中,您使用的是“纯生产者-消费者场景”,每个线程要么只读取包,要么只写入包,没有单个线程在同一个线程中读取和写入。
  • 我同意 - 这让我接着问你什么时候会使用 ConcurrentBag,因为据我了解,你不能在同一个线程上同时读写...... ..你可以吗?因此我会让每个线程维护自己的列表。将 Bag 命名为并发集合的一部分肯定是个坏主意,因为它在多线程场景中效率低下?....除非我遗漏了什么!
  • Bag 适用于您有一堆工作线程的情况,这些线程都将工作转储到一个池中,然后在完成后从同一个池中拉出。如果一个工作人员最终完成了他投入的所有工作(它在内部存储数据的方式将取回线程首先投入的数据),他将开始从其他线程“窃取”工作。
  • 或者其他常见用途可能是工人尝试从池中拉出,看到没有项目,制作一个新项目,然后在完成资源后添加新项目/返回旧项目进入游泳池。下一次同一线程需要池中的项目时,它会更快,但如果另一个线程访问池,它可以使用第一个线程对象而无需创建自己的对象。这种行为在现实世界中的一个很好的例子是connection pooling
  • 我认为由于您的广泛回答,这有理由提出自己的问题。我认为记住这一点的最佳方法是您的连接池示例。

标签: c# multithreading concurrency .net-4.5 readerwriterlockslim


【解决方案1】:

当我唯一改变的是,为什么结果如此不同 列表中任务的顺序?

我最好的猜测是Test #1 实际上并没有读取 项,因为没有什么可读取的。任务执行顺序为:

  1. 从共享池中读取 1M 次并计算总和
  2. 写入共享池 100 次

您的Test # 2 混合了读取和写入,这就是为什么,我猜您会得到不同的结果。

有没有更好的方法来更改我的测试以使其更公平?

在开始任务之前,请尝试随机分配任务的顺序。重现相同的结果可能很困难,但您可能会更接近真实世界的使用情况。

最终,您的问题是关于乐观并发(Concurrent* 类)与悲观并发(基于锁)的区别。根据经验,当您同时访问共享资源的机会较低时,更喜欢乐观并发。当同时访问的机会很高时,更喜欢悲观的。

【讨论】:

  • 感谢您的意见!在测试 1 中,更改顺序轮次以使写入在前,读取在后,这会对性能产生巨大影响。 ConcurrentBag 大约在 9 秒左右推出,而我的自定义 ThreadSafeList 是 1300 毫秒。我相信测试 2 是随机化任务顺序的一个很好的尝试?我相信悲观并发的可能性很高,因为 WCF 服务是一个发布-订阅服务器,其中订阅(写入列表)很少,但多条消息的并发广播(从列表中读取)很频繁。
  • 我会使用像 Fisher-Yates-Durstenfeld shuffle 这样的东西。参见实现here。当您开始任务时,它可能看起来像:tasks.Shuffle().ForEach(task =&gt; task.Start()); 看起来您的实现更好(但仅适用于您的特定用例)
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2015-08-24
  • 1970-01-01
  • 1970-01-01
  • 2012-10-24
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多