并发模型

原文链接:http://tutorials.jenkov.com/java-concurrency/concurrency-models.html

并发系统可以采用不同的并发模型来实现。并发模型规范了系统中的线程通过怎样的协作来完成给定的作业。不同的并发模型采用不同的方式来分解作业,线程之间采用不同的方式来通信和协作。该并发模型教程将会深入研究到目前(2015)为止最流行使用的并发模型。

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

在本文中描述的并发模型和在分布式系统中使用的架构具有相似性。在并发系统中不同的线程之间相互通信,在分布式系统中不同的进程之间相互通信(可能是在不同的计算机上)。线程和进程之间本来就有很大的相似性,这也是并发模型和分布式系统看起来相似的原因。

当然在分布式系统中会有特殊的挑战,比如网络连接可能会失败,远程计算机或进程会宕掉等。但是一个运行在大型主机上的并发系统也可能会经历相似的问题,比如 CPU 坏掉,网卡坏掉,磁盘坏掉等。这些硬件坏掉的可能性很小,但理论上仍然仍然存在发生的可能。

由于并发模型和分布式系统架构具有相似性,所以它们之间可以相互借鉴。比如,为工作线程之间分配工作的模型通常和分布式系统中的负载均衡模型很相似。错误处理方法方面也是类似的,比如记录日志、故障转移、幂等性的作业等。

并行工作线程

第一个并发模型是并行工作线程模型,接收的作业被分配给不同的工作线程。下面的图描绘了并行工作线程并发模型:
这里写图片描述
在并行工作线程并发模型中一个委托者(delegator)负责分配接收到的作业给不同的工作线程,各个工作线程协同完成了整个作业。工作线程并行工作(可能在不同的 CPU 上工作),在不同的的线程上运行。

如果一个使用了并行工作线程模型的汽车制造工厂,每个工作线程生产一台汽车。工作线程应该知道汽车是怎样生产出来的,工作线程将会把一台汽车从无到有的生产出来。

并行工作线程并发模型是 Java 应用程序中使用最广泛的并发模型。 java.util.concurrent 包中的很多并发工具就是为这种模型设计的,也可以在 J2EE 应用服务中找到这个模型的踪迹。

并行工作线程的优势

并行工作线程并发模型的优势是易于理解。为了增加应用程序的并行性,只需要增加更多的工作线程。

比如,用并行工作线程并发模型实现了一个 web 爬虫,可以采用几组不同数量的工作线程来爬取固定数量的页面。然后统计出哪组工作线程所花费的爬取时间最短(意味着最高的性能)。由于 web 爬虫是一项 IO 密集型作业,可能会给每个处理器或核心分配一些线程。一个处理器分配一个线程肯定是分配得太少了,这样一来处理器在等待数据下载的过程中会有很多的空闲时间都被浪费了。

并行工作线程的劣势

然而并行工作线程并发模型有一些劣势隐藏在简单的表面。在下面的部分我将会解释并行工作线程并发模型最明显的劣势。

共享状态可以导致复杂性

事实上并行工作线程模型要比上图中描绘的复杂。共享工作线程经常需要访问一些共享数据,要么是在内存中要么是在数据库中。下面的图展示了并行工作线程并发模型的复杂性:
这里写图片描述
有些共享状态是用来实现通信机制的,比如作业队列。还有些共享状态是业务数据、数据缓存、数据库连接池等。

只要共享状态出现在并行工作线程模型中,这个模型就变得复杂了。由于线程需要通过某种方式来访问共享数据,所以要确保被一个线程修改的数据对其他线程可见(把修改的内容推送到内存而不是缓存在执行当前线程的处理器缓存中)。线程需要避免竞争条件,死锁和许多其他共享状态的并发问题。

而且,在访问共享的数据结构时线程之间得相互等待,会牺牲一定的并行性。许多的并发数据结构是阻塞的,意味着一个或有限个线程可以同时访问。这可能会导致这些共享数据发生竞争。高竞争基本上会加重访问共享数据结构的代码串行化执行的程度。

现在非阻塞并发算法可以较少竞争提高性能,但是非阻塞算法不容易实现。

