【问题标题】:C# ThreadPool Implementation / Performance SpikesC# 线程池实现/性能峰值
【发布时间】:2012-09-02 06:11:33
【问题描述】:

为了加快 C# 中物理对象的处理速度,我决定将线性更新算法更改为并行算法。我认为最好的方法是使用 ThreadPool,因为它是为完成作业队列而构建的。

当我第一次实现并行算法时,我为每个物理对象排队了一个作业。请记住,单个作业完成得相当快(更新力、速度、位置,检查与任何周围对象的旧状态的碰撞以使其线程安全等)。然后,我将使用单个等待句柄等待所有作业完成,每次物理对象完成时我都会递减一个互锁整数(在达到零时,我然后设置等待句柄)。需要等待,因为我需要做的下一个任务涉及更新所有对象。

我注意到的第一件事就是表演太疯狂了。平均下来,线程池的速度似乎快了一点,但性能却出现了大量峰值(每次更新大约 10 毫秒,随机跳转到 40-60 毫秒)。我尝试使用 ANTS 对此进行分析,但无法深入了解为什么会出现尖峰。

我的下一个方法是仍然使用 ThreadPool,但是我将所有对象分成组。我最初只使用 8 个组,因为这就是我的计算机所具有的任何内核。表演很棒。它远远优于单线程方法,并且没有峰值(每次更新大约 6 毫秒)。

我唯一想到的是,如果一个工作在其他工作之前完成,就会有一个空闲的核心。因此,我将作业数量增加到 20 个左右,甚至增加到 500 个。正如我所料,它下降到 5ms。

所以我的问题如下:

  • 为什么在我快速/大量增加作业大小时会出现峰值?
  • 是否有任何关于如何实现 ThreadPool 的见解可以帮助我了解如何最好地使用它?

【问题讨论】:

  • 性能出现巨大峰值时有多少物理对象?

标签: c# multithreading threadpool


【解决方案1】:

以下是我对您的两个问题的看法:

我想从问题 2(线程池的工作原理)开始,因为它实际上是回答问题 1 的关键。线程池是作为(线程安全的)工作队列实现的(无需详细说明)和一组工作线程(可以根据需要缩小或放大)。当用户调用QueueUserWorkItem 时,任务被放入工作队列。工作人员继续轮询队列并在空闲时开始工作。一旦他们设法接受一项任务,他们就会执行它,然后返回队列进行更多工作(这非常重要!)。所以工作是由工作人员按需完成的:当工作人员变得空闲时,他们需要做更多的工作。

说了这么多,很容易看出问题 1 的答案是什么(为什么你会看到更细粒度的任务的性能差异):这是因为使用细粒度你可以获得更多的负载平衡(一个非常理想的属性),即您的工人或多或少地完成相同数量的工作,并且所有核心都被统一利用。正如您所说,使用粗粒度的任务分布,可能会有更长和更短的任务,因此一个或多个核心可能会滞后,从而减慢整体计算速度,而其他核心则什么也不做。有了小任务,问题就消失了。每个工作线程一次执行一项小任务,然后返回执行更多任务。如果一个线程选择一个较短的任务,它会更频繁地进入队列,如果它需要一个较长的任务,它会减少进入队列的频率,所以事情是平衡的

最后,当作业太细粒度时,考虑到池可能会扩大到超过 1K 线程,当所有线程返回以执行更多工作时,队列上的争用非常高(这种情况经常发生),这可能是您看到的尖峰的原因。如果底层实现使用阻塞锁来访问队列,那么上下文切换非常频繁,这会极大地损害性能并使其看起来相当随机。

【讨论】:

  • 这是一个非常清晰的描述,谢谢。并发容器(堆栈/队列)不是实现为无锁的吗?如果是这样,底层实现不会使用它吗?
  • @Rovert:他们只在 .NET 4.0 中引入了高性能容器。 ThreadPool 从 2.0 开始存在。它可能没有非常复杂的实现。
  • 线程池实现(该类自 V1.1 以来就一直存在!)随着 CLR 的每个版本发生变化,并且在 .NET 4 中发生了重大变化,以启用所有并行和引入异步的东西,并更好地利用新的多核系统。它是框架几乎所有并行和异步功能的核心。
