跳到主要内容

04、Java并发编程:并发模型(共享状态,分离状态,并行工作机模型,流水线模型,反应/事件驱动系统,函数式并行)

并发系统可以由不同的并发模型来实现。并发模型规定了系统中线程的协作方式,从而能够共同完成指定任务。不同的并发模型会将任务按不同的方式分割,而线程之间也通过不同的方式来通信和协作。本并发教程将更深入一点的讨论时下(2015 - 2019)使用最普遍的并发模型。

并发模型和分布式系统的相似之处

本文所描述的并发模型和以不同架构实现的分布式系统很相似。在并发系统中不同线程之间彼此通信。在分布式系统中进程之间彼此通信(进程可能在不同的计算机上)。实际上线程和进程大同小异,这也正是为什么不同的并发模型和不同的分布式系统往往看上去很像。

当然,分布式系统要面对更多的挑战,比如网络失败,远程计算机或进程故障等。而对于运行在大型服务器上的并发系统来说,存在类似的问题,比如CPU故障,网卡故障,硬盘故障等。故障的几率可能较低,但是理论上仍会发生故障。

由于并发模型和分布式系统架构很相似,所以它们之间常常可以互相借鉴。例如,工作线程之间分配工作的模型常常类似于分布式系统的负载均衡模型。类似的还有错误处理技术,比如日志记录,故障转移,任务的幂等性等。

共享状态 VS 分离状态

并发模型的一个重要方面是,该让组件和线程在线程之间共享状态呢?还是各自拥有独立的状态,从不在线程之间共享呢?

共享状态表示系统的线程之间会共享一些状态。状态指的是某些数据,通常类似于一个或多个对象。线程间共享状态会导致一些问题,比如竞态条件,死锁等。当然这取决于线程是如何使用和访问共享对象的。
 
分离状态表示系统的不同线程之间不会共享任何状态。如果不同线程之间需要通信,它们可以通过交换不可变对象的方式实现,也可以通过发送对象(或数据)的拷贝的方式来实现。所以两个线程写入同一个对象(或数据、状态)的情况就不存在了,也就避免了大多数常见的并发问题。
 
采用分离状态的并发设计常常可以让一部分代码更容易实现和分析,因为你清楚的知道只有一个线程会写某个对象,不用担心该对象被同时访问。但是,使用分离状态并发来设计程序时,你可能还要统筹兼顾,考虑的更细致。我觉得这么做是值得的。我个人偏向于使用分离状态并发的设计。

并行工作机模型(Parallel Workers)

最初的并发模型我把它叫做并行工作机模型,它把接到的工作分配给不同的工作机。下面是并行工作机并发模型的示意图。
 
在并行工作机并发模型中,有个委派机负责把接到的任务分发给不同的工作机。每个工作机完成整个工作。工作机在不同的线程(可能在不同的CPU)中并行的工作。

假如在汽车工厂中实现这个并行工作机模型,每辆车会由某个工人生产。工人会按照汽车的生产规范,从头到尾把这辆车制造出来。

并行工作机并发模型是java程序中最常用的并发模型,虽然这已经在变化了。在java.util.concurrent包中的很多并发工具类是以此模型而设计的。你也能在企业版java应用服务器中看到该模型的踪迹。

并行工作机模型的优点

并行工作机并发模型的优点是容易理解。你只需增加更多的工作机就可以增加应用的并行能力。
例如你要实现一个网络爬虫,你可以用不同数量的工作机来爬取一定量的页面,看用多少数量的工作机爬取的时间最短(也就意味着性能最好)。由于网络爬取是IO密集型作业,最终很可能每个CPU或核心只需开几个线程。一个CPU只开一个线程会太少,因为CPU大部分时间在等待下载数据,处于空闲状态。

并行工作机模型的缺点

虽然表面上简单,但是并行工作机并发模型也存在一些缺点。我会在下面的章节中阐明最明显的缺点。

共享状态会变得更复杂

实际上的并行工作机并发模型要比上面示例中的更复杂一些。共享工作机经常需要访问某些类型的共享数据,这些数据处于内存或者某个共享数据库中。这种情况让并行工作机并发模型变的复杂,如下图所示:
 
