Fork me on GitHub

4. [译]并发的模型

本文翻译自Java Concurrency / Concurrency Models

并发系统可以使用不同的并发模型来实现,并发模型是指线程在系统中如何写作来完成给定的任务。不同的并发模型以不同的方式拆分任务,线程间以不同的方式协作和通信,本文将深入研究在撰写本文时最流行并发模型(2015年)。

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

本文中描述的并发模型与分布式系统中使用的架构类似,在一个并发系统中,不同的线程之间互相通信,在一个分布式系统中,不同的进程间彼此通信(这些进程可能在不同的电脑上)。线程和进程在本质上时非常相似的,这就是为什么不同的并发模型与不同的分布式系统架构通常看起来相似。

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

由于并发模型和分布式系统架构类似,它们通常可以相互借鉴,比如在线程中分配工作的模型通常与分布式系统中的负载均衡类似,它们的错误处理手段也类似,例如日志(logging)、故障切换(fail-over)和等幂性任务(idempotency of jobs)等。

并行工作者模型(Parallel Workers model)

并行工作者模型是本文要说明的第一个并发模型,该模型会将系统中到来的任务分配给不同的工作者,如下图所示:
"并行工作者模型"

并发模型中有一个“委托者”将到来的任务分配给不同的工作者,每个工作者完成整个任务,每个工作者在不同的线程中(也有可能在不同的CPU)并行工作。

如果一个汽车厂采用了并行工作者模型,那么每辆汽车将由一个工人根据说明书从头到尾来制造。

并行工作者模型是Java应用程序中使用最广泛的并发模型(尽管这种情形正在发生变化),java.util.concurrent 中的许多包都被设计用于此模型,你也可以在Java企业级服务器的设计中找到此模型的应用踪迹。

并行工作者模型的优点

并行工作者模型的优点是理解容易,当要增加应用程序的并行能力时我们只需添加更多的工作者即可。

例如,假设你想实现一个网络爬虫,你可以使用不同数量的工作者线程来爬取制定数量的页面,根据结果来决定使用多少个工作者线程具有最短的抓取时间(同时意味着最优性能)。由于网络爬虫是IO密集型工作,在等待下载数据时会浪费大量时间,若每个CPU只运行一个线程时效率不高,所以最终的结果可能会是在电脑中一个CPU/内核运行多个线程。

并行工作者模型的缺点

并行工作者模式在其简单外表之下还有若干缺点,我将在以下部分说明其中最为明显的几个。

状态共享将使复杂性增加

实际上并行工作者模型比上面说明的还要复杂一些,并行工作者通常需要访问一些共享数据,它们可能存储在内存中也可能存在数据库中,下面的图标展示了这种情形是如何是的并行工作者模型变得复杂的。
"并行工作者访问共享数据"
其中的一些共享状态可能在类似于任务队列的通信过程中,但是另外一些共享状态可能是商业数据、缓存数据、数据库的连接池等。一旦共享状态引入到了并行工作者模型,问题就开始变得复杂。线程需要一种方式来访问共享数据以确保一个线程对共享数据的更改对其它线程是可见的(将其推送到主内存中,而不是仅停留在执行线程的CPU缓存中)。线程间需要避免竞争条件、死锁和其它共享状态相关的问题。

另外,当线程间在等待彼此访问共享数据结构时,会降低应用程序的并行性。许多并发数据结构都是阻塞式的,这意味着在给定时间只有一个或一组有限的线程可以访问它们,这可能导致线程对这些共享数据的竞争,高度竞争将会导致访问共享数据的代码从本质上变为串行执行。

现代的 非阻塞并行算法(non-blocking concurrency algorithms ) 可能会减少竞争和提高性能,但是非阻塞算法很难实现。

持久化数据结构是另外一种选择,一个持久化数据在自身被修改时会始终保留之前的值。因此,如果多个线程同时操作一个持久化数据并且其中一个修改了该数据,该线程会得到新数据的引用,而其它线程在则保持着对未修改的旧数据的引用,从而依旧保持一致。在Scala编程中包含若干个持久化的数据结构。

虽然持久化数据结构是并发修改共享数据的一种看似优雅的解决方案,但其执行性能并不理想。例如,一个持久化的列表会把新元素加入其首部并且返回对该新增元素的引用(它将会指向列表的其余元素)。所有其它的线程仍然保持着对先前列表中第一个元素的引用,对这些线程而言该列表并没有发生修改,它们看不见新增加的元素。

这种持久化的列表可以用链表来实现,不幸的是,现在的硬件并不能很好的支持链表,链表中的每一个元素都是一个单独的对象,这些对象可以遍布计算机的内存。现在的CPU在访问连续的内存地址时速度更快,因此实现为数组(Array)结构会获得更高的性能。对于一个以数组方式存储的数据而言,CPU缓存可以一次将更大的数组块加载到缓存中,一旦数据加载完毕,CPU可以直接在缓存中访问这些数据,而这对于元素分散在RAM中的链表而言是不太可能实现的。

