【03】并发模型


可以使用不同的并发模型来实现一个并发系统。

并发模型 用以规范系统中的线程如何协作完成给定的任务。 不同的并发模型会以不同的方式分割任务,并以不同的调度/协同/通讯方式来使用线程。 本文将会深度的介绍如今(2015年)的一些流行的并发模型。

并发模型以及类似的分布式系统

本文中描述的并发模型与各种分布式系统有相似之处。 在一个并发系统中,不同的线程也是需要互相通讯的。 而在一个分布式系统中,不同的进程(可能还是不同的机器上的进程)也需要互相通讯。 线程与进程本质上非常类似。 这就是为啥个别并发模型从架构上看,非常类似一个分布式系统。

当然,分布式系统有着更多的挑战以及难度,如:网络通讯,远程计算机/进程失效等情况。 但是一个并发系统运行一个大服务也可能遇到类似的问题,诸如:CPU故障,网卡故障,磁盘故障等等。 虽然说失败的概率比较低,但是理论上还是可能发生的。

由于并发模型非常类似于分布式系统架构,所以,他们经常放在一起讨论。 例如:在多个工作线程之间进行调度就非常类似分布式系统中的负载均衡。 同样的需要小心的处理如:日志系统,容错,任务幂等性等问题。

1. 并行处理模型(Parallel Workers)

首先介绍的模型,是一个作者称之为并行处理模型( parallel worker ) 。 就是将收到的任务调度给不同的工作线程:

图 1-1

在这个模型中,有一个委托者(Delegator) 负责将收到的任务调度给不同的工作线程。 每一个工作线程完整的处理一个任务。 任务是以一个并行的方式运行,在不同的工作线程,并且可能还是在不同的CPU上。

如果并行处理模式比作一个制造汽车的工厂,那么,每辆汽车都是由一个工人进行生产。工人按照说明书进行汽车的组装,并且从头到尾的负责制造过程中的每一个环节。

并行处理模型,是Java应用中最常见的并发模型了(尽管会有所变化)。 在java.util.concurrent包中,有一些并发工具包就是使用这个模型的。 在JEE应用服务的设计中,也能发现这个模型的身影。

1.1 并行处理的优势

并行处理模型的优势就是它非常利于理解。 如果需要提高应用的并发处理能力,只需要添加更多的工作线程。

例如,如果需要实现一个WEB爬虫,可以观察使用不同数量的工作线程来爬取一定数量的页面(为了最高性能)。 由于web爬取页面是一个IO密集型任务,所以需要为每一个CPU内核分配多一点的线程数量。 如果每个CPU一个线程,那就太少了,因为,线程总是会花大量的时间等待数据的下载。

1.2 并行处理的劣势
共享状态带来的复杂性

并行处理模型也会有着一些劣势。共享的工作线程,经常需要访问同一种共享数据,诸如:内存/数据库等等。 下图说明了这种情况: image

在这个通讯机制中还会存在一些共享的状态信息,诸如:任务队列。 其实还会存在业务数据、缓存、数据库连接池等各种共享资源的状态信息。

一旦在并行处理模型中引入共享状态信息就会让事情变得复杂了。 线程需要去访问那些可能随时被其他线程修改的共享数据(这些数据还不仅仅是在执行线程的那个CPU的缓存中,而且还需要回写主存)。 线程需要去处理竞态条件,死锁以及其他共享数据状态的同步等此类问题。

此外,在某些场景中,当线程在访问共享数据时总是需要等待其他线程,那这样就会让并行处理反而性能下降。 许多并发数据结构是处于阻塞中的,这就意味着,在同一个时间点只会又一个或者是一小部分线程能够访问到数据。 这就会导致共享型数据结构处于一个竞争之中。而本质上,高竞争就会导致大量的序列操作来访问那些数据结构。

现代的非阻塞并发算法 可以减少竞争并提高性能,但是通常一个非阻塞算法实现起来是有难度的。

还有一个选择,就是:不变型数据结构 (或者叫 固化型数据结构)(Persistent data structures)。 一个不变型数据结构总是在修改时保存自身前一个版本的数据副本。 这样,如果多个线程使用同一份不变型数据结构,其中一个线程修改了数据,那这个线程修改的其实是一份副本数据。 而其他的线程则继续使用修改前的数据副本。 Scala语言就带有几种不变型的数据结构。(JUC中的写时复制的几个集合也是)

不变型数据结构是解决并发修改共享数据的一个不错的方法。然而,这种数据结构实际中也不会有太好的表现。

比方说,一个不变的List需用在头部添加N个新元素后,返回带有新添加元素的List引用(然后重新将原有引用重新指向这个新List的引用)。 而其他线程仍然持有着旧List的引用,对于这些线程来说List并没有任何改变。 所以,它们也就不能发现最新添加的元素。

