你会在这里遇到几个问题。调度程序的饥饿避免机制将看到您的任务在等待进程时被阻塞。它将发现很难区分死锁线程和只是等待进程完成的线程。因此,如果您的任务运行或长时间运行(见下文),它可能会安排新任务。爬山启发式应该考虑到系统上的整体负载,包括您的应用程序和其他应用程序。它只是试图最大化完成的工作,所以它会增加更多的工作,直到系统的整体吞吐量停止增加,然后它会回退。我认为这不会影响您的申请,但可能会避免饥饿问题。
您可以在Parallel Programming with Microsoft®.NET、Colin Campbell、Ralph Johnson、Ade Miller、Stephen Toub 中找到有关这一切如何工作的更多详细信息(早期的草稿在线)。
“.NET 线程池自动管理池中工作线程的数量。它根据内置的启发式方法添加和删除线程。.NET 线程池有两种主要的线程注入机制:增加工作线程的饥饿避免机制如果它发现排队的项目没有任何进展,则线程和爬山启发式尝试在使用尽可能少的线程的同时最大化吞吐量。
饥饿避免的目标是防止死锁。当工作线程等待同步事件时,可能会发生这种死锁,而该同步事件只能由仍在线程池的全局或本地队列中挂起的工作项来满足。如果有固定数量的工作线程,并且所有这些线程都被类似地阻塞,那么系统将无法取得进一步的进展。添加一个新的工作线程解决了这个问题。
爬山启发式的一个目标是在线程被 I/O 或其他使处理器停止的等待条件阻塞时提高内核的利用率。默认情况下,托管线程池每个核心有一个工作线程。如果这些工作线程中的一个被阻塞,则内核可能未被充分利用,具体取决于计算机的整体工作负载。线程注入逻辑不区分被阻塞的线程和正在执行冗长的处理器密集型操作的线程。因此,只要线程池的全局或本地队列包含待处理的工作项,运行时间较长(超过半秒)的活动工作项就会触发创建新的线程池工作线程。
每次工作项完成或以 500 毫秒间隔(以较短者为准)时,.NET 线程池都有机会注入线程。线程池利用这个机会尝试添加线程(或将它们移除),由线程计数先前更改的反馈引导。如果添加线程似乎有助于提高吞吐量,那么线程池会增加更多;否则,它会减少工作线程的数量。这种技术被称为爬山启发式。因此,保持单个任务简短的一个原因是避免“饥饿检测”,但保持简短的另一个原因是通过调整线程数为线程池提供更多提高吞吐量的机会。单个任务的持续时间越短,线程池就越频繁地测量吞吐量并相应地调整线程数。
为了具体化,考虑一个极端的例子。假设您有一个复杂的财务模拟,其中包含 500 个处理器密集型操作,每个操作平均需要十分钟才能完成。如果您在全局队列中为这些操作中的每一个创建顶级任务,您会发现大约五分钟后线程池将增长到 500 个工作线程。原因是线程池将所有任务都视为阻塞,并开始以大约每秒两个线程的速度添加新线程。
500 个工作线程有什么问题?原则上,如果您有 500 个内核供他们使用,并且有大量系统内存,则没什么。事实上,这是并行计算的长期愿景。但是,如果您的计算机上没有那么多内核,那么您将处于许多线程竞争时间片的情况。这种情况称为处理器超额订阅。允许许多处理器密集型线程在单个内核上竞争时间会增加上下文切换开销,从而严重降低整体系统吞吐量。即使您没有耗尽内存,在这种情况下的性能也可能比顺序计算差得多。(每个上下文切换需要 6,000 到 8,000 个处理器周期。)上下文切换的成本并不是开销的唯一来源。中的托管线程。NET 大约消耗一兆字节的堆栈空间,无论该空间是否用于当前执行的函数。创建一个新线程大约需要 200,000 个 CPU 周期,而退休一个线程大约需要 100,000 个周期。这些都是昂贵的操作。
只要你的任务不花几分钟,线程池的爬山算法最终会意识到它有太多的线程并自行削减。但是,如果您确实有任务占用工作线程数秒、数分钟或数小时,那将摆脱线程池的启发式算法,此时您应该考虑替代方案。
第一个选项是将您的应用程序分解为较短的任务,这些任务完成的速度足够快,以便线程池成功控制线程数以获得最佳吞吐量。第二种可能性是实现您自己的不执行线程注入的任务调度程序对象。如果您的任务持续时间较长,则不需要高度优化的任务调度程序,因为与任务的执行时间相比,调度成本可以忽略不计。MSDN® 开发人员程序有一个简单的任务调度程序实现示例,它限制了最大并发度。有关详细信息,请参阅本章末尾的“进一步阅读”部分。
作为最后的手段,您可以使用 SetMaxThreads 方法为 ThreadPool 类配置工作线程数的上限,通常等于内核数(这是 Environment.ProcessorCount 属性)。此上限适用于整个流程,包括所有 AppDomain。”