C++服务器设计:基于I/O复用的Reactor模式
Posted linux大本营
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++服务器设计:基于I/O复用的Reactor模式相关的知识,希望对你有一定的参考价值。
推荐视频:
linux多线程之epoll原理剖析与reactor原理及应用
网络原理tcp/udp,网络编程epoll/reactor,面试中正经“八股文”
c/c++ linux服务器开发学习地址:C/C++Linux服务器开发/后台架构师
I/O模型选择
在网络服务端编程中,一个常见的情景是服务器需要判断多个已连接套接字是否可读,如果某个套接字可读,则读取该套接字数据,并进行进一步处理。
在最常用的阻塞式I/O模型中,我们对每个连接套接字通过轮流read系统调用获取可读数据。如图3-1所示,read系统调用将会把该线程阻塞,直到数据报到达且被复制到应用进程的缓冲区中时才会返回。
图3-1 阻塞式I/O模型
在阻塞式I/O模型中,数据可读和读取数据这两个操作被合并在了一个系统调用中,对于单个套接字是否可读的判断,必须要等到实际数据接收完成才行,阻塞耗时是不确定的。考虑到这样一个情景,如果服务器中有十个已连接的的套接字,此时服务器给其中一个套接字调用read,而这个连接客户下一次向服务器发送数据将是2小时后。则该服务器也将阻塞在read这个系统调用2个小时,这段时间内服务器就算收到其他九个已连接的套接字数据也无法进行处理。这种一次只能针对一个客户连接的服务器I/O模型显然不是我们应该考虑的。
我们可以在阻塞式I/O模型的基础上进行修改,采用非阻塞式I/O模型。如图3-2所示,我们在执行read系统调用时,如果没有立即收到数据报,我们将不会把线程投入睡眠,而是返回一个错误。
图3-2 非阻塞式I/O模型
在非阻塞I/O模型中,虽然数据可读和读取数据这两个操作依旧在一个系统调用中,但是如果没有数据可读,系统调用将立即返回。此时我们可以对多个连接套接字轮流调用read,直到某次调用收到了实际数据,我们才针对这次收到的数据进行处理。通过这种方式,我们能够初步解决服务器同时读取多个客户数据的问题。但是这种在一个线程内对多个非阻塞描述符循环调用read的方式,我们称之为轮询。应用持续轮询内核,以查看某个操作是否就绪,这往往会消耗大量CPU时间,同时也会给整个服务器带来极大的额外开销。因此这种服务器I/O模型也不能很好的满足我们对于高性能的追求。
一种改良方案是在以上两种I/O模型的基础上加入多线程支持,即thread-per-connection方案。在该方案中,服务器会给每个连接客户分配一个线程,每个连接的读写都是在一个单独的线程中进行。因此对一个客户线程内的套接字进行读取操作,最多只会阻塞该客户线程,而不会对其他线程的其他连接产生影响。这是Java网络编程常见方案。这种方式中线程创建和销毁的开销较大,因此并不适合可能会频繁连接和断开的短连接服务,当然频繁的线程创建和销毁可以通过线程池进行改良。同时这种方案的伸缩性同样受到线程数的限制,对于存在的一两百个连接而创建的一两百个线程数,系统还能勉强支撑,但是如果同时存在几千个线程的话,这将会对操作系统的调度程序产生极大的负担。同时更多的线程也会对内存大小提出很高的要求。
我们从以上几个例子可以看出,解决服务器对多个连接套接字的读取的关键,其一是需将可读判断与实际读取数据相分离;其二是能同时支持多个套接字可读判断。因此我们需要一种能够预先告知内核的能力,使得内核一旦发现进程指定的一个或多个I/O条件就绪,即输入已经准备好被读取,它就通知进程。这个行为称之为I/O复用。在Linux平台上,提供了select、poll和epoll这几种系统调用作为I/O复用的方式。
Epoll是Linux内核为处理大批量文件描述符而做了改进的poll,是Linux下多路复用I/O接口的增强版本,支持水平触发和边缘触发两种方式。相对于select等I/O复用方式,它具有支持大数目的描述符,I/O效率不随注册的描述符数目增加而线性下降(传统的select以及poll的效率会因为注册描述符数量的线形递增而导致呈二次乃至三次方的下降),和使用mmap加速内核与用户空间的消息传递等优点。本系统将采用水平触发的epoll作为具体I/O复用的系统调用。
图3-3 I/O复用模型
如图3-3所示,我们将多个连接套接字注册在I/O复用的epoll系统调用中,并注册读事件,此时系统阻塞于epoll调用,等待某个数据报套接字变为可读。当epoll返回时,将会返回可读的套接字集合,我们只需遍历这些可读套接字,然后分别对每个可读套接字调用read系统调用,读出具体数据,并进一步进行相应处理即可。
通过使用epoll的这种I/O复用模型,我们能够在不引入创建新线程开销的前提下实现了服务器对多个连接套接字的读取支持。此处的多个连接,只和系统内存大小相关。而且由于Linux内核对于epoll的优化,连接描述符数量的增加不会导致性能线性下降。因此基于epoll的I/O复用模型能够满足我们对于服务器系统高并发和高性能的需求。
【文章福利】需要C/C++ Linux服务器架构师学习资料加群812855908(资料包括C/C++,Linux,golang技术,内核,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等)
Reactor模式介绍
在之前的研究中,我们选用epoll作为服务器系统的I/O复用模型。但是epoll太底层,它只是一个Linux的系统调用,需要通过某种事件处理机制进行进一步的封装。
在普通的事件处理机制中,首先程序调用某个函数,然后函数执行,程序等待,当函数执行完毕后函数将结果和控制权返回给程序,最后程序继续处理。在之前的I/O复用模型的研究中,我们同样是采用这种事件处理顺序进行的。
我们可以将整个问题抽象。每个已经连接的套接字描述符就是一个事件源,每一个套接字接收到数据后的进一步处理操作作为一个事件处理器。我们将需要被处理的事件处理源及其事件处理器注册到一个类似于epoll的事件分离器中。事件分离器负责等待事件发生。一旦某个事件发送,事件分离器就将该事件传递给该事件注册的对应的处理器,最后由处理器负责完成实际的读写工作。这种方式就是Reactor模式的事件处理方式。
相对于之前普通函数调用的事件处理方式,Reactor模式是一种以事件驱动为核心的机制。在Reactor模式中,应用程序不是主动的调用某个API完成处理,而是逆置了事件处理流程,应用程序需要提供相应的事件接口并注册到Reactor上,如果相应的事件发生,Reactor将主动调用应用程序注册的接口,通过注册的接口完成具体的事件处理。
Reactor模式优点
Reactor模式是编写高性能网络服务器的必备技术之一,一些常用网络库如libevent、muduo等都是通过使用Reactor模式实现了网络库核心。它具有如下优点:
- 响应速度快,不必为单个同步时间所阻塞,虽然Reactor本身依然是需要同步的;
- 编程简单,可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销;
- 可扩展性强,可以很方便的通过增加Reactor实例个数来充分利用CPU资源;
- 可复用性强,Reactor模式本身与具体事件处理逻辑无关,具有很高的复用性。
Reactor模式组成
图3-4是Reactor类图,图中表明Reactor模式由事件源、事件反应器、事件分离器、事件处理器等组件组成,具体介绍如下:
图3-4 Reactor类图
- l 事件源(handle):由操作系统提供,用于识别每一个事件,如Socket描述符、文件描述符等。在服务端系统中用一个整数表示。该事件可能来自外部,如来自客户端的连接请求、数据等。也可能来自内部,如定时器事件。
- l 事件反应器(reactor):定义和应用程序控制事件调度,以及应用程序注册、删除事件处理器和相关描述符相关的接口。它是事件处理器的调度核心,使用事件分离器来等待事件的发生。一旦事件发生,反应器先是分离每个事件,然后调度具体事件的事件处理器中的回调函数处理事件。
- l 事件分离器(demultiplexer):是一个有操作系统提供的I/O复用函数,在此我们选用epoll。用来等待一个或多个事件的发生。调用者将会被阻塞,直到分离器分离的描述符集上有事件发生。
- l 事件处理器(even handler):事件处理程序提供了一组接口,每个接口对应了一种类型的事件,供reactor在相应的事件发生时调用,执行相应的事件处理。一般每个具体的事件处理器总是会绑定一个有效的描述符句柄,用来识别事件和服务。
Reactor事件处理流程
Reactor事件处理流程如图3-5所示,它分为两个部分,其一为事件注册部分,其二为事件分发部分,具体论述如下。
图3-5 Reactor事件处理时序图
在事件注册部分,应用程序首先将期待注册的套接字描述符作为事件源,并将描述符和该事件对应的事件处理回调函数封装到具体的事件处理器中,并将该事件处理器注册到事件反应器中。事件反应器接收到事件后,进行相应处理,并将注册信息再次注册到事件分离器epoll中。最后在epoll分离器中,通过epoll_ctl进行添加描述符及其事件,并层层返回注册结果。
在事件处理部分,首先事件反应器通过调用事件分离器的epoll_wait,使线程阻塞等待注册事件发生。此时如果某注册事件发生,epoll_wait将会返回,并将包含该注册事件在内的事件集返回给事件反应器。反应器接收到该事件后,根据该事件源找到该事件的事件处理器,并判断事件类型,根据事件类型在该事件处理器调用之前注册时封装的具体回调函数,在这个具体回调函数中完成事件处理。
根据Reactor模式具体的事件处理流程可知,应用程序只参与了最开始的事件注册部分。对于之后的整个事件等待和处理的流程中,应用程序并不直接参与,最终的事件处理也是委托给了事件反应器进行。因此通过使用Reactor模式,应用程序无需关心事件是怎么来的,是什么时候来的,我们只需在注册事件时设置好相应的处理方式即可。这也反映了设计模式中的“好莱坞原则”,具体事件的处理过程被事件反应器控制反转了。
Reactor模式的使用
到目前为止,Reactor事件处理模式已经初步成型,我们通过进一步对Reactor模式的使用和完善来逐步实现服务端网络框架底层。
我们将Reactor的事件分离器epoll返回,再到服务器下一次调用epoll阻塞,称之为一次事件循环。如图3-6所示,我们将从业务的角度,对服务器系统开始监听客户端连接到处理每一个连接的可读数据,这一整个业务流程进行详细分析。
图3-6 Reactor模式的事件循环
在服务器刚启动时,我们完成相关初始化工作,并将服务器监听套接字及相应的监听套接字处理器注册到Reactor反应器。之后系统进入反应器的事件循环中等待注册事件发生。
此时如果有事件发生且事件为监听套接字的可读事件,则表示有新连接产生。Reactor回调之前注册的监听套接字handler。在这个handler处理中,我们通过accept系统调用获取新连接的套接字,并将该新连接的套接字及其相应的连接套接字处理器注册到Reactor反应器中。执行完该监听套接字handler后,如果仍有事件未处理,我们继续进入该事件的handler中进行回调处理,否则我们进入下一次事件循环中,继续调用epoll阻塞等待新的事件。
此时我们在Reactor反应器中已经注册了两类事件,一个是监听套接字可读事件,代表有新的连接到来;另一个是连接套接字可读事件,代表该连接套接字收到了客户端发来的数据。
如果再次有事件发生,且为监听套接字可读事件,则继续做如上处理。如果为连接套接字,Reactor回调注册的连接套接字handler。在这个handler回调中,我们通过调用read系统调用获取客户端发送过来的数据,并根据我们具体的业务需求做进一步处理。直到所有事件处理完毕后,我们再次进入下次事件循环,继续调用epoll阻塞等待新的事件。
通过以上业务实现,我们完成了在单线程下的服务器建立新的客户端连接,以及接收和处理客户端数据的工作。我们借助Reactor模式,不但保证了高并发和高性能的需求,同时也实现了网络细节与业务逻辑的分离。我们只需在注册的不同事件handler中实现具体的业务逻辑即可。
以上是关于C++服务器设计:基于I/O复用的Reactor模式的主要内容,如果未能解决你的问题,请参考以下文章