CLR 4.0 ThreadPool 中的并发限制
Erika Fuentes
自 CLR 2.0 后,最新版本 (CLR 4.0) 中的 CLR ThreadPool 已经过多次重大更改。最近的技术趋势转变(例如广泛应用多核体系结构以及由此产生的并行化现有应用程序或编写新并行代码的需求)已成为 CLR ThreadPool 改进中最重要的决定性因素之一。
在 MSDN 杂志 2008 年 12 月刊的 CLR 全面透析专栏“CLR 中的线程管理”(msdn.microsoft.com/magazine/dd252943) 中,我介绍了一些动机和相关问题,例如并发控制和干扰信息。现在,我将介绍 CLR 4.0 ThreadPool 中解决这些问题的方式、相关的实现选项以及这些选项对其行为的影响。同时,我还将重点介绍当前 CLR 4.0 ThreadPool(为了方便起见,下文简称 ThreadPool)中实现自动化并发控制的方法。我还将简要地概述 ThreadPool 体系结构。本文中介绍的实现细节在未来版本中有可能发生变更。不过,对于设计和编写新并发应用程序的读者而言,如果他们正在致力于利用并发技术改进旧应用程序,或者使用 ASP.NET 或并行扩展技术(所有这些都在 CLR 4.0 上下文中实现),他们会发现本文对于理解和利用当前 ThreadPool 行为非常有用。
ThreadPool 概述
线程池用来提供重要服务,例如线程管理、不同类型的并发的抽象性以及并发操作的限制。通过提供这些服务,线程池可以减轻用户的负担,使他们无需手动执行这些操作。对于无经验的用户而言,线程池是非常方便的,他们不需要学习和掌握多线程环境的细节。对于经验丰富的用户而言,拥有可靠的线程系统就意味着用户可以将精力集中在改进应用程序的各种功能上。ThreadPool 为托管应用程序提供这些服务,并且提供跨平台可移植性支持(例如,在 Mac OS 上运行特定 Microsoft .NET Framework 应用程序的平台)。
有各种不同类型的并发可以与系统的不同部分相关。最相关的部分包括:CPU 并行性、I/O 并行性、计时器和同步、负载平衡和资源利用率。我们可以针对并发的不同方面,简要地概述 ThreadPool 的体系结构。有关 ThreadPool 体系结构和相关 API 使用的详细信息,请参阅“CLR 的线程池”(msdn.microsoft.com/magazine/cc164139)。尤其值得一提的是,有两种独立的 ThreadPool 实现:一种用来处理 CPU 并行性,称为工作线程 ThreadPool;另一种用来处理 I/O 并行性,称为 I/O ThreadPool。下一部分将重点介绍 CPU 并行性和 ThreadPool 中的相关实现工作,特别是关于并发限制的策略。
工作线程 ThreadPool:旨在提供 CPU 并行性级别的服务,它利用多核体系结构。CPU 并行性有两个主要的考虑因素:以优化的方式快速调度工作;以及限制并行度。对于前者,ThreadPool 实现利用无锁定队列这样的策略来避免争用和工作窃取,以便实现负载平衡,这些领域超出了本文的讨论范围(要进一步了解这些主题,请参阅 msdn.microsoft.com/magazine/cc163340)。后者(即并行度限制)使并行度控制可以防止由于资源争用而导致总体吞吐量下降。
CPU 并行性处理起来非常棘手,因为它涉及到许多参数,例如确定在任意给定时间有多少工作项可以同时运行。另外一些问题是内核数以及如何针对不同类型的工作负载进行优化。例如,理论上每个 CPU 一个线程是最优的,但如果工作负载经常发生阻塞,就会浪费 CPU 时间,因为其他线程可以使用这些资源来执行更多工作。工作负载的大小和类型实际上是另一个参数。例如,如果发生工作负载阻塞的情况,要确定使总体吞吐量达到最优的线程数是非常困难的,因为很难确定一个请求的完成时间(甚至可能很难确定请求到达的频率,这与 I/O 阻塞是密切相关的)。与此 ThreadPool 相关的 API 是 QueueUserWorkItem,它使方法(工作项)排队等待执行(请参阅 msdn.microsoft.com/library/system.threading.threadpool.queueuserworkitem)。建议将可能以这种方式工作的应用程序并行运行(与其他项一起)。具体工作由 ThreadPool 处理,它将自动“计算”运行时间。这一功能减轻了编程人员的负担,使他们无需担心创建线程的方式和时间;不过,它并不是适用于所有场景的最有效的解决方案。
I/O ThreadPool:这部分的 ThreadPool 实现与 I/O 并行性有关,它负责处理阻塞的工作负载(即,服务时间相对较长的 I/O 请求)或异步 I/O。在异步调用中,线程不会发生阻塞,并且在处理请求期间可以继续执行其他工作。此 ThreadPool 负责在请求和线程之间进行协调。I/O ThreadPool 与工作线程 ThreadPool 类似,使用并发限制算法;它根据异步操作完成率控制线程数。不过,此算法与工作线程 ThreadPool 中的算法完全不同,相关内容不在本文档讨论范围内。
ThreadPool 中的并发
处理并发是一项困难但又必不可少的任务,它直接影响系统的整体性能。系统限制并发的方式会直接影响到其他任务,例如同步、资源利用和负载平衡,反之亦然。
“并发控制”(更准确地说,应该是“并发限制”)的概念是指在特定时间,允许在 ThreadPool 中执行工作的线程数;这是一种决定同时运行多少线程而不会损害性能的策略。我们只讨论与工作线程 ThreadPool 有关的并发控制。并发控制并不是直观的,它与限制 和减少 可并行运行的工作项数量有关,这样做的目的是为了改进工作线程 ThreadPool 吞吐量(即,控制并发度是为了阻止工作项运行)。
ThreadPool 中的并发控制算法会自动选择并发级别;它为用户决定需要运行多少线程来保持性能在整体上达到最优。此算法的实现是 ThreadPool 中最复杂、也最吸引人的部分之一。有多种方法可以在并发级别上下文中优化 ThreadPool 的性能(也就是说,确定“正确的”同时运行的线程数)。在下一部分中,我将介绍其中一些方法,这些方法曾考虑在 CLR 中使用或者已在使用。
ThreadPool 中的并发控制的发展
最先采用的方法之一是根据观察到的 CPU 利用率进行优化,然后增加 线程以最大程度地提高 CPU 利用率,即运行尽可能多的工作,使 CPU 处于繁忙状态。在处理长时间运行的工作负载或不确定的工作负载时,将 CPU 利用率作为一项度量是非常有用的。不过,由于评估度量的标准可能会产生误导,因此这种方法不太合适。例如,请考虑一下发生大量内存分页的应用程序。观察到的 CPU 利用率会非常低,而在这种情况下增加更多线程会导致使用更多内存,这样又会导致 CPU 利用率变得更低。此方法的另一个问题是,在存在许多争用的情况下,CPU 时间实际上用在同步上,而不是执行实际工作上,因此增加线程只会使情况更糟。
另外一种观点是让操作系统负责并发级别。实际上,这是 I/O ThreadPool 的工作,但工作线程 ThreadPool 要求更高级别的抽象性,以便提供更好的可移植性并且对资源进行更有效的管理。这种方法可能在某些场景中适用,但编程人员仍然需要了解如何进行限制,以避免资源过饱和。例如,如果创建了数以千计的线程,资源争用会成为一个大问题,而增加线程实际上会使情况变得更糟。此外,这意味着编程人员仍然必须考虑并发问题,而这违背了使用线程池的初衷。
最近提出的方法是引入吞吐量概念,测量每个时间单位内完成的工作项,然后将它作为一项优化性能的度量。在这种方法中,当 CPU 利用率非常低时,增加线程来查看是否能够改善吞吐量。如果可以,则增加更多线程;如果不能,则删除线程。这种方法比前面的方法更加合理,因为它考虑了完成的工作量,而不仅仅是资源的使用方式。不幸地是,吞吐量受到许多因素的影响,而不只是活动线程数(例如,工作项大小),这使得优化吞吐量变得非常困难。
并发限制的控制理论
为了克服以前实现中的某些局限,CLR 4.0 中引入了一些新概念。考虑的第一种方法来自控制理论领域,即爬山 (HC) 算法。此技术是一种根据输入/输出反馈循环自动进行优化的方法。在一个较小的时间间隔内监视和测量系统输出,查看哪些因素影响到受控制的输入,然后将该信息反馈给算法,以便进一步优化输入。使用输入和输出作为变量,以函数的方式对系统进行建模。目标是优化测量的输出。
在工作线程 ThreadPool 系统的上下文中,输入是同时执行工作的线程数(即并发级别),而输出是吞吐量(请参阅图 1)。
图 1 ThreadPool 反馈循环
我们观察并测量一段时间内增加或删除线程导致的吞吐量变化,然后根据观察到的吞吐量下降或提高,确定是增加还是删除线程。图 2 说明了这一概念。
图 2 以并发级别函数的形式建模的吞吐量
将吞吐量作为并发级别的(多项式)函数后,该算法会增加线程,直至达到函数的最大值(在本示例中大约为 20)。此时,吞吐量将会下降,而算法将会删除线程。在每个时间间隔中对吞吐量度量进行采样并“计算平均值”。然后,使用该值来针对下一个时间间隔做出决定
.度量的杂乱无章是可理解的,除非在相当长的时间间隔中进行采样,否则统计信息并不能代表实际情况。很难确定改进是并发级别更改的结果还是由于其他因素(例如工作负载浮动)造成的。
实际系统中的自适应方法是非常复杂的。在我们的示例中,使用这种方法尤其存在问题,因为在很短时间内从杂乱无章的环境中检测微小的变化或提取更改是非常困难的。通过这种方法观察到的第一个问题是,建模的函数(请参阅图 2 中的黑色曲线)在真实情况下(请参阅相同图形中的蓝色点)不是静态目标,因此很难测量微小更改。第二个问题可能更值得关注,就是干扰信息(系统环境导致的度量差异,例如特定操作系统活动、垃圾收集等等),这个问题使得确定输入和输出之间是否存在必然联系变得更加困难,即无法确定吞吐量是否不单纯是线程数的函数。事实上,在 ThreadPool 中,吞吐量只是实际观察到的输出中的一小部分,观察到的大部分是干扰信息。例如,考虑一个其工作负载使用许多线程的应用程序。只增加少许线程并不会导致输出发生变化;在某个时间间隔内观察到的改进甚至可能与并发级别更改无关(图 3 有助于说明这一问题)。
图 3 ThreadPool 中的干扰信息示例
在图 3 中,X 轴代表时间;Y 轴则指示吞吐量和并发级别度量已超过限制。上方的图形说明了在某些工作负载下,即使线程数(红色)保持不变,也会观察到吞吐量发生变化(蓝色)。在本示例中,这些浮动就是干扰信息。下方的图形是另一个示例,说明即使存在干扰信息,在一段时间内仍会观察到整体吞吐量有所提高。不过,线程数保持不变,因此吞吐量的改进是系统中其他参数的结果。
在下一部分中,我将介绍处理干扰信息的方法。
引入信号处理
信号处理在许多工程领域中用来减少信号中的干扰信息,相应概念就是在输出信号中查找输入信号的模式。如果我们将并发控制算法的输入(并发级别)和输出(吞吐量)视为信号,则此理论就可以在 ThreadPool 的上下文中运用。如果我们将故意修改的并发级别作为带有已知周期和波幅的“波形”输入,然后在输出中查找原始波形模式,则可以针对吞吐量判断哪些是干扰信息,哪些是实际输入效果。图 4 对此概念做出了说明。
图 4 确定 ThreadPool 输出中的因素
仔细考虑一下,用黑色方框表示的系统在给定输入的情况下如何生成输出。图 4 说明了一个简单的 HC 输入和输出(绿色)示例;下面是一个使用滤波技术(黑色)后输入和输出作为波形的显示形式示例。
我们不再提供平直的、固定不变的输入,而是引入了一个信号,然后尝试在杂乱无章的输出中发现这一输入。这种效果可以通过带通滤波器 或匹配滤波器 这样的技术实现;这些技术通常用于从其他波形中提取所需波形或者在输出中找出非常有针对性的信号。这也表示在引入输入更改时,算法可以根据最后一小段输入数据,在每个时间点做出决定。
ThreadPool 运用的特定算法中使用离散傅里叶变换,这是一种提供波形的波幅和相位等信息的方法。然后,此信息可用于确定输入是否影响到输出以及产生的影响。图 5 中的图形显示对运行 600 秒以上的工作负载使用此方法的 ThreadPool 行为的示例。
图 5 测量输入对输出的影响
在图 5 的示例中,可以在输出中追踪输入波形的已知模式(相位、频率和波幅)。该图说明了对工作负载样本使用滤波后并发算法的行为。红色的波形对应于输入,而蓝色波形对应于输出。线程数会在一段时间内向上和向下浮动,但这并不表示我们正在创建或销毁线程;我们保持线程数不变。
虽然线程数的变化幅度与吞吐量的变化幅度不同,但我们可以了解它是如何指示输入对输出的影响的。线程数持续在某个时间段内递增或递减至少一个,但这并不表示正在创建或销毁线程。相反,线程在线程池中保持“活动”状态,但当前并未执行工作。
在最初使用 HC 的方法中,目标是建立吞吐量曲线模式,并根据计算结果做出决策。与之相比,这种改进的方法只确定输入更改是否有助于改进输出。我们在直观上就非常肯定,人为引入的更改对输出产生了观察到的效果(到目前为止,在信号中引入的、观察到的最大线程数是 20,这是相当合理的,尤其是对于有许多线程的场景)。使用信号处理的方法的缺点之一是由于人为引入波形模式,最优并发级别始终需要在至少一个线程后才发挥作用。同时,对并发级别的调整相对较慢(更快的算法建立在 CPU 利用率度量的基础上),因为收集足够的数据来保持模型稳定是非常必要的。并且,速度将取决于工作项的长度。
这种方法并不是完美的,而且具体的表现随工作负载而所有不同;不过,它相比前面的方法来说有相当大的进步。我们的算法表现最好的工作负载类型是那些具有相对较短的单独工作项的工作负载,因为工作项越短,算法自适应的速度也就越快。例如,它对于持续时间低于 250 毫秒的工作项表现非常好,不过在持续时间低于 10 毫秒的环境中使用时更为出色。
并发管理 - 无需劳您大驾
总而言之,ThreadPool 提供了一些服务,使编程人员能够将精力集中在并发管理以外的其他功能上。为了实现这种功能,ThreadPool 实现中集成了能够自动为用户作出许多决策的高端工程算法。一个示例是并发控制算法,该算法随技术进步和不同场景中的需求(例如需要测量有用的工作执行进度)而不断发展。
CLR 4.0 中并发控制算法的目的是自动确定多少工作项可以同时有效地运行,从而优化 ThreadPool 的吞吐量。由于干扰信息和工作负载类型之类的参数的原因,这种算法难以优化;它还建立在每个工作项都是有用工作段的假设的基础上。当前的设计和行为深受 ASP.NET 和并行框架方案的影响,在这些方案中,此算法的表现堪称完美。通常情况下,ThreadPool 能够非常有效地执行工作。不过,用户应当意识到,对于某些工作负载可能会出现一些意外行为,或者,假如同时有多个 ThreadPool 正在运行时也可能会出现意外行为。
Erika Fuentes 博士* 是 CLR 团队中负责测试的软件开发工程师。她在性能团队中工作,主要关注线程处理中的核心操作系统领域。她曾发表过有关科学计算、适应性系统和统计学领域的多篇学术文章。*
衷心感谢以下技术专家对本文的审阅:Eric Eilebrecht和Mohamed Abd El Aziz