- 原文地址:Concurrency Models
- 作者: Jakob Jenkov
可以使用不同的并发模型来实现一个并发系统。
并发模型 用以规范系统中的线程如何协作完成给定的任务。 不同的并发模型会以不同的方式分割任务,并以不同的调度/协同/通讯方式来使用线程。 本文将会深度的介绍如今(2015年)的一些流行的并发模型。
并发模型以及类似的分布式系统
本文中描述的并发模型与各种分布式系统有相似之处。 在一个并发系统中,不同的线程也是需要互相通讯的。 而在一个分布式系统中,不同的进程(可能还是不同的机器上的进程)也需要互相通讯。 线程与进程本质上非常类似。 这就是为啥个别并发模型从架构上看,非常类似一个分布式系统。
当然,分布式系统有着更多的挑战以及难度,如:网络通讯,远程计算机/进程失效等情况。 但是一个并发系统运行一个大服务也可能遇到类似的问题,诸如:CPU故障,网卡故障,磁盘故障等等。 虽然说失败的概率比较低,但是理论上还是可能发生的。
由于并发模型非常类似于分布式系统架构,所以,他们经常放在一起讨论。 例如:在多个工作线程之间进行调度就非常类似分布式系统中的负载均衡。 同样的需要小心的处理如:日志系统,容错,任务幂等性等问题。
1. 并行处理模型(Parallel Workers)
首先介绍的模型,是一个作者称之为并行处理模型( parallel worker ) 。 就是将收到的任务调度给不同的工作线程:
在这个模型中,有一个委托者(Delegator) 负责将收到的任务调度给不同的工作线程。 每一个工作线程完整的处理一个任务。 任务是以一个并行的方式运行,在不同的工作线程,并且可能还是在不同的CPU上。
如果并行处理模式比作一个制造汽车的工厂,那么,每辆汽车都是由一个工人进行生产。工人按照说明书进行汽车的组装,并且从头到尾的负责制造过程中的每一个环节。
并行处理模型,是Java应用中最常见的并发模型了(尽管会有所变化)。 在
java.util.concurrent
包中,有一些并发工具包就是使用这个模型的。 在JEE应用服务的设计中,也能发现这个模型的身影。
1.1 并行处理的优势
并行处理模型的优势就是它非常利于理解。 如果需要提高应用的并发处理能力,只需要添加更多的工作线程。
例如,如果需要实现一个WEB爬虫,可以观察使用不同数量的工作线程来爬取一定数量的页面(为了最高性能)。 由于web爬取页面是一个IO密集型任务,所以需要为每一个CPU内核分配多一点的线程数量。 如果每个CPU一个线程,那就太少了,因为,线程总是会花大量的时间等待数据的下载。
1.2 并行处理的劣势
共享状态带来的复杂性
并行处理模型也会有着一些劣势。共享的工作线程,经常需要访问同一种共享数据,诸如:内存/数据库等等。 下图说明了这种情况:
在这个通讯机制中还会存在一些共享的状态信息,诸如:任务队列。 其实还会存在业务数据、缓存、数据库连接池等各种共享资源的状态信息。
一旦在并行处理模型中引入共享状态信息就会让事情变得复杂了。 线程需要去访问那些可能随时被其他线程修改的共享数据(这些数据还不仅仅是在执行线程的那个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)。 也有人称之为:反应式系统,事件驱动。 整个模型就类似下图:
工作线程就像工厂中的装配线一样被调度。每一个工作线程只是执行任务中的某个部分。 工作线程完成自己的逻辑处理后,就把任务交给下一个工作线程处理。
工作线程之间不需要同步状态信息。 因此,有的时候这种模型,也被称为:无共享并发模型
装配线模型一般用于非阻塞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
还有:
- spring reactor
- disruptor
Actors 与 Channels
Actors 和 Channels 是两个类似装配线(或者叫做响应式/事件驱动)的模型。
在Actor模型中,每个工作线程被称之为actor。 这些个actor可以彼此直接发送消息。 消息的发送和处理都是异步的。 actor可以被用于实现任务处理装配线。例如:
在Channel模型中,工作线程彼此不会直接通讯。而是将消息(事件)发布到不同的通道(channel)中。 其他工作线程就可以在这些通道上进行监听,而发送者不需要去关心谁在监听。如图:
在作者看来,Channel模型有着更高的灵活性。 工作线程不再需要关心哪些线程会来继续处理任务。 工作线程只需要知道任务转发到哪个通道就行了。 通道上的监听器可以随时订阅/取消订阅,而且还不用去通知写入的那个线程。 从而使得,工作线程之间解耦了。
2.1 装配线模型的优势
相对并行处理模型来说,装配线模型有几个突出的优点:
没有共享状态
由于工作线程之间没有需要共享的状态信息,这也就使得在设计上可以变得简单一些了,而且还自然而然的避免了许多并发问题。 在实现工作线程的逻辑时,就和普通单线程的处理上基本一样了。
工作线程无状态
没有其他工作线程来修改当前线程的数据,工作线程就可以以一个有状态的方式来实现。 对于状态,其实就是维持一些内存数据,而只在数据发生修改时才保存到外部存储器上。 有状态的工作线程也因此会比无状态要更快。
更好的硬件适合性
单线程代码通常都更符合底层应用的工作方式。 首先,单线程时,可以充分的利用数据结构和算法。(无需考虑复杂的并发问题)
再一个,单线程有状态的工作线程可以在内存中进行数据缓存。数据缓存在内存中,也就意味着更高的性能,而且也就意味着在执行时更高的CPU缓存命中率(CPU内部缓存(L1,L2,L3...)缓存行,内存分页)。 这些都会让性能变得更好。
当以一种更加符合硬件工作的方式来编写代码,就可以被称之为:硬件适合性。 有的人也称之为:mechanical sympathy。 作者更喜欢用hardware conformity 来表示这个概念。
任务顺序是可预见的
在装配线模型的并发系统中,是可以保障任务的执行顺序的。 任务的有序执行,那就可以更容易的理解当前系统是处于什么状态之中。 此外,还可以把请求进来的任务记录在日志中。 可以在系统崩溃后,重新按照日志进行系统状态的恢复。 任务按照以一个有序的方式写入到日志中,这样可以保障任务有序性。如图所示:
实现任务有序,其实并不容易,但还是可能做到的。 如果可以,就可以大大的简化诸如:备份,数据恢复,数据复制等等场景的逻辑处理(都可以通过日志文件完成)。
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。