六种网络应用架构模式

六种网络应用架构模式,以socket编程为例讲解。

一、串行化

处理请求的串行化模型。
在串行化架构中,所有的客户端连接是依次进行处理的,因为不涉及并发,多个客户端不会同时接受服务。
串行化架构最大的优势在于它的简单性。没有锁,没有共享状态,处理完一个连接之后才能处理另一个。在资源使用方面亦是如此:一个实例处理一个连接,一个萝卜一个坑,绝不多消耗资源。
串行化架构明显的劣势是不能并发操作。即便是当前连接处于空闲,也不能处理等待的连接。

二、单连接进程

这是首个可以对请求进行并行处理的网络架构。

实现这个只需要服务器fork出一个子进程,这个子进程的唯一目的就是处理新连接。连接处理完毕之后就退出。

进程衍生的基础知识

如果在程序中使用了fork,那实际上就是在运行期间创建了一个新进程。fork可以使你获得两个一模一样的进程。新创建的进程被视为“孩子”;原先的进程被视为“双亲”。一旦fork完成,就拥有了两个进程,它们可以各行其道,各行其事。
这一点及其有用,这意味着我们可以accept一个连接,fork一个子进程,这个子进程会自动获得一份客户端连接的副本。无需其他的设置、数据共享或者锁,直接就可以开始并行处理了。

让我们来理清事件流程:

1. 一个连接到达服务器;
2.主服务器进程接受该连接;
3.衍生出一个和服务器一模一样的新子进程;
4.服务器进程返回步骤1,由子进程并行处理连接。

得益于内核语义,这些进程是并行执行的。子进程处理连接时,原先的父进程可以继续接受新连接,衍生出新的子进程对其进行处理。
不管何时,总是有一个父进程等着接受连接,但可能会有多个子进程分别处理单个连接。

该模式有诸多优势。首先是它的简单性。为了能够并行处理多个客户端,只需要在串行化实现的基础上增加极省量的代码即可。
第二个优势是这种并行操作不难理解。之前说过,fork实际上提供了一个子进程所需要的所有东西的副本。不用留心边界情况(edge cases),没有锁或竞争条件,只是简单的分离而已。

一个明显的劣势是,对于fork出的子进程的数量没有施加上限。如果客户端数量不大,这倒也不是什么问题,但如果生成了上百个进程,那你的系统就可能会崩溃了。这方面可以使用preforking模式解决。
只有unix系统才支持fork.

三、单连接线程

单连接线程模式和单连接进程模式非常相似。不同之处在于,它是生成线程,而非进程。

线程与进程

线程和进程都可以用于并行操作,但是方式大不相同。没有万能药,究竟用哪个取决于实际情况。

生成(spawn)。就生成而言,线程的生成成本要低得多。生成一个进程需要创建原始进程所拥有的一切资源的副本。线程以进程为单位,多个线程都存在于同一个进程中。由于多个线程共享内存,无需创建副本,因而线程的生成速度要快得多。

同步。 因为线程共享内存,当使用会被多个线程访问的数据结构时,一定要多加小心。这通常意味着要在线程之间使用互斥量(mutex)、锁和同步访问。进程就无须如此了,因为每个进程都有自己的一分资源副本。

并行。 两者都提供了由内核实现的并行计算功能。

总而言之,线程是轻量级的,进程是重量级的。两都都用于并行操作。两都都有各自的适用环境。

使用线程时,每个线程获得自己的程序实例非常重要。如果我们就像以前那样,只是简单的将客户端套接字分配给一个实例变量,那么它会在所有的活动线程之间共享。
这与同进程打交道有着显著的差别,在后者中每个进程都会获得内存中所有资源的副本。之所以有些开发者声称线程编程不容易,其中一个原因便是状态共享。如果你使用线程进行套接字编程,有一条简单的经验:让每个线程获得它自己的连接对象。这可以让你少些麻烦。

该模式和前一个模式有很多共同的优势:代码修改量很少,很容易理解。尽管使用线程会引入锁以及同步问题,但这里我们并不用担心这个问题,因为每个连接是由单个独立线程来处理的。

该模式较单连接进程的一个优势是线程占用资源少,因而可以获得数量上的增加。比起使用进程,它能为客户端服务提供更好的并发性。

这个模式和单连接进程模式有一个共同的劣势:线程数会不断增加,直到系统不堪重负。如果你的服务器要处理持续增加的连接,系统可能难以在所有的活动线程上进行维护及切换。这可以通过限制活动线程数解决。

四、Preforking