某些共享状态存在于诸如工作队列的通信机制中,而另一些共享状态则是业务数据,缓存数据,数据库连接池等。

一旦并行工作机并发模型中引入了共享状态,它就开始变得复杂。线程在访问共享数据时,需要确保一个线程所做的修改对其他线程可见(推送到主内存,而不是仅仅停留在执行线程的CPU缓存中)。线程需要避免竞态条件、死锁和许多其他共享状态并发问题。

此外,当线程因访问共享数据结构而彼此等待时,将会丢失部分并行能力。许多并发数据结构是阻塞的,这意味着一个或有限的线程集可以在任意时间访问它们。这可能导致对这些共享数据结构的争用。激烈的争用本质上将导致访问共享数据结构的部分代码要顺序执行。

现代非阻塞并发算法可以减少竞争,提高性能,但非阻塞算法很难实现。

另一种选择是持久化数据结构。持久化数据结构在修改时始终保留其以前的版本。因此,如果多个线程指向同一个持久性数据结构,并且某个线程对其进行了修改,则该线程将获得新结构的引用。所有其他线程都保留对旧结构的引用,该结构仍然保持不变,因此保持一致。Scala编程包含了几个持久化数据结构。

虽然持久化数据结构很好的解决了并发地修改共享数据结构,但它往往不能很好地执行。

例如,持久化列表将所有新元素添加到列表的头部,并返回新元素的引用(该元素又指向列表的其余部分)。所有其他线程仍保留列表中前一个元素的引用,所以对于这些线程,列表看上去没有改变。它们看不到新添加的元素。

这种持久性列表是用链表实现的,但是链表在现代计算机上的性能并不好。列表中的每个元素都是一个单独的对象,这些对象可以分散在计算机内存的各个地方。当今的CPU在顺序访问数据方面要快得多,所以在当代计算机上,列表用数组实现会有更好的性能。数组是按顺序存储数据的,CPU缓存可以一次将更大的数组块加载到缓存中,之后让CPU直接访问缓存中的数据。而对于元素分散在内存中的链表来说,这是不可能的。

无状态工作机(Stateless Workers)

共享状态可以由系统中的其他线程修改。因此,工作机每次都必须重新读取状态,才能确保在最新的状态上工作。无论共享状态保存在内存中还是外部数据库中,都需要这么做。内部不保留状态(但每次都要重新读取)的工作机称为无状态工作机。
. 在每次需要时重新读取数据会变慢。如果状态存储在外部数据库中,会变得更慢。

任务次序是不确定的

并行工作机模型的另一个缺点是任务执行顺序不确定。无法保证哪些任务是先执行还是最后执行。任务A可以在任务B之前交给工作机,但任务B可能在任务A之前执行。
并行工作机模型天然的不确定性导致很难随时预测系统状态,也更难(如果可行)保证一项任务先于另一项任务。

流水线模型(Assembly Line)

第二种并发模型我把它称为流水线并发模型。我选择这个名字只是为了配合先前的“并行工作机”的比喻。其他开发者根据平台或者社区使用其他名字(例如反应式系统或事件驱动系统)。下面是流水线并发模型的示意图:
 
工作机像工厂装配线上的工人一样组织起来,每个工人只完成全部工作的一部分。当该部分完成时,工人将工作转发给下一个工人。

每个工作机都在自己的线程中运行,并且与其他工作机不共享任何状态。有时这也称为无共享并发模型。

使用流水线并发模型的系统通常设计为使用非阻塞IO。非阻塞IO意味着当工作进程启动IO操作(例如从网络连接读取文件或数据)时,工作进程不会等待IO调用完成。IO操作很慢,因此等待IO操作完成是在浪费CPU时间。CPU这时可以做别的事情。IO操作完成后,IO操作的结果(例如读到的数据或数据写入的状态)将传递给另一个工作机。