持久化数据结构是另外一种选择,持久化数据结构经常保留了修改前的版本。因此,如果多个线程引用了同一个持久化数据结构而且一个线程修改了它,这个修改线程会得到一个新的数据结构的引用。所有的其他线程保留一个指向旧的数据结构的引用,这个旧的数据结构是没有被修改的仍然具有一致性。 Scala 编程包含了一些持久化数据结构。

尽管持久化数据结构看起来是解决并发修改共享数据结构的一种优雅的方式,但是持久化数据结构表现得往往不够好。

比如,一个持久化的 list 会在 list 的头部添加所有的新元素,然后返回这个新元素的引用(可以通过这个引用来指向其余的 list)。其他的所有线程保留的仍然是指向先前 list 的第一个元素的引用,对于这些线程来说这个 list 看起来没有改变。它们看不到最近添加的元素。

这样的一个持久化 list 作为一个 linked list 来实现,不幸地是 linked lists 在现代硬件上表现得并不好。在 linked list 中的每个元素都是分开的对象,这些对象可能在散落遍布在计算机的整个内存区域中。现代处理器可以更快地访问连续的数据,所以如果一个 list 是基于数组实现的就可以充分利用硬件来提高性能。数组存储的数据是连续的,处理器可以一次加载一大块数组到缓存中。数据一旦被加载到缓存中以后,处理器就可以直接访问缓存。但在 linked list 中却不可以, linked list 中的元素散落遍布在整个内存区域,无法使用缓存。

无状态的工作线程

共享状态可以被系统中的其他线程修改,因此工作线程在每次需要它的时候都必须重新加载来保证它是工作在最新的拷贝上。共享的状态不管是保存在内存中或者是数据库中都是一样的,每次在需要共享状态的时候都必须重新读取。
需要共享状态时每次都重新读取会耗时,尤其共享的状态如果保存在外部数据库中。

作业顺序是不确定的

并行工作线程的另外一个劣势是作业执行顺序的不确定性。没有办法来办证哪个作业首先或最后来执行。或许作业 A 提交给一个工作线程的时间比作业 B 要早,但是作业 B 可能要比作业 A 要先执行完。

由于并行工作线程模型本身的不确定性,在任意时刻推断系统的状态很难。这也让实现确保一个作业发生在另一个作业之前更难(如果可以实现的话)。

流水线

第二个并发模型就是我所起的叫流水线并发模型。我选这个名字只是为了契合前面的“parallel worker”这个比喻。其他开发者使用了其他的名字(比如响应系统,或者是事件驱动系统),这依赖平台或者是开发者社区。下面的图说明了流水线并发模型:
这里写图片描述
工作线程就像是工厂里流水线上的工人一样,每个工作线程只负责整个作业的一部分。当部分作业完成以后就会被当前工作线程传递给下一个工作线程来处理。

工作线程之间是相互独立的,他们之间没有共享状态。因此,有时候流水线并发模型也叫无共享状态并发模型(shared nothing concurrency model)。

应用系统使用非阻塞 IO 来设计流水线并发模型。非阻塞 IO 意味着一个工作线程开始执行一个 IO 操作(比如通过一个网络连接读取文件或数据),工作线程不会等待 IO 调用完成。 IO 操作是很慢的,所以等待 IO 操作完成是很浪费 CPU 时间的。 CPU 在等待 IO 操作的同时可以干些其他的事。 IO 操作完成的时候, IO 操作的结果(比如数据的读写状态)会传递给流水线上的下一个工作线程。