【解决方案2】:

要了解更多关于 ThreadPool 的信息,请从这里开始ThreadPool Class

.NET Framework 的每个版本都增加了越来越多间接利用 ThreadPool 的功能。比如前面提到的Parallel.ForEach MethodSystem.Threading.Tasks 一起添加到了.NET 4 中,这使得代码更加可读和整洁。您也可以在这里Task Schedulers 了解更多信息。

在最基本的层面上,它的作用是:创建 20 个线程并将它们放入一个 lits 中。每次它接收到执行异步的委托时,它都会从列表中获取空闲线程并执行委托。如果没有找到可用的线程,则将其放入队列中。每次 deletegate 执行完成时,它会检查队列是否有任何项目,如果有,就会偷看一个并在同一个线程中执行。

【讨论】:

    【解决方案3】:

    问题 1 的答案: 这是因为线程切换,线程切换(或操作系统概念中的上下文切换)是在每个线程之间切换所需的 CPU 时钟,大多数情况下多线程会提高程序和进程的速度,但是当它的进程如此小而快时大小然后上下文切换将比线程的自身进程花费更多的时间,因此整个程序的吞吐量会降低,您可以在 OS 概念书籍中找到有关此的更多信息。

    问题 2 的答案: 实际上我对 ThreadPool 有一个全面的了解,我无法准确解释它的结构。

    【讨论】:

      【解决方案4】:

      正如您可能期望的那样,峰值很可能是由管理线程池并将任务分配给它们的代码引起的。

      对于并行编程,有比“手动”跨不同线程分配工作更复杂的方法(即使使用线程池)。

      例如,请参阅Parallel Programming in the .NET Framework 以了解概览和不同选项。在您的情况下,“解决方案”可能就像这样简单:

      Parallel.ForEach(physicObjects, physicObject => Process(physicObject));
      

      【讨论】:

      • 我不知道“并行”库。我将尝试一下,看看性能如何。我更喜欢这种方法,因为对我来说,似乎系统应该比我更了解事物的执行方式,所以我喜欢它而不是我来批处理事物的想法。同样,一切都归结为每次更新的实际毫秒数......测试测试测试
      • 事实证明,Parallel.ForEach 比每个物理对象的单个作业都快,但是,在我这边批处理它们仍然是最快的。
      • @Rovert,您可能会受到枚举器争用的影响。我建议您下载并阅读以下指南,以深入了解您可能面临的问题以及新的并行内容如何帮助您获得最佳性能:Patterns for Parallel Programming: Understanding and Applying Parallel Patterns with the .NET Framework 4/恕我直言,这是一个写得很好的文档不需要大量的知识,但仍会涉及您需要注意的所有细节。)
      • 谢谢卢塞罗。如果我理解正确,枚举器会正确地获得整个堆栈(以便他们可以重入)?我可以看到这可能会很昂贵,但我肯定会阅读那篇论文。再次感谢!
      • @Rovert,不,枚举器的问题很简单——接口不能以并发方式使用:您调用MoveNext(),然后必须通过Current 属性获取项目.因此,在这两个调用的整个过程中,只有一个线程可以访问枚举器;访问必须完全同步。您可以通过在枚举上调用ToArray() 来获得更好的性能,以便它允许按索引进行随机访问,从而消除了对枚举器同步的需要。
      【解决方案5】:

      使用线程是有代价的——你需要上下文切换,你需要锁定(当一个线程试图获取一个新作业时,作业队列很可能被锁定)——这一切都是有代价的。与您的线程正在执行的实际工作相比,此价格​​通常很小,但如果工作很快结束,价格就会变得有意义。

      您的解决方案似乎是正确的。一个合理的经验法则是线程数是内核数的两倍。

      【讨论】:

        猜你喜欢
        • 2011-09-11
        • 2018-09-23
        • 2021-09-16
        • 2014-11-22
        • 2011-11-28
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多