对于非阻塞IO,IO操作决定了工作机之间的边界。工作机尽其所能的工作,直到不得不启动IO操作,然后它就不再控制任务了。当IO操作完成时,流水线上的下一个工作机继续处理该任务,直到该任务也必须启动IO操作等。
 
实际上,这些任务可能不会沿着一条流水线进行。由于大多数系统可以执行多个任务,根据需要完成的任务,任务从一个工作机流向另一个工作机。实际上可能有很多不同的虚拟流水线在同时进行,这就是现实中流水线系统工作流程的样子。
 

任务甚至可以转发给多个工人进行并发处理。例如,任务可以转发给任务执行器和任务日志。此图说明了所有三个流水线将其任务转发给同一个工作机(中间装配线中的最后一个工作机)来完成任务:
 
流水线可能会变得更加复杂。

反应/事件驱动系统(Reactive, Event Driven Systems)

使用流水线并发模型的系统有时也称为反应系统,或事件驱动系统。系统的工作机对系统中发生的事件做出反应,这些事件来自外部世界,或者由其他工作机发出。事件可以是传入的HTTP请求,或者某个文件完成内存加载等。

在编写本文时,已经有许多有趣的反应/事件驱动平台,以后会有更多。下面这些似乎更受欢迎:
Vert.x
Akka
Node.JS (JavaScript)
就我个人而言,我觉得Vert.x非常有趣(特别是对于像我这样的Java/JVM老油条来说 ——译者注:原文为especially for a Java / JVM dinosaur like me)。

参与者与通道(Actors vs. Channels)

参与者和通道是流水线(或反应/事件驱动)模型的两个类似示例。在参与者模型中,每个工作机都称为参与者。参与者可以直接相互发送消息,消息是异步发送和处理的。如前所述,参与者可用于实现一个或多个工作流水线。下面是参与者模型的示意图:
 
在通道模型中,工作机之间不直接沟通。相反,他们在不同的通道上发布消息(或事件)。其他工作机可以在这些通道上监听消息,而发送者不知道谁在监听。下面是通道模型的示意图:
 
在撰写本文时,通道模式对我来说似乎更加灵活。某个工作机不需要知道哪些工作机后续将在流水线上处理任务。它只需知道向哪个通道转发工作 (或给哪个通道发送消息,等等)。通道上的侦听器在注册和取消时不会影响到写通道的工作机。这使得工作机之间的耦合更加松散。

流水线模型的优点

与并行工作机模型相比,流水线并发模型有几个优点。我将在下面的章节中介绍最大的优点。

不共享状态

工作机与其他工作机不共享任何状态,这意味着在实现它们时,所有在并发访问共享状态上会出现的问题,我们都不用再考虑了。这使得工作机更容易实现。你在实现一个工作机时,这个工作机就好像是唯一一个执行该任务的线程—本质上是一个单线程实现。

有状态的工作机

由于工作机知道其他线程不会修改他们的数据,工作机可以是有状态的。我的意思是,他们可以将需要操作的数据保存在内存中,只需将改动写回最终的外部存储系统。因此,有状态的工作机通常比无状态的工作机更快。

更好的硬件整合

单线程代码的优点是,它通常更符合底层硬件的工作方式。首先,当你认为代码会以单线程模式执行时,通常可以创建优化更好的数据结构和算法。

其次,单线程有状态工作机可以在内存中缓存数据,如上所述。当数据被缓存在内存中时,该数据也更有可能被缓存在执行线程的CPU缓存中。这使得访问缓存数据的速度更快了。

代码的编写方式天然的受益于底层硬件的工作方式,我称之为硬件一致性。一些开发者称之为机械同情(译者注:原文是mechanical sympathy,直译为机械同情)。我更喜欢硬件一致性这个词,因为计算机很少有机械部件,在这种情况下,“同情”一词隐喻为“更好地匹配”,而我相信“一致”一词传达得相当好。无论如何,这是吹毛求疵。你可以用你喜欢的任何术语。

可以进行任务排序

我们可以根据流水线并发模型,以保证任务有序的方式来实现一个并发系统。任务有序使我们更容易推测某个时间点的系统状态。不仅如此,还可以将所有传入的任务写入日志。进而可以使用此日志从头开始重建系统状态,以防系统的某部分出现故障。任务以特定顺序写入日志,此顺序会成为确定的任务顺序。下面是这样一个设计的样子:
 
