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