服务器架构模型
基础知识
数据传输过程
操作系统为了保护自己,设计了用户态、内核态两个状态。应用程序一般工作在用户态,当调用一些底层操作的时候(比如 IO 操作),就需要切换到内核态才可以进行。

服务器从网络接收的大致流程如下:
-
数据通过计算机网络传到网卡
-
把网卡的数据读取到socket缓冲区
-
把socket缓冲区读取到用户缓冲区,之后应用程序就可以使用
这个过程的核心就是两次读取操作,下面介绍的网络IO 模型的不同之处也就在于这两个读取操作怎么交互。
网络IO模型
阻塞式IO
应用调用recvfrom读取数据时,其系统调用直到数据包到达且被复制到应用缓冲区中或者发送错误时才返回,在此期间一直会等待,进程从调用到返回这段时间内都是被阻塞的。在内核将数据准备好之前,系统调用会一直等待。所有的套接字, 默认都是阻塞方式。

非阻塞式IO
当应用进程发起读取数据申请时,如果内核数据没有准备好会即刻告诉应用进程,不会让应用进程在这里等待,如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回EWOULDBLOCK错误码。非阻塞IO往往需要程序员循环的方式反复尝试读写文件描述符。

- 应用进程向内核发起recvfrom读取数据
- 内核数据报没有准备好,即刻返回EWOULDBLOCK错误码
- 应用进程再次向内核发起recvfrom读取数据
- 内核如果已有数据包准备好就进行下一步骤,否则还是返回错误码
- 内核将数据拷贝到用户空间
- 完成后,返回成功提示
------ 使用fcntl函数将某个文件描述符设置为默认非阻塞
cpp
//获取文件描述符当前的状态
int flags = fcntl(sockfd, F_GETFL, 0);
//在当前状态的基础上添加非阻塞特性
fcntl(sockfd, F_SETFL, flags|O_NONBLOCK); //按位或
IO多路复用
------ 使用fcntl函数将某个文件描述符设置为默认非阻塞
IO多路复用
由一个线程监控多个网络请求(linux系统把所有网络请求以一个文件描述符来标识),来完成数据状态询问的操作,当有数据准备就绪之后再分配对应的线程去读取数据。

- 应用进程向内核发起recvfrom读取数据
- 内核进行准备数据报(此时应用进程阻塞)
- 内核倘若已有数据包准备好则通知应用线程
- 内核将数据拷贝到用户空间
- 完成后,返回成功提示
------ 除此之外,还有信号驱动IO模型、异步IO模型,可自行了解。
并发服务器模型
循环式迭代式模型
一种单线程的应用程序,它只能使用短连接而不能使用长连接,缺点是无法充分利用多核CPU,不适合执行时间较长的服务,即适用于短连接(这样可以处理多个客户端),如果是长连接则需要在read/write之间循环,那么只能服务一个客户端。
所以循环式服务器只能使用短连接,而不能使用长连接,否则无法处理多个客户端的请求,因为整个程序是一个单线程的应用程序。

并发式服务器
适合执行时间比较长的服务。在父进程中要关闭创建连接的套接字,等待下一个客户端请求连接,所以可以并发式服务器处理多个客户端的请求,一个客户端一个进程;子进程在处理客户端的请求,子进程是长连接的,不断的处理请求,即使子进程中解包,计算,打包的过程时间过长,也不会影响父进程去连接其他客户端的请求。本模型也适用于线程,主线程每次accept 回来就创建一个子线程服务,由于线程共享文件描述符,故不用关闭,最后一个线程关闭监听套接字。

prefork服务器
它处理连接的进程/线程都是预先创建好的,因此可以减小创建进程/线程的开销,能够提高响应速度。进程预先fork了n个子进程(n的数目需要提前设定),每个子进程负责和客户端的通信,每个子进程执行的都是右图的流程。
前面的创建套接字,绑定端口号,监听套接字这些步骤,每个预先创建的进程已经完成,他们分别调用accept并由内核置入睡眠状态。
这种服务器的优点是:提高了响应速度,不需要引入父进程执行fork的开销,新客户就能得到处理。
缺点在于:每次启动服务器,父进程必须预测到底需要产生多少子进程,还有一个就是如果不考虑再派生子进程,先前派生的子进程可能被客户请求占用完,以后新到的请求只能先完成三次握手,并且达到listen接口的最大并发连接数backlog,直到有子进程可用。服务器才调用accept,将这些已经完成的连接传递给accept。

反应式服务器
服务器可以并发处理多个请求。不过本质上这些请求还是在一个线程中完成的,即:单线程轮询多个客户端。也无法充分利用多核CPU,不适合执行时间比较长的服务,所以为了让客户感觉是在"并发"处理而不是"循环"处理,每个请求必须在相对较短时间内执行。当然如果这个请求不能在有限的时间内完成,我们可以将这个请求拆分开来,使用有限状态机机制来完成。其中的Reactor可以使用IO多路复用
select/poll/epoll
多路:监听多个文件描述符
复用:复用同一个进程(或者说同一个线程)

