并发与并行
并发和并行都可以是相对于进程或是线程来说。并发是指一个或若干个CPU对多个进程或线程之间进行多路复用,用简单的语言来说就是CPU轮着执行多个任务,每个任务都执行一小段时间,从宏观上看起来就像是全部任务都在同时执行一样。并行则是指多个进程或线程同一时刻被执行,这是真正意义上的同时执行,它必须要有多个CPU的支持。
如下图是并发和并行的执行时间图。对于并发来说,线程一线执行一段时间,然后线程二再执行一段时间,接着线程三再执行一段时间。每个线程都轮流得到CPU的执行时间,这种情况下只需要一个CPU即能够实现。对于并行来说,线程一、线程二和线程三是同时执行的,这种情况下需要三个CPU才能实现。并发和并行都提升了CPU的资源利用率。
关于并发模型
拥有多个CPU的现代计算机依靠并行并发机制能更快地执行任务,但是如何通过并发并行来执行一个任务是有很多种不同的方式的,即不同的并发模型。不同的并发模型对任务的拆分可能也不同,此外线程之间的通信方式可能也不同。由于并发模型规定了任务描述、执行方式和线程协作等的总体框架,所以并发模型的设计需要考虑的点也有很多,比如如何简化对任务的描述、如何让并发更高效地执行、如何让开发人员更方便实现并发等等。
从进程与线程角度
对于并发模型,如果我们从进程和线程的角度来看的话, 它主要有三种映射模式:单进程-多线程、多进程-单线程以及多进程-多线程。一般来说,进程的颗粒度大且占用资源多,而线程则是小颗粒且轻量级的。某个程序启动后就是一个进程,一个进程可以对应一个线程,也可以包含若干个线程。下面我们分别来看看进程与线程的三种映射模式。
单进程-多线程
这种映射模式是指一个进程包含了多个线程来执行任务,这是我们常见的一种模式,特别是对于Java语言来说更是从语言层面天然使用该模式。在Java中写并发处理时使用的都是线程概念,我们可以创建多个线程来达到并发并行效果,Java启动后就是一个JVM进程,而进程里面就包含了若干线程。一般来说,当线程数量少于CPU个数时,操作系统会让一个CPU对应一个线程,这样就能提高CPU的使用率。此外,由于多个线程共享进程内部资源,所以需要考虑线程安全问题。下图是多个CPU执行一个进程,进程包含了四个线程。
多进程-单线程
这种映射模式是指多个进程共同执行处理任务,而每个进程内部只有一个线程。也就是程序启动后主进程会创建出多个子进程,每个子进程对应一个线程。这种模式下不存在线程安全问题,因为每个进程之间相互隔离,而内部只有一个线程不存在共享内存问题。我们知道进程是一个比较重的操作,所以该模式会消耗更多的系统资源,比如内存消耗和进程切换CPU消耗。下图是多个CPU执行多个进程,每个进程包含一个线程。
多进程-多线程
这种映射模式结合了前面两种模式,多个进程共同执行任务,而且每个进程都包含了多个线程。一个进程多可以包含的线程数是有限的,而且当包含的线程数量太多时可能会导致性能下降,此时就可以引入多个进程来解决,即多进程多线程模式。该模式也需要考虑线程安全问题,涉及到进程切换和线程切换。一般认为该模式可以增加并发处理能力,特别是对于IO密集型任务,但由于要更多的上下文切换,所以对于CPU密集型任务的总体处理能力不一定更优。下图是多个CPU执行多个进程,每个进程里面包含多个进程。
无状态的并发并行
为了使用并行并发机制,我们会将大任务拆分成很多小任务,比如对大量数据进行累加时可以分为若干个累加任务来并行并发处理,又比如web服务器对客户端的请求任务可以分为一个个请求来并行并发处理。当我们拆分后的任务不涉及共享状态(即无状态)时,无状态也就代表着多个进程和线程无需访问共享数据,这种情况下的并行并发就比较简单,不必考虑线程安全问题。同样以web服务器为例,如果我们处理的请求不涉及session时,那么就不涉及共享数据问题。下图上面是并发执行,而下图则是并行执行。
共享状态问题
相对于无状态,并发并行时更多的是需要访问共享数据的情况,此时就存在共享状态问题。常见的共享数据是保持在内存中,当然也可能保存在数据库或其它的存储系统上。一旦涉及到了共享状态,问题就会变得复杂起来,因为会涉及到竞争条件、死锁以及其它并发问题,而且对共享状态的不同访问策略也可能会影响执行的结果。此外,我们前面也学习过计算机的结构,数据从内存到CPU中间可能会经历若干高速缓存和寄存器,这就又引出了数据可见性问题。由此可以看到共享状态的并行并发需要解决的问题很多,这也正是并发编程这么复杂的原因,尽管很多编程语言从语言层面尝试将问题的复杂性封装起来,但并没有办法完全解决。下图是有共享状态的并行并发,上面是并发过程中多个线程会访问共享状态,而下面是并行过程中多个线程访问共享状态。
并发模型设计
前面我们提到过并发模型需要考虑的主体是CPU和任务,并发模型则是规定了任务描述、执行方式和线程协作等的总体框架。下面我们从并发框架设计的角度来了解几种常见的并发模型。
Fork/Join模型
首先先看Fork/Join模型,该模型其实就是一种分治思想,就是将任务不断分解成更小的任务,执行完毕后又将小任务的结果进行汇总。Fork操作就是分割任务,而Join操作就是合并结果。其实如果对常用的数据结构和算法比较熟的话应该就知道合并排序的做法,它就是使用了类似的思想。
我们看下面的图,任务-1是总任务,通过fork操作分割成了任务-1-1、任务-1-2、任务-1-3这三个子任务。其中任务1-1又继续通过fork操作分割成任务-1-1-1和任务-1-1-2,而任务-1-3则分割成任务-1-3-1和任务-1-3-2。任务-1-1-1和任务-1-1-2分别进行join操作将子任务结果传给任务1-1作为其结果,其它子任务也类似,一层层网上传递,终汇总作为总任务的终结果。
Reactor模型
Reactor模型是一种服务器端的模型,该模型能够处理多个客户端并发请求访问,它需要非阻塞机制的支持。Reactor模型将服务器端的整个处理过程分成若干个事件,例如分为接收事件、读事件、写事件、执行事件等。接着事件分发器会检测事件并将事件分发给相应的处理器去处理。每个处理器只负责自己的事情,而且要让所有的处理器都不产生阻塞,理想状态下每个事件处理器都能充分利用CPU。
如图所示,若干客户端连接访问服务器端,Reactor的事件分发器负责检测事件并将各种事件分发到对应处理器上,这些处理器包括接收连接的accept处理器、读数据的read处理器、写数据的write处理器以及执行逻辑的process处理器。在整个过程中只要有待处理的事件存在,即可以让Reactor线程不断往下执行,而不会阻塞在某处,所以处理效率很高。
Proactor模型
Proactor模型Reactor模型的设计思想类似,都是基于事件分发机制。其中Reactor模型需要自己检测接收读写事件,一旦检测到有可接收可读可写等事件就分发到各类处理器上。而Proactor模型则是将分发器注册到操作系统内核中,内核一旦完成了某些事件后就会通知分发器,然后分发器再分发到各类Handler(处理器)上。两者大的不同是对IO的操作方式,Reactor是基于应用层发起的同步IO操作,而Proactor则是基于内核的异步IO操作,应用层先注册到内核并由内核负责事件通知。
根据下图看看Proactor的工作原理。首先应用层创建分发器Dispatcher并注册到内核异步IO处理器中,它能够感知已完成接收操作、已完成读操作、已完成写操作等事件。然后当有相应事件发生时内核会通知分发器,进而调用对应的处理器Handler进行处理。后如果Handler需要读写则可以直接对内核缓冲区进行操作,此时数据肯定是已经准备好了的。
Actor模型
Actor模型由Carl Hewitt在1973年发明,该模型实际上提供了一种更高层次的并发语义,通过该模型我们能够通过Actor实体概念来进行并发编程,这些Actor之间通过邮箱来传递消息。简单地来说就是,每个Actor里面都有自己的状态、行为和邮箱,接收到消息后会执行相应的行为进行逻辑处理。此外还有一个重要的点,Actor与Actor之间是不共享状态的。
Actor模型出现后,我们再也不必接触到多线程和线程池等之类的基础概念了,我们只需将重心放在逻辑处理和消息传递上,这是一种简化并发编程的方法。反过来看传统的并发编程,数据都是共享的,多个线程会并发的访问这些共享数据,这就导致我们必须要面对繁杂的锁、同步等等并发问题。而Actor则通过不共享状态和消息传递来屏蔽这些复杂的问题,当然实际上底层实现也仍然会遇到这些并发问题,但对于开发者层面却不必面对这些问题。
下面看Actor模型的具体内容,实际上任务物体和概念都可以抽象为Actor,也就是万物皆Actor。每个Actor都包含自己的状态、行为以及邮箱,由于Actor之间是完全独立的且状态不共享,所以必须通过邮箱来传递消息。每个Actor可以看成是一个轻量线程,所以每个Actor多只能同时进行一个工作。后需要注意的是,消息的传递是完全异步且消息是不可变的。
CSP模型
CSP模型即通信顺序进程(Communicating Sequential Processes),由托尼霍尔在1978年发明的一种并发模型。它看起来跟Actor的思想有点像,也通过消息传递避免并发过程中锁和同步等问题,从而简化并发编程。CSP模型主要有Processor和Channel两个概念,其中Processor表示执行任务顺序单元,而Channel则表示消息交互通道,可以传递数据消息。每个Processor之间都是相互独立的,它们只能通过Channel来通信。Actor模型中每个Actor都包含一个邮箱,它们之间是强耦合的,但CSP模型中却不是这样,Processor不包含Channel,它们之间是相互解耦的。
总结
本文主要介绍了并发模型相关的知识,首先介绍了并行和并发以及并发模型,然后从进程线程角度讲解了单进程-多线程、多进程-单线程以及多进程多线程三种映射模式,后介绍了五种常见的并发模型设计:Fork/Join模型、Reactor模型、Proactor模型、Actor模型和CSP模型
作者简介:笔名seaboat,擅长人工智能、计算机科学、数学原理、基础算法。出版书籍:《Tomcat内核设计剖析》、《图解数据结构与算法》、《人工智能原理科普》。
推荐作者的一个Java并发原理专栏,现在提供限时优惠,有需要的朋友可看看,已经有180+人参与学习。