Java Concurrency 并发模型
Java Concurrency 并发模型
并发系统可以使用不同的并发模型来实现。 并发模型指定系统中的线程如何协作完成所提供的作业。 不同的并发模式以不同的方式拆分作业,线程可以以不同的方式进行通信和协作。
并发模型和分布式系统额相似之处
本文中描述的并发模型与分布式系统中使用的不同架构类似。在并发系统中,不同的线程相互通信。在分布式系统中,不同的进程彼此通信(可能在不同的计算机上)。线程和过程在本质上是非常相似的。这就是为什么不同的并发模型通常看起来与不同的分布式系统架构相似。
当然,分布式系统还有一个额外的挑战,即网络可能会失败,或远程计算机或进程关闭等。但是,如果CPU出现故障,网卡故障,磁盘发生故障,则在大型服务器上运行的并发系统可能会遇到类似问题故障概率可能较低,但理论上仍可能发生。
因为并发模型与分布式系统架构相似,所以他们经常可以相互借鉴想法。例如,在工作人员(线程)之间分配工作的模型通常与分布式系统中的负载平衡模型相似。错误处理技术也是如此,如日志记录,故障转移,作业等幂等
并行模式
在并行工作并发模型中,委托者将传入的作业分配给不同的工作人员。 每个工作人员完成了全部工作。 工作并行工作,运行在不同的线程,可能在不同的CPU上。
如果平行工人模型在汽车厂实施,每辆汽车将由一名工人生产。 工人将得到汽车的规格建造,并将从头到尾构建一切。
并行工作并发模型是Java应用程序中最常用的并发模型(尽管正在改变)。 java.util.concurrent Java包中的许多并发实用程序都设计用于此模型。 您还可以在Java Enterprise Edition应用程序服务器的设计中查看此模型的跟踪。
- 并行模式的优点
并行工作并发模型的优点是易于理解。 要增加应用程序的并行化,只需添加更多的员工。
例如,如果您正在实施网络抓取工具,则可以使用不同数量的工作人员抓取一定数量的页面,并查看哪个数字可以获得最短的抓取时间(意味着最高的性能)。 由于Web爬网是IO密集型工作,因此您的计算机中的每个CPU /内核可能会有几个线程。 每个CPU一个线程会太少,因为在等待数据下载的时候它会空闲很多。
- 并行模式的缺点
并行工作并发模式在简单的表面下存在一些缺点, 我将在以下部分中解释最明显的缺点。
- 共享状态变的复杂
这种共享状态中的一些处于通信机制,如作业队列。 但是这种共享状态中的一些是业务数据,数据高速缓存,数据库连接池等。
一旦共享状态潜入并行工作并发模式,它就开始变得复杂了。 线程需要访问共享数据,以确保一个线程的更改对其他线程是可见的(推送到主内存,而不仅仅是停留在执行线程的CPU的CPU缓存中)。 线程需要避免竞争条件,死锁和许多其他共享状态并发问题。
另外,当线程在访问共享数据结构时彼此等待,部分并行化将会丢失。 许多并发数据结构是阻塞的,这就意味着一个或一组有限的线程可以在任何给定的时间访问它们。 这可能导致对这些共享数据结构的竞争。 激烈竞争本质上将导致访问共享数据结构的部分代码的执行程度。
现代非阻塞并发算法可能会降低竞争并提高性能,但是非阻塞算法很难实现。
持久数据结构是另一种选择。 持久性数据结构在修改时始终保留自身的以前版本。 因此,如果多个线程指向相同的持久性数据结构,并且一个线程修改它,则修改线程将获得对新结构的引用。 所有其他线程都保留对旧结构的引用,该旧结构仍然不变.
虽然持久性数据结构是共享数据结构的并发修改的优雅解决方案,但持久数据结构往往不能很好地执行。
例如,持久性列表会将所有新元素添加到列表的头部,并返回对新添加的元素(然后指向列表的其余部分)的引用。所有其他线程仍然保留对列表中先前第一个元素的引用,并且对于这些线程,列表不会更改。他们看不到新添加的元素。
这样的持久列表被实现为链表。不幸的是,链表在现代硬件上表现不佳。列表中的每个元素都是一个单独的对象,这些对象可以遍布计算机的内存。现代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
- Akka
- Node.JS (JavaScript)
Actors vs. Channels
Actors和Channels是组装线(或反应/事件驱动)模型的两个类似示例。
在Actors模型中,每个工人都被称为Actor。 Actor可以直接发送消息给对方。 消息被异步发送和处理。 Actor可以用于实现一个或多个作业处理装配线,如前所述。 以下是演示模型的图示
在channel模式中,员工之间不直接沟通。 相反,他们会在不同的频道上发布他们的消息(事件)。 其他工作人员可以在这些频道上收听消息,而不需要发送方知道谁正在收听。 这是一个说明channel模型的图:
在撰写本文时,channel模式对我来说似乎更加灵活。 工人不需要知道工作人员稍后将在装配线中处理工作。 它只需要知道什么channel转发作业(或发送消息等)。 channel上的听众可以订阅并取消订阅,而不影响写入频道的工作人员。 这允许工人之间有些松动的耦合。
装配线优点
与并行工作模型相比,装配线并发模型具有几个优点。 下面我将介绍最大的优点。
- 没有共享状态
工作人员与其他工作人员没有任何状态的事实意味着可以实现它们,而无需考虑并发访问共享状态可能引起的所有并发问题。 这使得工作人员更容易实施。 你实现一个工作,就像它是唯一执行这个工作的线程 - 基本上是一个单线程的实现
- 有状态的工作者
由于工作人员知道没有其他线程修改其数据,工作人员可以是有状态的。 通过状态我的意思是他们可以保留他们需要在内存中运行的数据,只需将最后的外部存储系统写回更改。 因此,有状态的工作人员通常可能比无国籍工人更快。
- 更好的一致性
单线程代码的优点是,它通常更符合底层硬件的工作原理。首先,您可以假设代码以单线程模式执行,通常可以创建更优化的数据结构和算法。
第二,单线状态工作人员可以如上所述将数据缓存在内存中。当数据被缓存在存储器中时,这个数据也被缓存在执行线程的CPU的CPU缓存中的可能性更高。这使得访问缓存的数据更快。
当代码写入的方式自然受益于底层硬件的工作原理时,我将其称为硬件一致性。一些开发商称这个“mechanical sympathy”。我更喜欢“hardware conformity”,因为电脑机械部件很少,而在这方面,“sympathy”这个词被用来作为比较好的比喻,我认为“conform”这个词相当好地传达了。无论如何,这是硝烟。使用你喜欢的任何术语。
- 有序的任务
可以以保证作业排序的方式根据装配线并发模型实现并发系统。 作业排序使得在任何给定时间点更容易理解系统的状态。 此外,您可以将所有传入作业写入日志。 如果系统的任何部分发生故障,则可以使用该日志从头重建系统的状态。 作业以一定的顺序写入日志,此订单成为有保证的作业订单。 以下是这样的设计如何看待:
实施有保证的工作单不一定很容易,但通常是可能的。 如果可以,它大大简化了备份,恢复数据,复制数据等任务,因为这些都可以通过日志文件完成。
装配线缺点
装配线并发模式的主要缺点是,作业的执行通常会分散在多个工作人员上,从而跨越项目中的多个类。 因此,更难以准确地看出给定作业正在执行哪些代码。
编写代码也可能更困难。 工人代码有时被写成回调处理程序。 使用许多嵌套回调处理程序的代码可能会导致某些开发人员调用回调地狱。 回调地狱只是意味着很难跟踪代码在所有回调中真正做到的事情,以及确保每个回调都可以访问所需的数据。
使用并行工作并发模型,这往往更容易。 您可以打开工作代码,并从头到尾读取执行的代码。 当然,并行工作代码也可以分散在许多不同的类中,但是执行顺序通常更容易从代码中读取。
功能并行
功能并行性是第三个并发模型,这个模型在很多时候都是被讨论的。
功能并行性的基本思想是使用函数调用实现程序。功能可以被看作是发送消息到彼此的“代理”或“演员”,就像装配线并发模型(AKA反应或事件驱动系统)一样。当一个函数调用另一个函数时,类似于发送消息。
传递给函数的所有参数都被复制,所以在接收函数之外没有任何实体可以操纵数据。这种复制对于避免共享数据的竞争条件至关重要。这使得函数执行类似于原子操作。每个函数调用都可以独立于任何其他函数调用执行。
每个函数调用可以独立执行,每个函数调用都可以在不同的CPU上执行。这意味着,在多个CPU上可以并行执行功能实现的算法。
使用Java 7,我们得到了包含ForkAndJoinPool的java.util.concurrent包,它可以帮助您实现类似于功能并行性的功能。使用Java 8,我们得到并行流,可以帮助您并行化大型集合的迭代。请记住,有开发人员批评ForkAndJoinPool(您可以在我的ForkAndJoinPool教程中找到一个批评链接)。
关于功能并行的难点在于知道哪个函数调用并行化。跨CPU的协调功能调用带来了开销。由功能完成的工作单位需要具有一定的大小,值得这个开销。如果函数调用非常小,尝试并行化它们实际上可能比单线程单CPU执行慢。
从我的理解(根本不完美),您可以使用反应性事件驱动模型实现算法,并实现与功能并行性相似的工作分解。使用一个甚至驱动的模型,您只需要更准确地控制并行化(在我看来)。
另外,通过多个CPU分配任务,其开销与之相协调,只有当该任务当前是程序执行的唯一任务才有意义。然而,如果系统同时执行多个其他任务(例如Web服务器,数据库服务器和许多其他系统),则尝试并行化单个任务是没有意义的。计算机中的其他CPU无论如何正在忙于处理其他任务,所以没有理由试图用较慢的功能并行任务来打扰他们。您最有可能更好地使用装配线(反应式)并发模型,因为它具有较少的开销(以单线程模式执行顺序),并且更好地符合底层硬件的工作原理
哪个并发模式是最好的
通常情况下,答案是这取决于你的系统应该做什么。 如果您的工作自然并行,独立,无需共享状态,则可以使用并行工作模型实现您的系统。
许多工作不是自然并行和独立的。 对于这些类型的系统,我相信装配线并发模型比缺点有更多的优点,比并行工作模型更有优势。
您甚至不必自己编写所有的组装线路基础设施。 像Vert.x这样的现代平台为您实现了很多。 就个人而言,我将探索在Vert.x等平台上运行的设计,以便我的下一个项目。 Java EE只是没有边缘了,我觉得。