Preforking模式又折回到之前的单连接进程架构上了。
它依赖进程作为并行操作的手段,但并不为每个接入的连接衍生对应的子进程,而是在服务器启动后,连接到达之前就先衍生出一批进程。

下面是处理流程:

1. 主服务器进程创建一个侦听套接字;
2. 主服务器进程衍生出一大批子进程;
3. 每个子进程在共享套接字上接受连接,然后进行独立处理;
4. 主服务器进程随时关注子进程。

这个流程的重点是,主服务器进程打开侦听套接字,却并不接受该套接字之上的连接。它然后衍生出预定义数量的一批子进程,每个子进程都有一份侦听套接字的副本。子进程在各自的侦听套接字上调用accept,不再考虑父进程。
这个模式的精妙之处在于,无须担心负载均衡或是子进程连接的同步,因为内核已经替我们完成这个工作了。对于多个进程试图在同一个套接字的不同副本上接受(accept)连接的问题,内核会均衡负载并确保只有一个套接字副本可以接受某个特定的连接。

这个巧妙的模式利益于以下几处设计。
相较于类似的单连接进程架构,Preforking不用在每个连接期间进行fork。进程衍生的成本可不少,在单连接进程架构中,每个连接都要承担由此带来的开销。
如前所述,因为该模式提前就生成了所有的进程,因而避免了进程过量的情况。
比起与Preforking类似的线程模式,这个模式的一个优势就是完全隔离。因为每个进程都拥有所有的资源的副本,单个进程中的故障不会影响其他进程。因为线程共享进程资源以及内存空间,单线程故障可能会无法预测地影响到其他线程。
Preforking的一个劣势是:衍生的进程越多,消耗的内存也越多。进程可不是免费的午餐。考虑到每个衍生的进程都会获得所有资源的一份副本,可以预料到每一次进程衍生,内存占用率就要增加100%(以父进程为基准)。
按照这种衍生方式,占用100MB内存的进程在衍生出4个子进程之后将占用500MB内存。即便这样,也只有4个并发连接。
这种模式的代码简单,也不用担心运行期间出问题。

五、线程池

线程池模式之于Preforking,一如单连接线程与单连接进程之间的关系。同Preforking差不多,线程池在服务器启动后会生成一批线程,将处理连接的任务交给独立的线程来完成。
这个架构的处理流程和前一个一样,只需要把“进程”改为“线程”就行了。
这个同样由内核确保一个连接只能由单个线程接受。

线程池模式不需要每次处理连接时都生成线程,也没有什么令人抓狂的锁或竞争条件,但却仍提供了并行处理能力。

六、事件驱动

迄今为止我们看到的这些模式其实都是串行化模式的变体而已。其他几种模式实际上使用的结构与串行化模式相同,只不过包装了线程或者进程。

事件驱动(Reactor)模式采用的是一种和之前完全不同的方法。

事件驱动模式(基于Reactor模式)如今可谓风头正劲。它也是EventMachine、Node.js以及Nginx等库的核心所在。

该模式结合了单线程和单进程,它至少可以达到之前模式所能提供的并行操作级别。

它以一个中央连接复用器(被称为Reactor核心)为中心。连接生命周期中的每个阶段都被分解成单个的事件,这些事件之间可以按照任意的次序交错并处理。连接的不同阶段只是可能的IO操作而已:accept、read、write以及close.
中央复用器监视所有活动连接的事件,在触发事件时分派相关的代码。

下面是事件驱动模式的工作流程:

1. 服务器监视侦听套接字,等待接入的连接;
2.将接入的新连接加入到套接字列表进行监视;
3.服务器现在要监视活动连接以及侦听套接字;
4.当某个活动连接可读时,服务器从该连接读取一块数据并分派相关的回调函数;
5.当某个活动连接仍然可读时,服务器读取另一块数据并再次分派回调函数;
6.服务器收到另一个新连接,将其加入套接字列表进行监视;
7.服务器注意到第一个连接已经可以写入,因而将响应信息写入该连接。
记住,所有的一切都发生在单个线程中。注意,第一个连接仍在读/写过程时,服务器就可以accept新连接了。

服务器将每次操作分割成小块,这样属于多个连接的不同事件就可以彼此交错了。
事件驱动模式用的是单线程,但可以同时处理多个用户连接,所以它需要使用一个对象来描述每个独立的连接,这样就不会破坏连接各自的状态。

事件驱动模式同其他模式有着显著的不同,因而也就产生了尤为不同的优势和劣势。

首先,该模式以极高的并发处理能力而闻名,能够处理成千上万的并发连接。光这一点就让其它模式无法望其项背,因为它们都受到进程/线程数量的限制。
如果服务器需要生成5000个线程来处理5000个连接,服务器估计会不堪重负。就处理并发连接而言,事件驱动模式可谓一枝独秀并广为流传。