使用非阻塞 IO , IO 操作决定了工作线程之间的边界。一个工作线程会一直尽可能的做更多的工作直到它必须启动一个 IO 操作。然后它放弃对这项作业的控制权。当 IO 操作完成的时候,在流水线上的下个工作线程会继续完成这项作业,直到它也必须启动一个 IO ,如此往复直到全部的作业完成为止。
这里写图片描述
实际应用中,作业并不是在一条流水线上流动。由于大部分系统可以执行多个作业,在流水线上串行执行的作业需要依赖未完成的作业(在流水线上工作的工作线程是处于串行执行的,后边的工作线程必须等待在其前边的工作线程启动一个 IO 操作,然后等待这个 IO 操作完成以后才开始执行)。在实际中有多条不同的虚拟的流水线在并行运行。下面是实际中作业流通过流水线系统的图表:
这里写图片描述
作业甚至可能被转发到多个工作线程并发处理。例如,一个作业可能被转发到一个作业执行器(executor)和一个作业日志记录器(loger)。这个图表说明了三条流水线是怎样通过转发它们的作业到同一个工作线程(在中间那条流水线上的左后一个工作线程)。
这里写图片描述
流水线并发模型可以比上图描述得更复杂。

响应式,事件驱动系统

使用流水线并发模型的系统有时也叫响应式系统或事件驱动系统。系统的工作线程响应系统中发生的事件,或者接收外界来的或者其他工作线程发出的事件。举个例子,事件可以是一个输入的 HTTP 请求或者是某个文件要被加载到内存,当文件被加载完成时一个事件就发生了等。

在写本文的时候,可以找到很多有意思的响应式/事件驱动平台可以利用,而且将来有更多的平台会出现。下面是一些比较流行的响应式/事件驱动平台:

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

我个人认为 Vert.x 更有意思(尤其适合像我这样使用 Java / JVM 的开发者)。

行为者(Actors) VS. 信道(Channels)

行为者和信道这两个并发模型和流水线(响应式/事件驱动)模型是很相似的。

在行为者模式中工作线程叫做行为者,行为者之间可以相互直接发送消息。消息的发送和处理都是异步的。行为者可以用来实现一个或多个前面描述的作业处理流水线模型。下面的图表解释了行为者模型:
这里写图片描述
在信道模式中,工作线程之间不是直接通信的。相反,他们把消息(或事件)发布在不同的信道上。其他工作线程可以监听这些信道上的消息,而发布消息的工作线程却并不知道是谁在监听这些消息。下面的图表解释了信道模式:
这里写图片描述
到目前为止,对我来说信道模式更灵活。一个工作线程不需要知道在流水线上后面的哪些工作线程来处理接下来的作业。工作线程对信道的监听或者取消不会影响其他工作线程往信道上发送消息。这样的设计减少了工作线程之间的耦合。

流水线并发模型的优势

流水线并发模型与并行工作线程模型相比有几个优势,在接下来的部分我会涵盖几个最大的优势。

无共享状态

工作线程之间没有共享状态意味着在实现工作线程的时候不需要考虑由并发访问共享状态带来的并发问题。这使得工作线程更容易实现。在实现一个工作线程的时候似乎这个工作线程是执行任务的唯一线程,就像实现一个单线程程序一样。

有状态的工作线程

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

更好的硬件一致性(Better Hardware Conformity)

单线程的代码有个优势,它通常更好地遵从了底层硬件的工作方式。首先,假定代码是在单线程模式下执行你就可以创建更优化的数据结构和算法。

其次,单线程有状态的工作线程如上所述可以缓存数据于内存中。在内存中缓存数据的概率要高于 CPU 缓存(CPU 缓存的数据是保存在执行线程的那个 CPU 的缓存中)。这样一来甚至使得访问缓存数据更快。

我所说的硬件一致性是指遵从了底层硬件工作方式的代码自然会受益于硬件,有些开发者称之为:mechanical sympathy 。我更偏爱于硬件一致性,因为计算机只有很少的机械(mechnical)部分,”sympathy”在这个上下文中暗喻为“更好的匹配”。我认为“相一致”更合理一些。随便吧,不要吹毛求疵,想用哪个术语就用哪个术语吧。

作业的顺序成为可能

通过流水线并发模型可以实现在某种程度上保证作业执行顺序的并发系统。作业的执行顺序使得推断任意时刻系统的状态相对容易一些。而且,你可以把所有的输入作业都写到一个日志中。万一系统的任意部分发生了失败,可以根据这个日志文件恢复系统的状态。写入日志的作业都按照特定的顺序,这个顺序成为了可靠的作业顺序。下图是一种可能的设计:
这里写图片描述
实现一种可靠的作业顺序是相当不容易的,但通常也是可以实现的。可靠作业顺序系统简化了备份、数据恢复、复制数据等任务。这些任务都可以通过日志文件来做。

