并发系统可以用多种的并发模型实现。并发模型指系统中的多个线程如何协作完成指定的任务。不同的并发模型采用不同的方式分割任务,线程可以以不同的方法进行通信和协作。本教程会深入探讨在本文编写时(2015-2019)使用的最流行的并发模型。

并发模型与分布式系统的相似性

本文中描述的并发模型与分布式系统中的不同架构类似。在一个并发系统中,不同的线程间相互通信。在一个分布式系统中,不同的进程(可能在不同的计算机上)间相互通信。线程和进程在本质上非常相似。这就是为什么不同的并发模型通常看起来与不同的分布式系统架构类似的原因。

当然,分布式系统还有额外的挑战,比如网络可能会出现故障,或者远程计算机或者进程宕机等。但是一个运行在大型服务器上的并发系统也可能会遇到类似的问题,比如出现 CPU 故障、网卡故障、磁盘故障等。虽然出现这些故障的概率可能较低,但理论上是仍然有可能发生。

由于并发模型类似于分布式系统架构,所以二者常常可以相互借鉴。例如,在工作者(线程)之间分配工作的模型通常类似于分布式系统中的负载均衡。二者的错误处理技术也是如此,比如日志、容错、任务的幂等性等。

共享状态与分离状态

并发模型的一个重要方面是,组件和线程是被设计为在线程之间共享状态,还是被设计为具有在线程之间从不共享的分离状态。

共享状态是指系统中的不同线程会共享一些状态。状态是指一些数据,通常是一个到多个对象或者类似的。当线程共享状态时,可能会发生竞态条件和死锁等问题。当然,这取决于线程如何使用和访问共享对象。

4. 并发模型 - 图1

分离状态是指系统中的不同线程不共享任何状态。如果不同的线程需要通信,它们可以通过在它们之间交换不可变对象,或者通过发送对象(或者数据)的副本来实现。因此,当没有两个线程写入同一个对象(数据/状态)时,就可以避免大多数常见的并发问题。

4. 并发模型 - 图2

使用分离状态的并发设计,可以让代码的某些部分更易于设计、更易于推理,因为知道只有一个线程会对给定对象执行写入操作。不用担心对该对象的并发访问。不过,为了使用分离状态的并发编程,可能需要更仔细地考虑应用程序的总体设计。我认为这是值得的。就个人而言,我更喜欢分离状态的并发编程设计。

并行工作者模型

第一个并发模型称为并行工作者模型。传入的作业会被分配到不同的工作者。如下是并行工作者并发模型的示意图:

4. 并发模型 - 图3

在并行工作者模型中,委托者(Delegator)将传入的作业分配给不同的工作者(Worker)。每个工作者完成分配给自己的整个作业。不同的工作者在不同的线程上(甚至可能在不同的 CPU 上)并行运行。

举个生活中的例子,如果在某个汽车生产厂采用了并行工作者模型,那么每台车就会由一个工人来生产。每个工人都会拿到汽车的生产说明书,并且从头到尾负责所有工作。

在 Java 应用系统中,并行工作者模型是最常用的并发模型(不过这种情况正在改变)。java.util.concurrent 包中的很多并发实用工具都是为使用这个模型而设计的。我们也可以在 JavaEE 应用服务器的设计中看到这个模型的踪迹。

并行工作者并发模型可以被设计使用共享状态或者分离状态,也就是说工作者要么可以访问某些共享状态(共享的对象或者数据),要么不共享状态。

并行工作者的优点

并行工作者并发模型的优点是容易理解。如果要增加应用程序的并行化程度,只需要添加更多工作者即可。

比如,如果在实现一个网络爬虫,就可以用多个工作者(线程),每个工作者爬一定数量的页面,然后看看用几个工作者可以获得最短的总爬网时间(也就是最高的性能)。因为网络爬虫是一个 IO 密集型工作,所以最终结果可能是你电脑中的每个 CPU 或者 CPU 核分配几个线程。每个 CPU 一个线程可能有点少,因为在等待数据下载时会空闲大量时间。

并行工作者的缺点

并行工作者并发模型虽然看起来简单,但是却隐藏着一些缺点。接下来的小节会解释最明显的几个缺点。

共享状态会增加复杂性

如果共享工作者需要访问某类共享数据,这些共享数据要么在内存中,要么在共享数据库中,那么管理正确的并发访问可能会变得很复杂。下图展示这种并行工作者并发模型有多复杂:

4. 并发模型 - 图4