无状态的工作者

共享状态可以被系统中的其它线程修改,因此工作者(workers)在每次需要它们时都必须重新读取该状态,以确保它在最新的副本上工作,无论共享状态是保存在内存还是外部数据库中,都是如此。一个工作者不在其内部保存状态(而是在每次需要时都重新读取),我们称之为无状态。

任务顺序的不确定

并行工作者模型的另一个缺点是任务执行的顺序无法确定。没有办法来确保某个任务最先执行或最后执行,任务A在任务B之前分配给一个工作者,但是任务B可能先于任务A执行。

并能工作者模型的不确定性使得很难在任何给定的时间点推理系统的状态,它同样使得确保一个任务在另外一个任务之前执行变得更难(如果可能)。

流水线模型(Assembly Line)

第二种并发模型我称之为流水线模型,我选择名称以符合早期“并行工作者”的含义。在不同的平台/社区中,其他的开发人员或许使用其它的名称,如反应式系统(reactive systems),或事件驱动系统(event driven systems),下图是流水线并发模型的一个展示
"流水线并发模型"
这些工作者就像工厂里的工人一样组织起来,每个工作者只完成整个任务的一部分,当该部分任务完成时,该工作者将任务转移到下一个工作者。每个工作者都在自己的线程中运行,并且没有与其它的工作者共享状态,因此流水线模型有时也被称之为无共享的并发模型。

流水线模型通常用于系统中的非阻塞IO操作,非阻塞IO意味着当一个工作者(worker)开始一个IO操作时(如从网络读取文件或数据),该工作者(worker)不必等待IO操作结束。IO操作通常较慢,因此等待IO操作完成是对CPU时间的浪费,CPU可以在此时做一些其它事情。当IO操作完成时,IO操作的结果(如数据状态读取或输入写入)会传给下一个工作者(worker)。

使用非阻塞IO时,IO操作的结果决定了工作者(worker)之间的边界,一个工作者(worker)在不得不开始IO操作之前可以尽可能的完成任务,然后放弃对该任务的控制,当IO操作结束时,在流水线上的另一个工作者(worker)以类似的方式继续完成该任务,直到它不得不开始IO操作。
"非阻塞IO操作"

实际中,上述这些任务可能不会沿着一条流水线流动,因为大多数操作系统可以同时运行多个任务,这些任务根据实际需求沿着流水线逐个的被工作者处理。在实际使用中可能会有多个虚拟流水线同时运行,下图展示了在实际使用中任务如何在这种流水线上流转。
"多条流水线的模型"

任务甚至可以转发给多个工作者进行并发处理,例如,一个任务可以被同时转发给一个任务执行器和一个任务日志记录器。下图展示了如何将三条装配线的中任务转发给同一个工作者完成(中间装配线上的最后一个工人):
"多条流水线指向同一个工作者"
流水线甚至可以做的比上面展示的更复杂。

响应式、事件驱动系统

使用流水线并发模型的系统有时候也被称之为 响应式系统事件驱动系统 。系统工作者在事件发生时做出对应的响应:从外部接收消息或转发给其它工作者等。事件驱动的例子可能是传入的HTTP请求,也可能是某个文件完成加载到内存中等。

在写作本文时,已经有一些有趣的响应式/事件驱动平台可以使用,并且在将来会出现更多的。其中一些比较受欢迎的如下:

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

对我个人而言,我发现Vert.x十分有趣(尤其是像我这种对Java/JVM落伍的人)。

参与者(Actors)与管道(Channels)对比

参与者(Actors)和管道(Channels)是两种类似的流水线(响应式/事件驱动)模型。

在参与者模型中,每个工作者被称之为一个参与者,参与者之间可以直接发消息给对方,这些消息以异步方式来发送和处理。参与者可以用于处理如前所述的一个或多个流水线任务,下图展示了这种模型:
"参与者模型"

在管道(Channel)模型中,工作者之间不直接互相沟通,相反地,他们会将消息发布到不同的管道中,其他的工作者可以在这些管道上收听消息,同时消息发送者不必知道谁在收听消息。下图展示了该模型:
"管道模型"

在写作本文时,管道模型对我而言似乎更灵活:一个工作者不必知道在流水线上的哪个工作者要处理接下来的任务,它只需要知道需要将任务转发到哪个管道(或发送消息哪个管道等),在管道中的收听者可以订阅和取消订阅而不会影响到往管道中正在写入的工作者,这允许工作者之间有某种程度的低耦合。

流水线模型(Assembly Line)的优点

相对于并行工作者模型,流水线模型有一些优点,在接下来的部分,我会叙述其中最突出的几个优点。

无共享状态

工作者之间不共享状态的情形意味着它们可以在实现时不必考虑在状态共享时所遇到的各种并发问题,这让工作者的实现变得更加容易,在实现工作者时可以假设只有一个线程在处理该工作,本质上就是一个单线程实现。

