并发模型
可以使用不同的并发模型来实现并发系统。并发模型指定系统中的线程如何协作完成给定的任务。不同的并发模型以不同的方式拆分任务,并且线程可以以不同的方式进行通信和协作。本并发模型教程将在撰写本文时(2014 2019)更深入地研究最流行的并发模型。
并发模型和分布式系统的相似性
本文中描述的并发模型类似于分布式系统中使用的不同体系结构。在并发系统中,不同的线程彼此通信。在分布式系统中,不同的进程相互通信(可能在不同的计算机上)。线程和进程本质上非常相似。这就是为什么不同的并发模型通常看起来与不同的分布式系统体系结构相似的原因。
当然,分布式系统还面临着额外的挑战,即网络可能会失败,或者远程计算机或者进程关闭等。但是,如果CPU发生故障,网卡发生故障,磁盘发生故障,则在大型服务器上运行的并发系统可能会遇到类似的问题。失败的可能性可能会更低,但理论上仍会发生。
由于并发模型与分布式系统体系结构相似,因此它们经常可以相互借鉴。例如,用于在工作程序(线程)之间分配工作的模型通常类似于分布式系统中的负载平衡模型。错误处理技术(例如日志记录,故障转移,任务的幂等)也是如此。
共享状态与分离状态
并发模型的一个重要方面是,组件和线程是设计为在线程之间共享状态,还是具有独立的状态,这些状态永远不会在线程之间共享。
共享状态意味着系统中的不同线程将在它们之间共享某些状态。状态是指一些数据,通常是一个或者多个对象或者类似对象。当线程共享状态时,可能会出现争用条件和死锁等问题。当然,这取决于线程如何使用和访问共享对象。
单独的状态表示系统中的不同线程在它们之间不共享任何状态。万一不同的线程需要通信,它们可以通过在它们之间交换不可变对象或者通过在它们之间发送对象(或者数据)的副本来进行通信。因此,当没有两个线程写入同一对象(数据/状态)时,可以避免大多数常见的并发问题。
使用单独的状态并发设计通常可以使代码的某些部分更易于实现和推理,因为我们知道只有一个线程将写入给定对象。我们不必担心并发访问该对象。但是,使用单独的状态并发性,我们可能需要更全面地考虑应用程序设计。我觉得这是值得的。我个人更喜欢单独的状态并发设计。
平行工人
第一个并发模型是我所说的并行工作器模型。传入的工作分配给不同的工人。这是说明并行工作程序并发模型的图:
在并行工人并发模型中,委托人将传入的作业分配给不同的工人。每个工人完成全部工作。这些工作程序并行工作,在不同的线程中运行,并可能在不同的CPU上运行。
如果在汽车制造厂实施并行工人模型,则每辆汽车将由一名工人生产。工人将获得要制造的汽车的规格,并会从头到尾制造所有东西。
并行工作程序并发模型是Java应用程序中最常用的并发模型(尽管正在发生变化)。 java.util.concurrent Java包中的许多并发实用程序都是设计用于此模型的。我们还可以在Java Enterprise Edition应用程序服务器的设计中看到此模型的痕迹。
平行工人优势
并行工作程序并发模型的优点是易于理解。为了增加应用程序的并行化,我们只需添加更多工作程序即可。
例如,如果我们正在实施Web搜寻器,则可以使用不同数量的工作程序来搜寻一定数量的页面,并查看哪个数量的总爬行时间最短(意味着最高的性能)。由于Web爬网是一项IO密集型工作,我们最终可能会在计算机中的每个CPU /内核上得到几个线程。每个CPU一个线程太少了,因为在等待数据下载时,很多时间它处于空闲状态。
平行工人的劣势
但是,并行工作程序并发模型具有隐藏在简单表面下的一些缺点。我将在以下各节中解释最明显的缺点。
共享状态会变得复杂
实际上,并行工作程序并发模型比上面说明的要复杂一些。共享工作者经常需要访问内存或者共享数据库中的某种共享数据。下图显示了如何使并行工作器并发模型复杂化:
这种共享状态中的某些处于诸如工作队列之类的通信机制中。但是这种共享状态包括业务数据,数据缓存,数据库的连接池等。
一旦共享状态潜入并行工作程序并发模型中,它就会开始变得复杂。线程需要以确保一个线程的更改对其他线程可见的方式访问共享数据(将其推送到主内存中,而不仅仅是停留在执行该线程的CPU的CPU缓存中)。线程需要避免争用条件,死锁和许多其他共享状态并发问题。
此外,当线程在访问共享数据结构时互相等待时,并行化的一部分会丢失。许多并发数据结构正在阻塞,这意味着一个或者一组有限的线程可以在任何给定时间访问它们。这可能导致对这些共享数据结构的争用。高竞争本质上将导致访问共享数据结构的部分代码的执行序列化。
现代的非阻塞并发算法可以减少争用并提高性能,但是非阻塞算法很难实现。
持久数据结构是另一种选择。永久数据结构在修改后始终保留其自身的先前版本。因此,如果多个线程指向相同的持久数据结构,并且一个线程对其进行了修改,则修改线程将获得对新结构的引用。所有其他线程保留对旧结构的引用,该旧结构仍保持不变,因此是一致的。 Scala编程包含几个持久性数据结构。
虽然持久性数据结构是对共享数据结构进行并发修改的理想解决方案,但持久性数据结构往往无法很好地执行。
例如,一个持久列表会将所有新元素添加到列表的开头,并返回对新添加元素的引用(该引用随后指向列表的其余部分)。所有其他线程仍保留对列表中先前第一个元素的引用,并且对这些线程而言,列表保持不变。他们看不到新添加的元素。
这样的持久列表被实现为链接列表。不幸的是,链表在现代硬件上的表现不佳。列表中的每个元素都是一个单独的对象,这些对象可以分布在计算机内存中。现代CPU顺序访问数据的速度要快得多,因此在现代硬件上,从阵列顶部实现的列表中可以获得更高的性能。数组顺序存储数据。 CPU高速缓存可以一次将更大的阵列块加载到高速缓存中,并让CPU在加载后直接访问CPU高速缓存中的数据。对于链表,将元素分散在整个RAM上,这实际上是不可能的。
无国籍工人
共享状态可以由系统中的其他线程修改。因此,工作人员必须在需要时每次都重新读取状态,以确保它在最新副本上正常工作。无论共享状态是保留在内存中还是外部数据库中,这都是正确的。不在内部保持状态(但每次需要时都会重新读取状态)的工作人员称为无状态。
每次需要时重新读取数据都会变慢。特别是如果状态存储在外部数据库中。
作业排序是不确定的
并行工作程序模型的另一个缺点是作业执行顺序不确定。无法保证首先执行或者最后执行哪些作业。作业A可以在作业B之前提供给工人,但作业B可以在作业A之前执行。
并行工作程序模型的不确定性使得在任何给定时间点都难以推理系统状态。这也使得很难(如果不是不可能的话)保证一项工作先于另一项工作发生。
流水线
第二种并发模型是我所说的组装线并发模型。我选择该名称只是为了与之前的"并行工作者"比喻相吻合。其他开发人员根据平台/社区使用其他名称(例如,反应系统或者事件驱动系统)。这是说明组装线并发模型的图:
工人的组织就像工厂中装配线的工人一样。每个工人仅完成全部工作的一部分。完成该部分后,工人会将工作转发给下一个工人。
每个工作程序都在自己的线程中运行,并且不与其他工作程序共享任何状态。有时也称为无共享并发模型。
使用组装线并发模型的系统通常设计为使用非阻塞IO。无阻塞IO意味着当工作进程开始IO操作(例如从网络连接读取文件或者数据)时,工作进程不会等待IO调用完成。 IO操作速度很慢,因此等待IO操作完成会浪费CPU时间。同时,CPU可能正在做其他事情。 IO操作完成后,IO操作的结果(例如,读取的数据或者写入的数据的状态)将传递给另一个工作程序。
使用非阻塞IO,IO操作将确定工作线程之间的边界。在必须启动IO操作之前,工作人员会尽力而为。然后,它放弃了对工作的控制。 IO操作完成后,装配线中的下一个工作人员将继续进行该工作,直到必须开始IO操作等为止。
实际上,这些作业可能不会沿着一条装配线流动。由于大多数系统可以执行一项以上的工作,因此工作会根据需要完成的工作在不同的工作人员之间流动。实际上,可能同时存在多个不同的虚拟装配线。这是现实中流水线系统中的工作流的样子:
甚至可以将作业转发给多个工人进行并行处理。例如,可以将作业转发给作业执行者和作业记录器。此图说明了三条装配线如何通过将其作业转发给同一工人(中间装配线中的最后一个工人)来完成:
流水线甚至比这还要复杂。
反应性,事件驱动系统
使用组装线并发模型的系统有时也称为反应系统或者事件驱动系统。系统的工作人员会对系统中发生的事件做出反应,这些事件是从外界接收到的,也可能是其他工作人员发出的。事件的示例可能是传入的HTTP请求,或者某个文件已完成加载到内存等。
在撰写本文时,有许多有趣的反应式/事件驱动平台可用,将来还会有更多。一些更受欢迎的似乎是:
- Vert.x
- 阿卡
- Node.JS(JavaScript)
我个人认为Vert.x非常有趣(特别是对于像我这样的Java / JVM恐龙)。
演员与频道
角色和通道是装配线(或者反应/事件驱动)模型的两个类似示例。
在演员模型中,每个工人称为演员。 Actor可以直接彼此发送消息。消息是异步发送和处理的。如前所述,可以使用Actor来实现一个或者多个作业处理装配线。这是说明参与者模型的图:
在渠道模型中,工作人员不直接相互通信。相反,他们在不同的渠道上发布消息(事件)。然后,其他工作人员可以在这些通道上侦听消息,而发件人不知道谁在侦听。这是说明通道模型的图:
在撰写本文时,渠道模型对我来说似乎更灵活。工人无需知道稍后在装配线中将处理什么工作的工人。它只需要知道将作业转发到哪个渠道(或者将消息发送到等等)。频道上的侦听器可以订阅和取消订阅,而不会影响工作人员对频道的写入。这允许工人之间的联轴器稍松一些。
流水线优势
与并行工作程序模型相比,组装线并发模型具有多个优点。在以下各节中,我将介绍最大的优点。
没有共享状态
工人与其他工人不共享任何状态的事实意味着无需考虑并发访问共享状态可能引起的所有并发问题,就可以实现他们。这使得实施工人变得容易得多。我们实现了一个工作程序,就好像它是唯一执行本质上是单线程实现的线程。
有状态的工人
由于工作人员知道没有其他线程会修改其数据,因此工作人员可以是有状态的。有状态的意思是,他们可以将需要操作的数据保留在内存中,仅将更改写回最终的外部存储系统。因此,有状态工人通常可以比无状态工人更快。
更好的硬件整合
单线程代码的优势在于,它通常与基础硬件的工作方式更好地相符。首先,当我们可以假定代码在单线程模式下执行时,通常可以创建更多优化的数据结构和算法。
其次,如上所述,单线程有状态工作者可以将数据缓存在内存中。当将数据缓存在内存中时,也更有可能将该数据也缓存在执行线程的CPU的CPU缓存中。这样可以更快地访问缓存的数据。
当以自然受益于底层硬件工作方式的方式编写代码时,我将其称为硬件一致性。一些开发人员称这种机械同情。我更喜欢"硬件一致性"一词,因为计算机几乎没有机械零件,并且在这种情况下,"同情"一词被用作"更好地匹配"的隐喻,我相信"符合"一词可以很好地传达。无论如何,这是挑剔的。使用我们喜欢的任何术语。
可以订购工作
可以根据组装线并发模型以保证作业排序的方式实现并发系统。作业排序使在任何给定时间点推断系统状态变得更加容易。此外,我们可以将所有传入的作业写入日志。然后,在系统的任何部分出现故障的情况下,可以使用此日志从头开始重建系统状态。作业以特定顺序写入日志,并且该顺序成为保证的作业顺序。这样的设计看起来是这样的:
实施有保证的工作订单不一定很容易,但是通常是可能的。如果可以的话,它可以极大地简化备份,还原数据,复制数据等任务,因为所有这些都可以通过日志文件完成。
组装线的缺点
组装流水线并发模型的主要缺点是,作业的执行通常分散在多个工作人员中,因此分散在项目中的多个类中。因此,很难确切地知道给定作业正在执行什么代码。
编写代码也可能会更困难。辅助代码有时被编写为回调处理程序。具有许多嵌套回调处理程序的代码可能会导致某些开发人员称之为回调地狱。回调地狱只是意味着很难跟踪所有回调中代码的实际作用,以及确保每个回调都可以访问所需的数据。
使用并行工作程序并发模型,这往往会更容易。我们可以打开工作程序代码,并从头到尾阅读几乎执行的代码。当然,并行工作程序代码也可以分布在许多不同的类上,但是执行顺序通常更容易从代码中读取。
功能并行
功能并行是第三种并发模型,最近(2014年)被广泛讨论。
函数并行性的基本思想是使用函数调用实现程序。功能可以看作是相互发送消息的"代理"或者"参与者",就像在组装线并发模型(AKA反应或者事件驱动的系统)中一样。当一个函数调用另一个函数时,这类似于发送消息。
传递给该函数的所有参数都将被复制,因此接收函数之外的任何实体都无法操纵该数据。此复制对于避免共享数据出现争用情况至关重要。这使得函数执行类似于原子操作。每个函数调用都可以独立于任何其他函数调用执行。
当每个函数调用可以独立执行时,每个函数调用可以在单独的CPU上执行。这就是说,功能上实现的算法可以在多个CPU上并行执行。
使用Java 7,我们获得了包含ForkAndJoinPool的java.util.concurrent
软件包,该软件包可以实现类似于功能并行性的东西。使用Java 8,我们获得了并行流,可以并行化大型集合的迭代。请记住,有些开发人员对ForkAndJoinPool
持批评态度(我们可以在我的ForkAndJoinPool
教程中找到批评的链接)。
关于函数并行性的困难部分是知道要调用哪个函数进行并行化。跨CPU协调函数调用会带来开销。一个功能完成的工作单元必须具有一定的大小,才能负担此开销。如果函数调用很小,则尝试并行化它们实际上可能比单线程,单CPU执行慢。
根据我的理解(一点也不完美),我们可以使用反应性,事件驱动的模型来实现算法,并实现类似于功能并行性的工作分解。使用均匀驱动的模型,我们可以更好地控制要并行化的对象和数量(在我看来)。
此外,只有在该任务当前是程序唯一执行的任务时,才有意义地将任务分配给多个CPU,并产生开销。但是,如果系统正在同时执行多个其他任务(例如,Web服务器,数据库服务器和许多其他系统都在执行),则尝试并行处理单个任务毫无意义。无论如何,计算机中的其他CPU都将忙于其他任务,因此没有理由尝试以较慢的,功能上并行的任务来打扰它们。使用流水线(反应式)并发模型可能会更好,因为它具有较少的开销(以单线程模式顺序执行),并且与底层硬件的工作方式更好地相符。
哪种并发模型最好?
那么,哪种并发模型更好?
通常,答案是这取决于系统应该执行的操作。如果工作自然是并行的,独立的并且不需要共享状态,则可以使用并行工作器模型来实现系统。
但是,许多工作并非自然而然地平行和独立。对于这些类型的系统,我相信组装线并发模型的优点要大于缺点,比并行工作器模型要有更多的优点。
我们甚至不必自己编写所有组装线基础设施的代码。像Vert.x这样的现代平台已经为我们实现了很多功能。我个人将为下一个项目探索在Vert.x等平台上运行的设计。我觉得Java EE不再具有优势。