关于并行和并发
Ron Pressler 于 2021 年 11 月 30 日
一周多前,我们向 Java 用户社区征求了关于 结构化并发的新构造 的反馈,我们将其作为 Project Loom 的一部分公布。它旨在使编写正确且高效的并发代码更容易,并使工具(如新的分层线程转储机制)更容易观察并发程序。
当我们设计一个新的构造时,我们尝试从一个定义明确的问题开始,我们在 JEP 的动机部分描述了这个问题。确定问题是最重要的,通常也是最困难的部分。因此,有用的反馈采取识别所提出的解决方案的问题的形式:要么解决方案没有完全解决最初的问题,要么它可能引入了另一个问题。
但还有另一种有用的反馈,尽管是隐式的:人们的反应可能表明我们想要解决的问题本身并不明确。在这种情况下,可能存在理解问题的根本困难,而且这不是一个新问题:并发和并行之间的混淆。
并行与并发
这种混淆是可以理解的,当然也不仅仅存在于 Java 中。一方面,并发和并行都涉及同时做多件事。另一方面,线程通常用于并发和并行,它们扮演着两种难以区分的不同角色,其中一个角色与并行性更相关,另一个角色与并发性更相关。
我将尝试首先定义这两个概念,然后尝试将它们整合到一个统一的框架中,我希望这将有助于了解为什么它们需要不同的解决方案。我们将使用的定义可能不是通用的,但它们符合 ACM 的 课程指南。
并行是通过使用多个处理单元来更快地完成一项工作(例如,求矩阵的逆或对列表进行排序)的问题。这是通过将工作分解成多个协作子任务来完成的,这些子任务的不同子集在不同的核心上运行。相比之下,并发是将一些计算资源调度到一组在很大程度上独立的任务上的问题,这些任务竞争这些资源。当我们谈论并发时,我们关心的主要性能指标不是任何单个任务的持续时间(延迟),而是吞吐量——我们每单位时间可以处理的任务数量。
与并行情况不同,并行算法本身创建了协作子任务,并发中的竞争任务来自外部,并且是问题的一部分,而不是解决方案的一部分。换句话说,我们在并行中有多个任务的原因是因为我们想更快地完成一些工作;我们在并发中有多个任务的原因是因为处理多个任务就是工作。并发程序的典型示例是为通过网络到达的请求提供服务的服务器。
一旦我们了解了这些定义,以及 Loom 的虚拟线程用于并发,而并行流用于并行,我们就完成了:当我们需要对一个非常大的列表进行排序时,我们使用并行流,当我们编写一个服务器时,我们使用虚拟线程。
但在本文中,我想深入探讨。这两个问题陈述是如此不同——我 曾经将 并行比作一群食人鱼吞噬大型猎物,并将并发比作在城市街道上行驶的汽车——以至于几乎令人惊奇的是,任何人都可能将它们混淆。我们确实将它们混淆表明了一些相似之处,我们不会将其忽略,而是会尝试接受它。
一个通用框架
让我们忽略任务的来源或目的,以及它们是协作还是竞争。如果我们只关注机制,我们会发现,无论出于何种原因,在这两种情况下,我们都希望同时处理一些任务。在这一点上,我们可以允许自己有一些混淆,并问,为什么我们需要为这两个问题使用不同的机制?为了回答这个问题,我们现在将重新引入两种情况下的要求,以及我们用来满足这些要求的手段。
让我们从并行开始:我们希望同时做多件事,以更快地完成一项工作。这怎么可能?如果我们需要执行的任务可以使用以一定恒定速率在任务上取得进展的设备来执行,并且我们拥有多个这样的资源,我们可以将我们的工作分成多个任务,并将这些任务的不同子集分配给其中一个资源。之前提到的那种问题——对大型列表进行排序或求大型矩阵的逆——可以通过我们拥有的资源来执行:处理核心。并行算法将把工作分成子任务(以某种有效的方式相互协调),并将它们分配到我们的处理核心上。
虽然操作系统内核直接控制 CPU 核心,但它不会将 CPU 分配直接暴露给用户空间程序。相反,它提供了一个构造来间接——并且只是近似地——控制 CPU 分配:线程。假设同一台机器上没有其他进程正在运行,如果线程数小于或等于核心数,我们预计操作系统会将每个线程分配到不同的核心。因为这种并行问题使用线程作为处理单元的代理,所以我们需要更快地完成工作所需的线程数与其大小无关。如果我们有十个核心,一个并行计算将最优地使用十个线程,无论它需要排序的列表有百万个元素还是十亿个元素。
现在,对于并发,我们有一系列来自外部来源的基本上独立的任务,它们竞争我们的资源。如果它们所需的唯一资源是处理,那么,和以前一样,我们所需要做的——我们所能做的——就是通过将它们分配给相对较少的线程来将它们调度到处理核心上。但通常情况下,这些任务无法由 CPU 完成,需要一些其他资源,这些资源通过 I/O 访问,例如数据库或一些 HTTP 服务。事实上,通常情况下,大多数处理此类任务所需的时间都花在了 I/O 上。更多的线程不能为我们购买更多 CPU 来加速我们的并行工作,但它们当然也不能为我们购买更多数据库和服务。在这种情况下,使用线程同时做多件事对我们有什么帮助呢?
回想一下,我们使用并发的目标不是更快地处理任务——即延迟更低——而是每单位时间完成尽可能多的任务,实现高吞吐量。即使每个任务都需要恒定的时间来处理,恒定的延迟为一秒,例如,我们仍然可以每秒处理一个任务或一百万个任务。
Little 定律 将并发系统中的吞吐量和延迟联系起来,它告诉我们,我们可以通过增加我们的并发级别来提高我们的吞吐量——我们可以同时启动并取得进展的任务数量。一种相对愉快的方法是使用线程,线程允许多个顺序任务独立地取得进展,这意味着即使没有任务完成,所有任务都可以取得进展。但我们需要多少线程?答案是很多。
如果每个请求都通过一些网络服务器套接字,并且可能需要一些其他客户端套接字与其他服务通信,那么套接字的数量限制了我们可以同时处理的请求数量,但这个限制很高。如果在并行情况下,我们需要相对较少的线程(等于 CPU 核心数),无论工作量有多大,在并发情况下,我们希望有非常多的线程,无论我们有多少核心(有关虚拟线程如何帮助提高并发系统的吞吐量的更详细说明,请参阅 这篇文章)。
诚然,每个线程都会消耗我们机器上的一些有限资源——一些 RAM、一些网络带宽、一些 CPU——这些资源在某个时刻会变得饱和,并限制了我们可以有效使用的线程数量。如果每个线程消耗大量的 CPU,那么这将成为限制其数量的一个因素,因此说虚拟线程有助于 I/O 密集型工作负载,但不适用于 CPU 密集型工作负载是正确的,尽管这里重要的是线程数量,而不是它们的实现,虚拟的还是操作系统的。但关键的见解是,我们使用线程的方式与我们使用它们进行并行的方式非常不同。我们不将它们用作分配核心的接口,而是用于不同的功能:能够在多个操作上独立地取得进展。
为什么线程执行这两个不同的功能?我们可以将它们视为在时间和空间上调度处理资源。将不同的 CPU 内核分配给不同的线程,在空间上调度处理,使不同的内核同时进行处理,而对多个任务进行独立的处理,则在时间上调度处理,但不一定同时进行[1]。因此,我们可以将并行性视为在空间上调度资源的问题,将并发性视为在时间上调度资源的问题。我不知道这是否有帮助,但这听起来确实很酷。
结论
并发和并行是两个截然不同的问题。但即使我们只关注它们的共同点——它们通过使用多个线程来执行多个操作——它们以不同的方式使用线程。
Project Loom 的虚拟线程允许我们创建比使用操作系统线程所能创建的更多线程。拥有更多线程有助于并发,其中理想的线程数量是工作负载的函数,但对并行性没有帮助,其中理想的线程数量是机器的函数。需要不同的机制来解决它们的有趣差异是,利用线程作为 CPU 内核代理的功能的问题不会从拥有更多线程中受益,而利用线程的函数来处理许多独立进行的顺序操作的问题会从更多线程中受益。Loom 的 API 旨在解决后一个问题,即处理许多基本上独立的任务。
要想知道虚拟线程是否对您的工作负载有帮助,问问自己:它是否会从拥有更多线程中受益,或者它实际上需要更多内核?