有些共享状态是在像作业队列这样的通信机制下。但也有一些共享状态是业务数据、数据缓存、数据库连接池等。

一旦共享状态潜入到并行工作者并发模型中,它就开始变得复杂起来。线程需要以某种方式访问共享数据,确保一个线程所做的修改对其它线程可见(被推送到主内存,而不仅仅是停留在执行线程的 CPU 的 CPU 缓存中)。线程需要避免竞态条件、死锁以及很多其它共享状态的并发问题。

此外,在访问共享数据结构时,当线程在相互等待时,部分并行化会丢失。很多并发式数据结构被阻塞,这意味着在指定时间内,只有一个或者一组有限的线程可以访问这些数据。这可能会导致对这些共享数据结构的争用。从本质上讲,高争用会导致访问共享数据结构的代码的执行出现一定程度的串行化(消除并行化)。

现代非阻塞并发算法可能会降低争用,增强性能,不过非阻塞算法很难实现。

可持久化的数据结构是另一种选择。在修改时,可持久化的数据结构总是保护它的前一个版本不受影响。因此,如果多个线程指向同一个可持久化的数据结构,并且其中一个线程做了修改,那么进行修改的线程会获得一个指向新结构的引用。所有其他线程保持对旧结构的引用,旧结构没有被修改,因此保证了一致性。Scala 标准 API 包含几个可持久化的数据结构。

虽然可持久化的数据结构在解决共享数据结构的并发修改时看似很优雅,但是可持久化的数据结构的表现往往不尽人意。

比如,一个可持久化的列表需要在列表头部插入所有新元素,并且返回对新添加元素的引用(然后这个引用指向列表的剩余部分)。所有其他线程仍然保留了这个列表之前的第一个元素,所以对于这些线程来说,列表仍然是未改变的。它们无法看到新添加的元素。

这种可持久化的列表可以采用链表来实现。然而,不幸的是,链表在现代硬件上表现的不太好。链表中的每个元素都是一个独立的对象,这些对象可能遍布在整个计算机内存中。现代 CPU 能更快的进行顺序访问,所以可以在现代硬件上用数组实现的列表,以获得更高的性能。数组可以顺序的保存数据。CPU 缓存能够一次加载数组的一大块进行缓存,一旦加载完成,CPU 就可以直接访问缓存中的数据。这对于元素散落在 RAM 中的链表来说,不太可能做得到。

无状态的工作者

共享的状态可以被系统中的其他线程进行修改,因此工作者必须在每次需要共享数据的时候重读这些数据,来保证他所获得的数据是最新的。这对于无论共享状态是保存在内存中还是保存在外部数据库中都是适用的。如果一个工作者不在其内部保存共享状态(而是每次都重新读取最新的数据),那么我们称其为无状态的。

共享状态可以被系统中的其他线程修改,所以每次工作者在需要共享状态时,不管共享状态是保存在内存中,还是在外部数据库中,都必须重读状态,以确保每次都能访问到最新的副本。如果工作者不在内部保存状态,而是每次需要的时候都要重读状态,就称为无状态的工作者。

每次都重读需要的数据,将会导致速度变慢,特别是状态保存在外部数据库中的时候。

作业顺序是不确定的

并行工作者模型的另一个缺点是作业的执行顺序是不确定的。无法保证哪些作业先执行,哪些作业后执行。作业 A 可能要在作业 B 之前交给工作者,而作业 B 可能在作业 A 之前就已经执行。

并行工作者模型的不确定性很难推理出系统在任何给定时间点的状态。这也让保证一项任务在另一项任务之前完成变得更加困难(如果这可以实现的话,可以说是难上加难)。然而,这并不一定总是会引起问题,主要还是取决于系统的需要。

流水线模型

第二种并发模型称为流水线并发模型。选择这个名字只是为了与前面的并行工作者比喻相吻合。其他开发人员根据平台或者社区使用其它名称(比如响应式系统或者事件驱动系统)。如下是流水线并发模型的示意图:

4. 并发模型 - 图5

在这种模型中,工作者被组织成像工厂流水线上的工人一样,沿着流水线工作。每个工作者只执行完整作业的一部分。当该部分工作完成时,工作者把作业转交给下一个工作者。

使用流水线并发模型的系统通常用非阻塞 IO 来设计。非阻塞 IO 意味着,当一个工作者开始一个 IO 操作(比如读文件或者从网络连接中读数据)时,这个工作者不会一直等待 IO 调用的结束。IO 操作很慢,所以等待 IO 操作完成很浪费 CPU 时间。此时,CPU 可以做一些其它事情。当 IO 操作完成时,IO 操作的结果(比如读出的数据或者数据写完的状态)被传给下一个工作者。