不变List通常会使用一个链表结构的List来实现。 但是链表结构在现代硬件上运行的并不是很高效。 List中的每一个元素都是独立的,并且这些对象可能分散在内存中,它们在内存中并不是连续存放的。 现代CPU对于连续的数据访问会非常快(预取(prefetch)),因此,在现代硬件上最佳性能的数据结构其实是数组。 数组中存放数据是在一个连续的内存空间中。 那CPU的缓存就可以一次性加载数组的数据块,这样CPU就可以直接在内部缓存中使用数据。 这是链表结构所无法比拟的。

无状态

共享状态会被系统中其他线程修改。因此,工作线程每次都需要重新读取状态,以确保数据是最新的。 共享状态不论是存放在内存中还是外部数据库中,都一样,都需要这个动作。 工作线程如果内部不保持状态(就不需要每次都重新读取数据),就可以称之为:无状态

每次重新读取数据会严重影响性能。如果数据是存放在外部数据库,那就更严重。

任务执行的顺序是不确定的

并行处理模型的另一个问题就是任务执行的顺序是不确定的。 无法确保哪个任务先执行,哪个后执行。 任务A可以先于任务B提交,但可能在执行时,任务B却先于任务A被执行。

这个不确定性本质上其实是,在任何一个时间点上都很难去推断的系统状态。 这也是为啥说很难确保某个任务就一定比另一个先执行。

2. 装配线模型

介绍的第二种并发模型就是作者称之为: 装配线模型(Assembly Line)。 也有人称之为:反应式系统,事件驱动。 整个模型就类似下图:

image

工作线程就像工厂中的装配线一样被调度。每一个工作线程只是执行任务中的某个部分。 工作线程完成自己的逻辑处理后,就把任务交给下一个工作线程处理。

工作线程之间不需要同步状态信息。 因此,有的时候这种模型,也被称为:无共享并发模型

装配线模型一般用于非阻塞IO系统的实现。 非阻塞IO,意味着工作线程开始一个IO操作(如:读取文件,访问网络数据等)时,工作线程不需要一直等到IO完成。 IO操作是很慢的,所以,等待IO操作完成会非常浪费CPU的时间。 CPU本可以在这期间去处理其他事情。 当IO操作完成后,IO操作的结果(如:读取到的数据)将会交给下一个工作线程。

非阻塞IO时,IO操作发生在工作线程的间隙才被执行。 工作线程尽可能的处理任务,如果需要开始IO操作时,那么工作线程就会放弃控制权。 当IO操作完成,下一个工作线程将会继续处理任务,直到下一次IO操作开始。

image

实际情况中,任务不可能只在一个装配线上处理。 现在的系统都是可以执行多个任务的,任务会在不同的工作线程之间流转。 实际上,任务还可能同时在多个不同的“虚拟的”装配线上被处理。 例如下图:

image

任务甚至会在多个工作线程中并行处理。 例如:一个任务可能会转发到一个任务执行处理,同时还转发到一个日志处理中。

image

甚至会变得比图中所示的更为复杂。

响应式,事件驱动 (Reactive, Event Driven Systems)

装配线模型的并发系统,有的时候,也被称为:响应式系统,或者 事件驱动的系统。 系统能够响应系统内部,外部请求,或者处理过程中产生的各种事件。 例如:HTTP请求,某个文件已经加载到内存中等等。

在(作者)写这篇文章的时候,已经有很多这类响应式/事件驱动的框架了,而且以后会更多。 诸如:

  • Vert.x
  • Akka
  • Node.js
还有:
    - spring reactor
    - disruptor
Actors 与 Channels

Actors 和 Channels 是两个类似装配线(或者叫做响应式/事件驱动)的模型。

在Actor模型中,每个工作线程被称之为actor。 这些个actor可以彼此直接发送消息。 消息的发送和处理都是异步的。 actor可以被用于实现任务处理装配线。例如:

image

在Channel模型中,工作线程彼此不会直接通讯。而是将消息(事件)发布到不同的通道(channel)中。 其他工作线程就可以在这些通道上进行监听,而发送者不需要去关心谁在监听。如图:

image

在作者看来,Channel模型有着更高的灵活性。 工作线程不再需要关心哪些线程会来继续处理任务。 工作线程只需要知道任务转发到哪个通道就行了。 通道上的监听器可以随时订阅/取消订阅,而且还不用去通知写入的那个线程。 从而使得,工作线程之间解耦了。

2.1 装配线模型的优势

相对并行处理模型来说,装配线模型有几个突出的优点:

没有共享状态

由于工作线程之间没有需要共享的状态信息,这也就使得在设计上可以变得简单一些了,而且还自然而然的避免了许多并发问题。 在实现工作线程的逻辑时,就和普通单线程的处理上基本一样了。

