任务并行库(TPL)或并行LINQ(PLINQ)是否考虑其他进程?

14

具体来说,我想使用TPL启动(并等待)外部进程。在决定是否启动另一个任务(在我的情况下是另一个外部进程)之前,TPL会查看总的机器负载(包括CPU和I/O)吗?

例如:

我有大约100个需要编码或转码的媒体文件(例如从WAV到FLAC或从FLAC到MP3)。通过启动外部进程(例如FLAC.EXE或LAME.EXE)进行编码。每个文件需要大约30秒的时间。每个进程主要受限于CPU,但其中还涉及一些I/O操作。我有4个内核,所以最坏情况下(通过将解码器输入传输到编码器)仍然只使用2个内核。我想做类似于以下的事情:

Parallel.ForEach(sourceFiles,
    sourceFile =>
        TranscodeUsingPipedExternalProcesses(sourceFile));

这个操作会启动100个任务(从而导致200个外部进程竞争CPU资源)吗?还是它会意识到CPU已经很忙了,只一次做2-3个任务?


当然,我应该将一个 TaskCompletionSourceProcess.Exited 事件连接起来,然后编写一个返回 TaskTranscodeAsync 方法。这样它就不会阻塞了。然后我可以在仍然保持 TPL 粒度的情况下更好地控制任务。 - Roger Lipscombe
3个回答

22
你将会遇到几个问题。调度程序的饥饿避免机制会将你的任务视为已阻塞,因为它们正在等待进程。它很难区分死锁线程和仅仅等待进程完成的线程。因此,如果你的任务运行时间过长(见下文),它可能会安排新的任务。爬山启发式应该考虑到系统的整体负载,包括你的应用程序和其他应用程序。它只是尝试最大化工作量,所以它会添加更多的工作,直到系统的总吞吐量停止增加,然后它会退缩。我不认为这会影响你的应用程序,但是饥饿避免问题可能会。

你可以在使用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。

+1 Parallel.ForEach 的重载版本可以使用带有 MaxDegreeOfParallelismParallelOptions 对象来帮助。 - Ian Mercer

2
答案是:不行。
TPL 在内部使用标准的 ThreadPool 来调度其任务。因此,您实际上正在询问 ThreadPool 是否考虑了机器负载,但它并没有。唯一限制同时运行的任务数量的是线程池中的线程数,没有其他限制。
是否可以让外部进程在准备就绪后向您的应用程序报告?在这种情况下,您无需等待它们(占用线程)。

-1

使用TPL/ThreadPool运行了一个测试,以安排大量任务进行循环旋转。使用外部应用程序,我使用处理器亲和力将其中一个核心加载到100%。活动任务的数量从未减少。

更好的是,我运行了多个相同的CPU密集型.NET TPL启用的应用程序。所有应用程序的线程数相同,并且从未低于核心数,即使我的计算机几乎无法使用。

因此,理论上,TPL使用可用的核心数,但从不检查它们的实际负载。在我看来,这是非常糟糕的实现。


我认为TPL至少分配了与核心数相等的线程数量。它会检查负载情况,并可能增加线程数量,但不会将线程数量减少到低于最小值。 - Void Star
没错。这是最小值的默认设置。这是一种逃避责任的做法。你希望我们开发人员创建监控整个CPU负载(不包括我们正在运行的应用程序)的代码,并相应地减少线程池消耗的最小和最大线程数吗?还是应该从一开始手动将最小值设置为1?或者是2?或者是CPU数量/2? 顺便说一句,它并没有监控实际的CPU负载。它只监控自己的线程。就这些。 - MoonStom
我想到的一件事是TPL很贪心,如果这有利于任务的执行速率,它可能会爬坡到CPU超额订阅的点。所以你是正确的,TPL监视任务/秒而不是CPU负载,这有一些缺点。我的观点是,我认为微软没有预料到开发人员在使用TPL时会改变处理器亲和性。 - Void Star
这不仅仅是关于我们在应用程序中所做的事情。人们的计算机上同时运行着其他服务和应用程序 :) 这个实现假定一个 .NET 应用程序是唯一在机器上运行的应用程序。解决这个问题的一个可能的方法是让我们能够提示线程池减少 CPU 密集型任务的线程优先级。一旦线程被释放到线程池中,它就会将优先级重置回默认的正常状态。虽然还不是理想的解决方案... - MoonStom

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接