有了非阻塞 IO,就可以用 IO 操作确定工作者之间的边界。一个工作者会尽可能多地工作,直到它必须启动一个 IO 操作为止。然后它交出对作业的控制权。当 IO 操作完成时,流水线中的下一个工作者继续处理该作业,直到它也必须启动一个 IO 操作为止,依此类推。

4. 并发模型 - 图6

在实际应用中,作业有可能不会就沿着一条流水线流动。由于大多数系统可以执行多个作业,所以作业会根据下一个需要执行的作业部分,从一个工作者流向下一个工作者。实际上,可能有多条不同的虚拟流水线同时运行。如下是现实中流水线系统的作业流向示意图:

4. 并发模型 - 图7

作业甚至可以被转发给多个工作者并发处理。比如,一个作业可能被同时转发给作业执行者和作业记录器。下图阐述了所有三条流水线是如何转发其作业给同一个工作者(中间流水线中的最后一个工作者)来完成作业的:

4. 并发模型 - 图8

流水线可能会变得比这更复杂。

响应式、事件驱动系统

使用流水线并发模型的系统有时也被称响应式系统或者事件驱动系统。系统的工作者对系统中发生的事件作出响应,这些事件要么是从外部世界接收的,要么是由其它工作者发出的。比如,事件可以是一个传入的 HTTP 请求,或者某个文件完成加载到内存中,等等。

在写本文时,已经有很多有趣的响应式/事件驱动平台可用,将来还会有更多出现。比较流行的是如下几个:

  • Vert.x
  • Akka
  • Node.JS (JavaScript)

个人觉得 Vert.x 非常有趣(特别是对于像我这样沉迷于 Java / JVM 的人来说)。

Actor 模型与 Channel 模型

Actor 和 Channel 是两种比较类似的流水线(或者响应式/事件驱动)模型示例。

在 Actor 模型中,每个工作者称为一个 actor。Actor 之间可以直接相互发送消息。消息是异步发送和处理的。Actor 可以被用来实现一到多个像前文所描述的那样的作业处理流水线。如下是 Actor 模型的示意图:

4. 并发模型 - 图9

在 Channel 模型中,工作者不直接相互通信,而是在不同的通道上发布其消息(事件)。然后其它工作者可以在这些通道上监听消息,而不需要发送者知道谁在监听。如下是 Channel 模型的示意图:

4. 并发模型 - 图10

到本文编写时,Channel 模型对我来说似乎更灵活。一个工作者不需要知道哪个工作者在后面的流水线上处理作业,只需要知道将作业转发给哪个通道(或者发送消息给哪个通道,等等)。通道上的监听器可以订阅和取消订阅,不会影响到写入到通道的工作者。这就让工作者之间的耦合更为松散。

流水线模型的优点

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

无共享状态

由于在流水线模型中,一个工作者不需要与其它工作者共享任何状态,这意味着,实现工作者的时候不需要考虑并发访问共享状态时可能引起的所有并发问题。这就让实现工作者变得相当容易。在实现一个工作者的时候,就好像它是执行该工作的唯一线程一样 — 基本上就是一个单线程实现。

有状态的工作者

既然工作者知道没有其它线程会修改它们的数据,那么工作者就可以是有状态的。有状态意味着它们可以将操作所需的数据保存在内存中,只需最后把更改写回到外部存储系统。因此,有状态的工作者通常比无状态的工作者更快。

更好的硬件符合性

单线程代码的优点是,它通常更符合底层硬件的工作机制。首先,当能确定代码只以单线程模式执行时,通常能创建更优化的数据结构和算法。

其次,如上面所述,单线程有状态的工作者可以把数据缓存在内存中。当数据被缓存在内存中时,该数据也被缓存在执行该线程的 CPU 的 CPU 缓存中的概率会更高。这就让访问缓存数据的速度变得更快。

在编写代码时采用一种自然受益于底层硬件的工作机制的方式去编写,我称之为硬件符合性(hardware conformity)。有些开发者称之为机械同理心(mechanical sympathy)。我更喜欢“硬件符合性”这个术语,因为计算机的机械部分极少,而这种上下文背景下,我认为单词“符合(conform)”来比喻“更好匹配”,比用单词“同理心(sympathy)” 表达得更好。不管怎样,这都是咬文嚼字的文字游戏而已,自己喜欢用什么术语就用好了。