流水线并发模型的劣势

流水线并发模型主要的劣势是执行一项作业通常需要开展多个工作线程来完成,因此在工程中就需要写多个类。因此,对于一个具体的作业,查看到底执行了些什么代码就更难了。

写代码也更难了。工作线程代码有时候是作为回调处理器(callback handlers)来实现的。回调地狱(callback hell)很容易理解,意思是在回调调用链中穿梭很难跟踪到底执行了什么代码,确定每个调用访问需要的数据也很难。

相比之下,并行工作线程模式跟踪代码更容易。可以打开工作线程代码从开始到结束一直往下读。当然,并行工作线程代码也可以在不同的类之间展开,但是代码的执行顺序更容易确定。

函数式并行(Functional Parallelism)

函数式并行是当下被谈论最多的第三个并发模型。

使用函数调用是函数式并行最基本的思想。函数可以被看做是互相发送消息的“代理人(agents)”或“行为者(actors)”。当一个函数调用另外一个函数就像在流水线并发模型(AKA 响应或事件驱动系统)发送一个消息,二者是相似的。

传给函数的所有参数都是拷贝了一份的,所以位于被调函数外部的实体是无法操纵函数内部的数据的。拷贝是有意义的,通过拷贝避免了共享数据上的竞争条件。这使得函数得执行和一个原子操作是相似的。每个函数调用的执行和其他函数调用是独立的。

当每个函数调用可以被独立地执行的时候,每个函数调用都可以在不同的处理器上来执行。也就是说,通过函数式实现的算法可以在多个处理器上并行地执行。

在 Java 7 中的 java.util.concurrent 包中包含了 ForkAndJoinPool ,在它的帮助下可以实现类似函数式并行的东西。在 Java 8 中的并行 streams 可以帮助我们并行迭代大型集合。要注意,有些开发者对 ForkAndJoinPool (可以在我的 ForkAndJoinPool 教程中找到批评的链接) 持有批评意见。

函数式并行最难的是决策并行化哪些函数调用。协调跨处理器的函数调用需要一定的开销。通过一个函数来完成一个工作单元需要做一个权衡,这个函数的大小应该对得起付出的开销。如果函数调用非常小,企图并行执行他们可能比在单处理器单线程下执行还要慢。

通过我的理解(可能不够完美),可以使用响应式,事件驱动模型来实现算法。完成分解工作与完成函数式并行是类似的。使用事件驱动模型可以更多地精确控制并行什么和并行多少。

另外,把一个任务分解成多个子任务后在多个处理器上执行,协调这些子任务需要一定的开销。只有当前执行的任务是程序唯一执行的任务时,这样做才是有意义的。然而,如果系统在并发执行多个其他的任务(例如 web 服务,数据库服务和其他系统的服务等),试图并行执行一个单独的任务是没有意义的。在计算机上的其他处理器反正也是在忙着执行其他的任务,所以试图打断其他处理器上正在执行的任务,转向执行一个函数式并行任务反而变得更慢了。使用流水线(响应式)并发模型似乎是最好的选择,因为它的开销较少(在单线程模式下顺序地执行)而且更好地契合底层硬件的工作原理。

哪个并发模型最好?

说了这么多,那么到底哪个并发模型最好呢?

通常就是这样,答案与系统要完成的任务相关。如果作业本身是并行的,独立的,不需要共享状态,使用并行工作线程模式来实现系统更好。

然而,许多作业本身都不是并发和独立的。对于这类型的系统,我认为使用流水线并发模型的利大于弊,而且比并行工作线程模型有更多的优点。

我们不必自己构建所有的流水线基础架构,像 Vert.x 这样的现代平台已经为我们实现了很多。就我个人而言,我将探索设计我的下一个项目在类似 Vert.x 的平台上运行。我感觉 Java EE 不再有优势。

Next:创建和启动 Java 线程

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值