译者注:上图看上去有错,应该是原文配图出错了,待原文修正后我再更新
虽然确定的任务顺序实现起来不一定容易,但往往是可行的。如果可行,它可以大大简化备份、恢复数据、复制数据等任务,因为这些都可以通过日志文件来完成。

流水线模型的缺点

流水线并发模型的主要缺点是,一个任务通常分布在多个工作机上执行,从而分布在项目中的多个类上。因此,很难确切地看到某个任务有哪些代码在执行。

编写代码也可能会更困难。工作机的代码有时编写成回调处理。于是代码中出现层层嵌套的回调处理,一些开发者把这称为回调地狱。回调地狱指的是很难在所有回调中跟踪代码真正在做什么,也很难确保每个回调都能访问所需的数据。

而使用并行工作机并发模型,这个问题就简单了。你可以打开工作机代码并读取从头到尾的执行代码。当然,并行工作机代码也可以分布在许多不同的类上,但是通常从代码中更容易读到执行顺序。

函数式并行(Functional Parallelism)

最近(2015)讨论较多的是第三种并发模型:函数式并行。
函数式并行的基本思想是使用函数调用来实现程序。函数可以看作是相互发送消息的“代理”或“参与者”,就像在流水线并发模型(也称为反应式或事件驱动系统)中的一样。一个函数调用另一个函数时类似于发送一个消息。

传递给函数的所有参数都会被复制,因此任何在接收函数之外的实体都不能操作数据。这种复制对于避免共享数据上的竞态条件至关重要。这使得函数的执行类似于原子操作。每个函数调用的执行都可以独立于任何其他函数。

当函数调用可以独立执行时,每个函数调用就可以在单独的一个cpu上执行。这意味着,一个用函数式实现的算法可以在多个cpu上并行执行。

在Java7中有了Java.util.concurrent包,其中包含了ForkAndJoinPool,它可以帮助你实现类似于函数式并行的功能。在Java 8中有了并行流,它可以帮助你把大型集合的迭代并行化。要注意的是,有些开发人员对ForkAndJoinPool持批评态度(可以在我的ForkAndJoinPool教程中找到批评的链接)。

函数式并行的难点在于要弄清楚哪些函数调用要并行化。在CPU之间协调函数调用会带来开销。一个函数完成的工作单元需要达到一定的大小,才值得这样的开销。如果函数调用非常小,那么若把它们并行化实际上可能比单线程、单CPU执行的更慢。

根据我的理解(虽不完美),你可以使用一个反应式的、事件驱动的模型来实现一个算法,并将工作分解成类似于函数式并行所实现的那样。在我看来,使用事件驱动模型,你可以更准确地控制要并行化的内容和数量。

另外,只有当某个任务是程序当前执行的唯一任务时,将该任务拆分到多个cpu上所产生的协调开销才有意义。而如果系统同时执行多个其他任务(如web服务器、数据库服务器和许多其他系统),那么尝试并行一个任务是没有意义的。计算机中的其他CPU终将会忙于处理其他任务,因此没有理由试图用一个较慢的、函数式并行的任务来干扰它们。使用流水线(反应式)并发模型很可能更好,因为它具有更少的开销(以单线程模式顺序执行),并且更好地符合底层硬件的工作方式。

哪种并发模型最好?

那么,哪种并发模型更好呢?

通常情况下,答案取决于系统要做什么。如果你的任务本来就是并行的、独立的并且不需要共享状态,那么你可以使用并行工作机模型来实现你的系统。

然而,许多工作并不是自然而然并行和独立的。对于这些类型的系统,我相信流水线并发模型比并行工作机模型有更多的优点而不是缺点。

你甚至不必自己编写所有的流水线基础代码。像Vert.x这样的新平台已经为你实现了很多这样的功能。对于我来说,我将试着在Vert.x这样的平台上设计下一个项目。我觉得Java EE已经没有优势了。