本系列译自jakob jenkov的Java并发多线程教程(本章节部分内容参考http://ifeve.com/并发编程模型),个人觉得很有收获。由于个人水平有限,不对之处还望矫正!
并发系统可以有多种并发模型来实现,并发模型指定线程如何协同完成分配给他们的任务。不同的并发模型以不同的方式划分任务,并且线程与线程之间以不同的方式进行通信和协作。
并发模型与分布式系统的相似性
本文中描述的并发模型类似于分布式系统中使用的不同体系结构。在并发系统中,不同的线程彼此通信。在分布式系统中,不同的进程彼此通信(可能在不同的计算机上)。线程和进程在本质上是非常相似的。这就是为什么不同的并发模型通常看起来与不同的分布式系统体系结构相似。
分布式系统在处理网络失效、远程主机或进程宕掉等方面也面临着额外的挑战。但一个并发系统运行在一个大型服务器中有可能也会遇到类似的问题,比如一块 CPU 失效、一块网卡失效或一个磁盘损坏等情况。虽然失败的概率可能较低,但在理论上它仍然可能发生。
由于并发模型与分布式系统体系结构相似,所以它们常常可以互相借鉴。例如,为工作者们(线程)分配作业的模型一般与分布式系统中的负载均衡系统比较相似。同样,它们在日志记录、失效转移、幂等性等错误处理技术上也具有相似性。
1、并行工作者(Parallel worker)
在并行worker模型中,传入的作业会被分配到不同的工作者,下图展示并行工作者模型
在并行工作者模型中,委派者将传入的作业分配给不同的工作者。每个工作者完成整个任务,这些工作者并行运行在不同的线程上,甚至可能不在同一个CPU上。
如果在车厂利用并行工作者模型,那么每一辆车都是由一工人来完成的,这些工人都要有汽车的生产说明书,并且从头到尾完成一辆车的生产。
并行工作者并发模型是Java应用中最常用的一种并发模型(尽管这个情况在改变)。在java.util.concurrent包中的很多并发工具类的目的是为了应用这个模型。在Java企业版的服务器应用中,也可以看到这个模型的踪迹。
并行工作者模型的优点
并行工作者模型的优点是他容易理解,如果想要增加应用的并发规模,你只需要添加更多的工作者即可。
例如:你正在实现一个网络爬虫,你可以通过多个工作者来爬一定数量的页面,然后看在一定量的页面情况下,调整工作者个数,看多少个工作者用时最短(意味着高性能),由于网络爬虫是一种密集型IO任务,所以最终结果很可能是一个CPU中可以运行多个爬虫线程。这种情况下,如果一个CPU只有一个爬虫线程将会浪费CPU资源,因为下载数据通常会产生大量的CPU等待时间的。
并行工作者模型的缺点
并行工作的并发模型在其简单的外表下有一些隐藏缺点。我将在下面的内容中阐述几个最明显的缺点。
A)共享状态带来了复杂性
在实际的的运用中,并行工作者模型远比上面的例子复杂。共享工作者需要访问一些共享数据,主些共享数据可能是内存数据或是数据库数据。下面的图来展示这样的情形如何使得并行工作者变得更为复杂:
有时,这些共享状态可能是通讯机制里的工作队列,有时可能是业务数据,缓存数据,数据库连接池等。
一旦并行工作者模型中有共享状态,那么将会变得很复杂,线程访问共享数据,需要保证共享数据如果被一个线程更改,要对其他的线程可见(将修改结果同步到主存中,而不是当前执行线程的CPU缓存中)。线程间需要避免竞争、死锁和其他的一些因共享状态并发问题。
除此之外,部分并行线程在等待访问共享状态时,许多并发数据结构被阻塞,这就意味着在同一时刻限制了其他线程对他们的访问。这将导致线程对这些共享数据结构的竞争,从本质上讲,高竞争状态会导致获取共享数据的代码在一定程度上串行化运行。
现行非阻塞式的并行算法有可能会减少竞争和提高性能,但是非阻塞式的并行算法很难实现 。
持久化数据是另外一种选择,待久化数据结构在修改之前通常会保存上一个版本的数据,因些,如果多个线同时访问持久化数据时,如果其中一个线程对数据进行修改,修改的线程获取这个数据结果的一个新的引用,而其他线程则保持原有数据结构的引用。Scala语言具有几种持久化的数据结构。
虽然持久化数据结构是共享数据并发读写中遇到的问题的一个看似“优雅的”解决方案,但其性能并不那么好。
例如:一个待久化的列表,添加一些新的元素到这个列表的头部,然后返回新加元素的引用。其他的线程仍旧保持持久化列表之前的引用,因此对这些线程来说,新添加的元素对他们是不可见的。
B)无状态的工作者
由于共享状态可能被系统中的其他线程修改。所以工作者在需要用到的时候必须重新去读取这个状态,以确保当前工作者运行在最新的副本上。不管这个状态是保存于内存中,还是保存在外部的数据库中,都是这样去实现的。一个工作者,不需要在线程内部保存状态(而是在需要用到的时候重新去读取),这样就称之为无状态。
每次重新去获取共享数据的状态会变慢,尤其是共享状态存储在外部的数据库中。
C) 工作顺序是不确定的
并行工作者的另一个缺点就是工作顺序的不确定。我们没有办法去保证任务按顺序执行,例如:任务A有可能在任务B之前执行,也有可能任务B先于任务A执行。
并于工作者模型的顺序不确定性使得我们很难确定在给定的某个时间的状态。并且也很难保证一个任务发生于另一个任务之前 。
2、流水线模型
第二种并发模型称之为流水线模型。我之所以取这个名字,只是为了配合“并行工作模型”。其他的程序员,可能会用其他名字。下图表示一个流水线模型。
像工作生产线上的工人们一样组织工作者,每个工作者只负责整个任务中的一部分,当完成自己这部分的工作的工作者会将任务转发给下一个工作者。每个工作者都在自己的线程中运行,并且不会和其他 的工作者共享状态。因此有时也称之为无共享并行模型。
通常使用非阻塞的IO来设计使用流水线并发模型的系统。非阻塞意味着当一个工作者操作IO时(比如:读取文件或是从网络连接中获取数据),工作者不需要等到IO操作结束,由于IO操作非常慢,因此等待IO操作完成很浪费CPU时间,在等待IO操作完成的同时,CPU可以做些其他的事情,当IO操作完成时,被传递给下一个工作者。
有了非阻塞IO,就可以使用IO操作来确定工作者之间的边界。工作者会尽可能多的运行直到遇到并启动一个IO操作。然后交出工作的控制权,当IO操作完成后。然后在流水线上的下一个工作者继续进行操作。直到他也遇到并启动一个IO操作。
实事上,作业不可能沿着单一的流水线,由于多数的系统可能执行多个作业。作业从一个工作者流向另一个工作者取决于作业需要做的工作。在实际中可能会有多个不同的虚拟流水线同时运行。下面是现实中作业在流水线系统中可能的情况:
作业可能被转发到多个工作者上并发处理。比如:作业可能同时被转发到任务执行器和作业日志。下图说明了三条流水线是如何把一个作业转发给同一个工作者(中间流水线的最后一个工作者)来完成作业:
流水线作业有时会比这个更复杂
响应(Reactive),事件驱动
采用流水线并发模型的系统有时也称之为Reactive或是事件驱动系统。系统中的工作者会对来自系统内部的事件,或者接受外部的请求或者其他工作传入的事件做出响应。举个例子:事件有可能是来自于外部的HTTP请求,也有可能是某个文件成功加载到内存中。
Actors vs. Channels
Actors和Channels是两种比较类似的流水线(Reactive/事件驱动)模型。在Actor模型中每个工作者被称之为actor,Actor之间可以异步发送和处理消息。actor可以被用来实现一个或多个流水线作业。下图给出了actor模型的:
而在channel模型中。工作者之间不直接与其他工作者通信。而是他们把他们的消息(事件)发布到另一个不同的channel,其他工作者可以监听在这个channel上,而发布者不需要知道是谁在监听。下图给出了channel模型:
在写这篇文章的时候,channel模型对于我来说似乎更加灵活。一个工作者无需知道谁在后面的流水线上处理作业。只需知道作业(或消息等)需要转发给哪个通道。通道上的监听者可以随意订阅或者取消订阅,并不会影响向这个通道发送消息的工作者。这使得工作者之间具有松散的耦合。
流水线模型的优点
相对于并行工作者模型,流水线模型具有几个优点,在接下来的章节中我会介绍几个最大的优点。
无共享状态
工作者之间无状态。意味着实现的时候无需考虑所有因并发访问共享对象而产生的并发性问题。这使得在实现工作者的时候变得非常容易。在实现工作的时候就好像是单个线程在处理工作一样,这基本上是一个单线程的实现 。
有状态的工作者
当工作者知道没有别的线程修改它们的数据,工作者可以是有状态的,对于有状态,我的意思是,它们可以在内存中保存要操作的数据,只需要在最后把更改的结果写回到外部存储系统。因此,有状态的工作者通常比无状态的工作者更快。
更好的与硬件结合
单线程代码通常在结合底层硬件时具有更好的优势。首先,当你假设代码只在单线程模型下运行时,通常能能创建更优的数据结构和算法 。
其次,单线程的工作者可以缓存数据到内存中,当数据缓存在内存中,有很大的可能数据也缓存在执行这个线程的CPU中,这使得访问缓存数据变得很快。
合理的作业顺序
基于流水线并发模型实现的并发系统,在某种程度上是有可能保证作业的顺序的。作业的有序性使得它更容易地推出系统在某个特定时间点的状态。更进一步,你可以将所有到达的作业写入到日志中去。一旦这个系统的某一部分挂掉了,该日志就可以用来重头开始重建系统当时的状态。按照特定的顺序将作业写入日志,并按这个顺序作为有保障的作业顺序。下图展示了一种可能的设计:
实现一个有保障的作业顺序是不容易的,但往往是可行的。如果可以,它 将大大简化一些任务,例如备份、数据恢复、数据复制等,这些都可以通过日志文件来完成。
流水线模型的缺点
流水线并发模型最大的缺点是作业的执行往往分布到多个工作者上,并因此分布到项目中的多个类上。这样导致在追踪某个作业到底被什么代码执行时变得困难。
流水线并发模型最大的缺点是作业的执行往往分布到多个工作者上,并因此分布到项目中的多个类上。这样导致在追踪某个作业到底被什么代码执行时变得困难。
使用并行工作者模型可以简化这个问题。你可以打开工作者的代码,从头到尾优美的阅读被执行的代码。当然并行工作者模式的代码也可能同样分布在不同的类中,但往往也能够很容易的从代码中分析执行的顺序。
3、函数式并行(Functional Parallelism)
第三种并发模型是函数式并行模型。函数式并行的基本思想是采用函数调用实现程序。函数可以看作是”代理人(agents)“或者”actor“,函数之间可以像流水线模型(AKA 反应器或者事件驱动系统)那样互相发送消息。某个函数调用另一个函数,这个过程类似于消息发送。
函数都是通过拷贝来传递参数的,所以除了接收函数外没有实体可以操作数据。这对于避免共享数据的竞态来说是很有必要的。同样也使得函数的执行类似于原子操作。每个函数调用的执行独立于任何其他函数的调用。
一旦每个函数调用都可以独立的执行,它们就可以分散在不同的CPU上执行了。这也就意味着能够在多处理器上并行的执行使用函数式实现的算法。
Java7中的java.util.concurrent包里包含的ForkAndJoinPool能够帮助我们实现类似于函数式并行的一些东西。而Java8中并行streams能够用来帮助我们并行的迭代大型集合。
函数式并行里面最难的是确定需要并行的那个函数调用。跨CPU协调函数调用需要一定的开销。某个函数完成的工作单元需要达到某个大小以弥补这个开销。如果函数调用作用非常小,将它并行化可能比单线程、单CPU执行还慢。
我个人认为(可能不太正确),你可以使用响应模型(Reactive)或者事件驱动模型实现一个算法,像函数式并行那样的方法实现工作的分解。使用事件驱动模型可以更精确的控制如何实现并行化(我的观点)。
此外,将任务拆分给多个CPU时协调造成的开销,仅仅在该任务是程序当前执行的唯一任务时才有意义。但是,如果当前系统正在执行多个其他的任务时(比如web服务器,数据库服务器或者很多其他类似的系统),将单个任务进行并行化是没有意义的。不管怎样计算机中的其他CPU们都在忙于处理其他任务,没有理由用一个慢的、函数式并行的任务去扰乱它们。使用流水线(反应器)并发模型可能会更好一点,因为它开销更小(在单线程模式下顺序执行)同时能更好的与底层硬件整合。
使用那种并发模型最好?
所以,用哪种并发模型更好呢?
通常情况下,这个答案取决于你的系统打算做什么。如果你的作业本身就是并行的、独立的并且没有必要共享状态,你可能会使用并行工作者模型去实现你的系统。虽然许多作业都不是自然并行和独立的。对于这种类型的系统,我相信使用流水线并发模型能够更好的发挥它的优势,而且比并行工作者模型更有优势。
你甚至不用亲自编写所有流水线模型的基础结构。像Vert.x这种现代化的平台已经为你实现了很多。我也会去为探索如何设计我的下一个项目,使它运行在像Vert.x这样的优秀平台上。