工作线程无状态

没有其他工作线程来修改当前线程的数据,工作线程就可以以一个有状态的方式来实现。 对于状态,其实就是维持一些内存数据,而只在数据发生修改时才保存到外部存储器上。 有状态的工作线程也因此会比无状态要更快。

更好的硬件适合性

单线程代码通常都更符合底层应用的工作方式。 首先,单线程时,可以充分的利用数据结构和算法。(无需考虑复杂的并发问题)

再一个,单线程有状态的工作线程可以在内存中进行数据缓存。数据缓存在内存中,也就意味着更高的性能,而且也就意味着在执行时更高的CPU缓存命中率(CPU内部缓存(L1,L2,L3...)缓存行,内存分页)。 这些都会让性能变得更好。

当以一种更加符合硬件工作的方式来编写代码,就可以被称之为:硬件适合性。 有的人也称之为:mechanical sympathy。 作者更喜欢用hardware conformity 来表示这个概念。

任务顺序是可预见的

在装配线模型的并发系统中,是可以保障任务的执行顺序的。 任务的有序执行,那就可以更容易的理解当前系统是处于什么状态之中。 此外,还可以把请求进来的任务记录在日志中。 可以在系统崩溃后,重新按照日志进行系统状态的恢复。 任务按照以一个有序的方式写入到日志中,这样可以保障任务有序性。如图所示:

image

实现任务有序,其实并不容易,但还是可能做到的。 如果可以,就可以大大的简化诸如:备份,数据恢复,数据复制等等场景的逻辑处理(都可以通过日志文件完成)。

2.2 装配线模型的不足

装配线模型最主要的不足就是,一个任务通常都需要在多个工作线程之间传递,这样就会产生更多的class。(更多的内存碎片) 如果实现类特别多,管理上也比较繁琐。

调用任务逻辑代码,有的时候还会以回调的方式来处理。如果有很多的回调嵌套发生就会导致“回调地狱”。 就是说大量的回调导致代码难以跟踪/阅读,所以在使用时,不要滥用回调。

并行处理模型会显得更简单一点。可以打开逻辑处理的代码,就可以轻松的看到整个逻辑的处理过程。 当然,并行处理的代码,也可能分布在不同的类中,但是,执行的逻辑顺序通常都比较利于阅读。

3. 函数化的并行处理模型

最基本的概念就是使用函数调用来编程。 函数可以被看做是一个“agents” 或者 “actors”,它们可以相互发送消息,就像装配线模型那样。(AKA reactive 或者 事件驱动) 当一个函数调用另一个函数时,就相当于发送了一个消息。

所有的参数都会以一个副本的形式传递给下一个函数,因此,对接受的函数来说,数据是自己独享的。 数据副本就避免了共享数据的竞争。 这就可以让函数更加简单的去处理一个原子操作。 每一个函数调用都是单独执行的,多个函数调用不会互相影响。

因为每一个函数调用都是单独执行的,所以,函数调用都是CPU隔离的。 这就意味这,函数化的算法可以在多个CPU上被并行处理。

在Java 7的java.util.concurrent包中,有一个ForkAndJoinPool。这个类就可以用来实现类似函数化的并行处理。 到了Java 8中,可以使用并行的streams的方式,来并行的迭代一个大集合。

对函数化并行处理来说,最难的是要清楚哪些函数是并行处理的。 整合在多个CPU执行的函数,也会带来开销。 所以使用函数来完成某个单元逻辑,需要去考量值不值当。 函数处理的逻辑非常少的情况时,并行处理反而会比单线程、单CPU执行要慢。

其实用响应式,事件驱动模型也可以达到并行函数化的任务分解的效果。 在事件驱动模型中,可以更好的控制并行化执行。

另外,将一个任务拆分,然后分配到多个CPU上执行,也会带来额外的性能开销。 只有在此任务是当前程序唯一的任务时,才有意义。 然而,如果系统还同时并发执行着多个其他的任务,那再试图并行处理单个任务就没意义了。 因为,CPU已经在忙于处理其他任务了,而此时再用一个更慢的并行的函数来打扰CPU执行,反而就拖累了CPU。

一般情况下,装配线模型性能会更好一些。调度更简单,而且也更服务硬件的工作方式。

4. 怎么选择

上面介绍了3中并发模型,那怎么选择呢?

这主要取决于系统的具体情况。如果任务本来就是并行的、独立的,而且没有什么共享状态。那就可以选择并行处理模型。

如果,任务本身并不是并发,但也是独立的。对这类系统,相对来说使用装配线模型更好一些。

现在有很多框架可以大大简化装配线模型的开发,如:Vert.x。

转载于:https://my.oschina.net/roccn/blog/1341316

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值