模型的过程解析:
Reactor是一个线程对象,该线程会启动事件循环,并使用select/poll/epoll来实现IO多路复用。注册一个Acceptor事件处理器到Reactor中,Acceptor事件处理器所关注的事件是accept事件,这样Reactor会监听客户端向服务器端发起的连接请求事件。
客户端向服务器端发起一个连接请求,Reactor监听到了该accept事件的发生并将该accept事件派发给相应的Acceptor处理器来进行处理。Acceptor处理器通过accept方法得到与这个客户端对应的连接,然后将该连接所关注的读事件以及对应的read读事件处理器注册到Reactor中,这样Reactor就会监听该连接的read事件了。或者当你需要向客户端发送数据时,就向Reactor注册该连接的写事件和其处理器。
当Reactor监听到有读或者写事件发生时,将调用dispatch将相关的事件分发给对应的处理器进行处理。比如,读处理器会通过read方法读取数据,此时read操作可以直接读取到数据,而不会堵塞与等待可读的数据到来。
每当处理完所有就绪的I/O事件后,Reactor线程会再次执行IO多路复用的函数阻塞等待新的事件就绪并将其分派给对应处理器进行处理。
目前的单线程Reactor模式中,不仅I/O操作在该Reactor线程上,连非I/O的业务操作也在该线程上进行处理了,这可能会大大延迟I/O请求的响应。所以我们应该将非I/O的业务逻辑操作从Reactor线程上分离出去,以此来加速Reactor线程对I/O请求的响应。这种的服务器的并发量比并发式服务器多,因为并发式服务器能够创建的进程或者线程数目是有限的。
反应式 + 线程池型服务器
与单线程Reactor模式不同的是,增加了线程池对象,并将业务逻辑的处理从Reactor线程中移出转交给工作的线程池来执行。这样能够提高Reactor线程的I/O响应,不至于因为一些耗时的业务逻辑而延迟对后面I/O请求的处理。

使用线程池带来的好处有如下几点:
- 线程池中的线程是提前创建好的,这样可以在处理多个请求时分摊在线程创建和销毁过程产生的巨大开销。
- 将IO操作与非IO操作分离,当请求到达时工作线程通常已经存在,因此不会由于等待创建线程而延迟任务的执行,从而提高了响应性。
- 可以进行职责分离,让IO线程做IO操作,线程池处理业务逻辑,处理大量计算,充分利用CPU的计算优势。
在这个模型之下,线程池中的线程处理完业务逻辑,需要与Reactor线程进行通信,把结果交给Reactor,再由它发送给客户端。
(必然要通过线程间通信,让Reactor线程与线程池中的工作线程交换数据)
多反应式服务器
上面的模型中,Reactor代表的是一个进程(一个线程),既负责监听,又负责IO,如果在执行监听或者执行IO时是采用的阻塞式,也会导致其他的业务等待。
进一步改进:
一个主线程,多个工作线程,主线程Reactor负责接收客户端连接,每个线程有各自的Reactor负责执行任务队列中的任务。

多反应式+ 线程池模型
多个Reactor的模式,mainReactor与subReactor都是一个线程,因为多进程之间无法共享计算线程池。这种模型能够适用IO频繁且计算密集的服务。

Reactor模型
Reactor的基本概述
Reactor是后续项目使用的框架,上述的Reactor模型可以演变出很多种类型,但是这里我们主要研究两种Reactor模型,即:基础的Reactor(本质就是socket网络编程+IO多路复用)以及Reactor与线程池结合的版本。那么我们先从基本的Reactor来研究,探究其中的原理,然后使用面向对象的设计思想将其实现出来。
先回顾一下基本的Reactor模型的原理图
从图例中可以看到,多个客户端可以同时向Reactor服务器发起请求,而Reactor服务器是可以同时处理这些请求的。其中,Reactor使用IO多路复用技术监听多个客户端,但是为了能与多个客户端进行连接,所以注册了一个连接器Acceptor对象到Reactor中,进行连接事件的处理,也就是执行accept()函数,然后将连接交给Reactor对象,Reactor对象就对该连接进行对应的处理,读连接的数据,处理连接的数据,然后将处理好之后的数据发送给给个客户端,一条连接处理完毕之后继续处理下一条连接。
其实这个过程,就是**使用socket网络编程与IO多路复用(也就是epoll技术)实现的逻辑。**接下来我们按照面向对象的思想进行重构。
实现架构

如上图就是整个Reactor实现的架构图。
- 首先就是实现服务器,不可避免的网络编程部分需要用到很多系统调用,这些都是c语言中的系统调用,- 我们要以C++的风格用类来封装这些c的系统调用。
- Reactor的核心就是通过连接器接收连接,然后将连接交给Reactor进行管理,并且再建立连接的时候要先注册三个事件,分别是创建连接,消息处理,和连接关闭,之后对各个连接进行监听,处理连接的事件响应,至此Reactor的核心功能就已经实现完成了。
- 可以对这些功能再进行一次封装。但是当前会有一个问题就是事件的处理是串行的,所以再引入线程池和任务队列来实现并发的事件处理。这样就打好了整个框架
- 对整个框架进行最后的封装,只需要在应用层设置通信协议和事件处理的任务就可以了,不必关心底层细节的实现。
- 之后我还创建了两个单例,一个是配置类,设置该服务器的相关参数。另外一个就是日志类,用来打印调试信息。另外还可以设置一个用户系统,用来集中管理用户的登录状态,如果只是简单的测试的话,可以把用户状态作为数据成员放到TcpConnection中。
之后就逐步实现Reactor模型,我们把整个模型分为了五层,所以我们分为五个版本来对该模型进行迭代,每个版本实现一层的功能。