它主要的劣势是所施加的编程模型。一方面这个模型更简单,因为无需处理众多进程/线程。这意味着就不存在共享内存、同步、越界进程,等等。但是考虑到所有的并发都发生在单个线程内部,有一条非常重要的规则必须遵循,那就是绝不能阻塞Reactor.
假设客户端在一条速度缓慢的连接上请求文件传输。这会对Reactor造成怎样的影响?
考虑到一切都运行在同一个线程之内,单个迟缓的客户端连接会阻塞住整个Reactor! 在这期间,无法读取其它数据,也无法接受新的连接。
应用程序需要达成的任何事情都应该快速完成。这一点非常重要。那我们应该怎样使用Reactor处理缓慢的连接呢?复用Reactor自身!
如果你采用该模式,那就需要确保所有阻塞式IO都由Reactor自己来处理。
当Reactor可以向缓慢的连接中写入数据时,它会触发相应的回调函数,该方法能够在没有阻塞的情况下尽可能多的向客户端写入数据。这样Reactor就可以在等待这个缓慢的远程连接的同时继续处理其它连接,一旦那条远程连接再次可用,仍可对其进行处理。

简而言之,事件驱动模式提供了一些显而易见的优势,真正简化了套接字编程的某些方面。另一方面,它需要你重新考虑自己的应用程序中所涉及的全部IO操作。该模式所带来的益处很容易就会被一些迟钝的代码或者含有阻塞式IO的第三方代码库搞得烟消云散。

七、混合模式

这是有关网络模式的最后一部分。这部分并不涉及特定的模式本身,而是阐述一个混合模式的概念,它采用了若干个之前学习过的模式。

但我们来看一些例子吧。

1 nginx

nginx项目提供了一个用C语言编写的性能极高的网络服务器,项目网站上宣称它能够在单服务器上服务100万个并发请求。nginx是如何实现如此高的并发的呢?
在核心部分,nginx使用了Preforking模式。但是在每个衍生进程中使用的却是事件驱动模式。作为一个高性能的网络服务器,这种选择意义重大,原因如下。
首先,在nginx衍生子进程时,所有的相关成本在启动时就已经付出了。这就保证了nginx能够最大限度地利用多核及服务器资源。其次,事件驱动模式也贡献了一臂之力,它不进行任何生成(spawn),也不使用线程。使用线程的一个问题就是需要由内核承担所有活动进程的管理以及上下文切换所带来的开销。
nginx 疾如闪电的运行速度少不了其它一干特性的协助,这包括严密的内存管理(只能利用C语言实现),但在核心部分,它混合使用了前面几章介绍的模式。

2 Puma

Puma rubygem 提供了一个“专注于并发的Ruby Web服务器”。 Puma 被设计作为由Ruby实现的王牌HTTP服务器。
那么Puma 是如何 实现并发的呢?
在高层上,Puma利用线程池提供并发。主线程一直用于accept新的连接,然后将连接加入线程池待作处理。这便是不适用keep-alive的HTTP连接的处理方法。不过Puma也支持HTTP的keep-alive.在处理连接时,如果首个请求要求连接保持活跃状态,那么Puma会尊重这一请求,不关闭连接。
不过这时Puma就不再只是accept这个连接了。它需要监视该连接上的新请求并进行处理。这是通过事件驱动类型的reactor实现的。当新的请求出现在处于保持活跃状态的连接上时,该请求会被再次加入线程池进行处理。Puma的请求处理总是由线程池完成的。它通过一个能够监视所有持久连接的Reactor实现。
Puma同样包括了其它方面的精心优化,但其核心同样也是采用了多个前面介绍的模式。

3 EventMachine
EventMachine是Ruby圈里知名的事件驱动IO库。它利用Reactor模式提供高稳定性及可扩展性。EventMachine是用C语言编写的,但是通过C扩展的形式提供了Ruby接口。

那么EventMachine是如何实现并发的呢?
EventMachine的核心是利用事件驱动模式实现的。它是一个单线程的事件循环,可以处理多个并发连接上的网络事件。EventMachine也提供了一个线程池,用于推迟处理会拖累Reactor的那些耗时或阻塞式的操作。
EventMachine支持包括监视生成进程、网络协议实现等大量特性。利用多种架构只是它实现并发性的一种手段。

(完)
参考《TCP Sockets编程》

转载请注明:来自Lenix的博客,地址:http://blog.p2hp.com/archives/4326

最后更新于 2018年6月2日

六种网络应用架构模式
标签: