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