作业顺序是可能的

基于流水线并发模型,以保证作业顺序的方式实现一个并发系统是可能的。作业顺序让推理出系统在某个给定时间点的状态变得很容易。此外,可以把所有进来的作业写入到日志中。然后在系统的任何部分出现故障时,这个日志就可以用于从头开始重建系统的状态。作业按照某种顺序写入到日志中,这种顺序就变成了有保证的作业顺序。如下是这种设计的示意图:

4. 并发模型 - 图11

实现一个有保证的作业顺序并不容易,但往往是可能的。如果可以的话,它就可以极大地简化像备份、恢复数据、复制数据等任务,因为这都可以通过日志文件搞定。

流水线模型的缺点

流水线并发模型的主要缺点是,一个作业的执行通常分散到多个工作者上,并因此分散到项目中的多个类上。因此,要看到某个作业到底是在执行哪些代码就变得很困难。

这可能还让编写代码变得更困难。有时候将工作者代码写成回调处理器。如果代码带有大量嵌套的回调处理器,会导致某些开发人员所称的“回调地狱”。回调地狱只是意味着跟踪代码在整个回调过程中到底做了什么,以及确保每个回调只访问它需要的数据时,会变得非常困难。

如果是使用并行工作者并发模型的话,这往往更容易。可以打开工作者代码,从头到尾优美地阅读被执行的代码。当然,并行工作者代码也可能散布在很多不同的类上,但是通常也能很容易从代码中读懂执行顺序。

函数式并行模型

函数式并行是最近(2015年)讨论得很多的第三种并发模型。

函数式并行的基本理念是用函数调用实现程序。函数可以被看作是相互发送消息的 “代理(agents)” 或者 “actor”,就像流水线并发模型(亦称响应式或者事件驱动系统)一样。一个函数调用另一个函数时,就类似于发送一条消息。

传给函数的所有参数被复制,因此接受函数之外的任何实体就不能操作该数据。这种复制本质上就是避免在共享数据上的竞态条件。这就让函数执行变成类似于原子操作。每个函数调用都可以独立于任何其它函数调用执行。

当每个函数调用都可以独立执行时,每个函数调用就可以在不同的 CPU 上执行了。也就是说,函数式实现的算法可以在多个 CPU 上并行执行。

随着 Java 7 的发布,我们有了 java.util.concurrent 包,这个包包含了ForkAndJoinPool,它可以帮助我们实现类似于函数式并行的代码。随着 Java 8 的发布,我们就有了并行流(streams), 可以帮助我们并行化大型集合的迭代。请记住,有些开发者对 ForkAndJoinPool 持批判态度(在后面的 ForkAndJoinPool 教程中可以找到批判的链接)。

函数式并行的难点在于,知道哪些函数调用要并行化。跨 CPU 协调函数调用会带来一定开销。一个函数所完成的工作单元必须有一定大小,才值得这种开销。如果函数调用很小,那么试图并行化它们实际上可能比单线程、单 CPU 执行更慢。

根据我的理解(也不算完美),可以用响应式、事件驱动模型实现一种算法,实现类似于通过函数式并行所能实现的工作分解。用事件驱动模型,就可以更精确地控制并行化的内容和程度(依我所见)。

此外,仅当一个任务当前是程序正在执行的唯一任务时,把一个任务拆分到多个 CPU 上,并协调所带来的开销才有意义。不过,如果系统是并发执行多个其它任务(比如像 Web 服务器、数据库服务器以及很多其它系统所做那样),那么试图并行化单个任务是没有意义的。计算机中的其它 CPU 无论如何都会忙于处理其它任务,所以没有理由试图用一个较慢的、函数式并行任务来干扰它们。这时候用流水线(响应式)并发模型可能会更好,因为它开销更小(以单线程模型顺序执行),并且更符合底层硬件的工作机制。

哪种并发模型最好?

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

通常情况下,答案是这取决于系统应该做什么。如果作业是自然并行的、独立的,并且不需要共享状态,那么就可以用并行工作者模型来实现系统。

不过,很多作业并不是自然并行以及独立的。对着这类系统,我认为流水线并发模型的优点多于缺点,并且比并行工作者模型有更多的优点。

我们甚至不用自己编写所有流水线基础设施的代码。像Vert.x这种现代平台已经为我们实现了很多功能。就我个人而言,我会为我的下一个项目探索运行在像 Vert.x 这类平台之上的设计。我认为,JavaEE 已经没有优势了。