有状态的工作者

由于工作者知道没有其它线程修改它们的数据,这些工作者可以具有状态。在说有状态时我的意思是它们可以保留在内存中操作所需的数据,只有写入才会改变最终的外部存储系统。因此,一个有状态的工作者通常比无状态的工作者执行更快。

更好的硬件协同

单线程代码的优点在于它通常更符合底层硬件的工作原理。首先你通常可以创建更优化的数据结构和算法当你能假定代码会以单线程模式执行。

其次,如前所述单线程有状态的工作者可以在内存中缓存数据,当数据在内存中缓存时,有很大的概率该数据也会被缓存到CPU缓存中,这样数据获取变得更快。

当代码以一种自然受益于底层硬件工作原理的方式编写时,我称之为 硬件协调*,有些开发者称之为 *mechanical sympathy ,我更倾向于硬件协同因为计算机只有很少的机械部件,同时单词“sympathy”在这种情况下被用作比喻“更高的匹配”,而我认为单词“conform”能更高的传达其含义。不管怎么说,这些都是吹毛求疵,可以使用你喜欢的任何术语来描述。

任务可排序

根据流水线模型实现的并发系统使得排序变得可能,任务排序使得在任何给定时间点更容易理解系统的状态。此外,你可以将所有传入的任务写入日志,如果系统的任何部分发生故障,则可以使用该日志从头重建系统的状态。这些任务以某种顺序写入日志,这个顺序称为该任务顺序,下图展示了这种设置如何实现:
"任务可排序"
确保一个任务的顺序实现起来不一定容易,但通常是可能的。如果你可以实现的话,它将会大大简化类似于数据备份、恢复数据、复制数据等的任务,这些都可以通过日志文件来完成。

流水线模型(Assembly Line)的缺点

流水线模型的最主要缺点是通常将执行一个任务分配到多个工作者,因此,当项目中有多个类时,将难以准确的看出哪段代码在执行给定的任务。

代码编写也可能会变得更难,工作者代码有时候被写作回调处理器(callback handlers)。在代码中有太多嵌套的回调处理器时可能会导致某些开发人员所谓的 回调陷阱(callback hell) 。回调陷阱简单的说就是在所有的回调中很难追踪代码真正在干啥以及确保每个回调都可以访问它需要的数据。

而使用并行工作者模型,这往往很容易。你可以打开对应的工作者代码,并从头到尾读取要执行的代码。当然,并行工作者模型也可能传播到不同的类中,但是要执行的序列通常更容易从代码中读取。

功能并行(Functional Parallelism)模型

功能\函数并行模型是第三种并发模型,最近谈论得很多(2015)。

功能\函数并行性的基本思想是通过函数调用实现程序,功能可以被看作是发送消息到彼此的“代理”或“角色”,就像流水线并发模型(AKA反应或事件驱动系统)一样,当一个函数调用另一个函数时,类似于消息发送。

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

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

使用Java 7,我们得到了包含 ForkJoinPool 模型的 java.util.concurrent 包,可以帮助您实现类似于功能并行性的功能,而使用Java 8,我们将得到并行流,可以帮助您并行化大型集合的迭代。请记住,有开发人员批评 ForkAndJoinPool 模型(您可以在我的ForkAndJoinPool教程中找到一个相应的批评链接)。

关于功能\函数并行的难点在于知道哪个函数调用需要并行化,跨CPU的协调功能调用带来了一定的开销。只有由功能/函数完成的工作单位具有一定的大小,才能值得这个开销,如果函数调用非常小,尝试并行化它们可能比单个线程的单个CPU执行更慢。

从我的理解(事实上根本不完美),您可以使用事件响应驱动模型来实现实现算法,并实现与功能并行性相似的工作分解。在我看来,通过事件响应驱动模型,你可以掌握如何来实现并行化。

另外,只有当前任务是程序执行的唯一任务时,将任务分配给多个CPU,协调开销才有意义。然而,如果系统同时执行多个其他任务(如Web服务器,数据库服务器和许多其它系统),则无需尝试并行化单个任务。计算机中的其它CPU可能正在忙于处理其它任务,所以没有理由试图用较慢的功能并行任务来打扰他们。如有可能,你最好使用流水线并发模型,因为它在以单线程模式顺序执行的程序中具有更少的开销,并且更好的符合底层硬件的工作原理。

孰优孰劣

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

通常情况下,答案取决于你的系统应该做什么。 如果你的工作自然并行,独立,无需共享状态,则可以使用并行工作模型来实现系统。但许多任务不是自然并行和独立的,对于这些类型的系统,我相信流水线并发模型比缺点有更多的优点,比并行工作模型更有优势。你甚至不需要自己编写所有的流水线路基础设施,像Vert.x这样的现代平台为你已经实现了很多。 就个人而言,我将探索在Vert.x等平台上运行的设计,以便我的下一个项目。我个人感觉JavaEE没有尽头。

<–翻译结束!–>

comments powered by Disqus