• 多路转接与Reactor


    目录

    五种IO模型

    阻塞与非阻塞

    select

    poll

    epoll

    Reactor


    五种IO模型

    IO = 等 + 拷贝数据

    比如传输层的接收缓冲区没有数据,那上层只能等它有数据了才能拷贝数据!

    高效的IO本质:减少单位时间内"等"的比重!!!

    一个江边,有5个人,张三,一直盯着自己的鱼漂,也不干别的事,当鱼漂动了就起杆;李四,则

    在等的过程,一会儿玩手机,一会儿看会儿书,当鱼漂动了就起杆;王五,则是通过一个铃铛与自

    己的杆连着,当有鱼上钩时,铃铛就会响;赵六,则是有很多鱼竿,哪个鱼漂动了,就去起哪个

    杆;田七是老板,让自己的秘书替自己钓鱼,然后自己就离开了,当田七给的桶装满了鱼的时候,

    秘书就给田七打电话,让他来拿鱼和接他!

    张三——阻塞等待

    李四——非阻塞等待

    王五——信号驱动

    赵六——多路转接

    田七——异步IO

    钓鱼 = 等+ 钓,前面几个都是同步IO!鱼竿 <-> fd

    几乎所有的IO函数,核心工作其实就2类,1.等;2.拷贝

    阻塞IO: 在内核将数据准备好之前,系统调用会一直等待,所有的套接字,默认都是阻塞等待

    非阻塞IO: 如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK错

    误码

    信号驱动IO: 内核将数据准备好的时候, 使用SIGIO信号通知应用程序进行IO操作

    多路转接:最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态

    异步IO: 由内核在数据拷贝完成时, 通知应用程序

    在实际的应用场景中,等待消耗的时间往往都远远高于拷贝的时间, 让IO更高效,最核心

    的办法是让等待的时间尽量少

    注意:这里的同步与进程/线程的同步没有任何关系!!!

    阻塞与非阻塞

    阻塞调用是指调用结果返回之前,当前线程会被挂起,调用线程只有在得到结果之后才会返回

    非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程

    什么叫做等事件就绪

    IO事件就绪分为读事件就绪和写事件就绪,只有当缓冲区的数据达到一定量的时候,才会被应用层

    拷贝数据!

    非阻塞IO

    获得/设置文件状态标记(cmd=F_GETFL或F_SETFL)

    如下图,将文件描述符设置为非阻塞

    如下图,从键盘读取数据,读取成功就打印读取到的数据和errno

    在非阻塞情况下,我们读取数据,如果数据没有就绪,系统是以出错的形式返回的(不是错误),而

    没有就绪和真正的错误,使用的是同样的方式标识,那就需要用errno来进一步区分!errno为11

    时,表示没有就绪,EAGAIN == 11,EAGAIN是一个宏!

    运行结果

    多路转接

    select:只负责一件事情,就是"等"!

    select定位:只负责等待,等到fd就绪,通知上层进行读取或者写入

    注意:select没有所谓的读取和写入数据的功能!!!

    read、write、recv、send本身也有等待的功能,但是只能传入1个fd,而select能够同时等待多

    个fd!!!

    我们想要的等待结果:读就绪,写就绪,异常就绪

    函数原型如下图

    nfds = maxfd + 1

    fd_set是一个位图结构:比特位的"位置"代表哪一个sock,如下图

    三个readfds,writefds,exceptfds参数分别表示:只关心读、写、异常,且是输入输出型参数!

    以读为例,select的核心功能有两点

    1.用户告知内核,你要帮我关心哪些fd上的读事件就绪

    2.内核告知用户,你所关心的哪些fd上的读事件已经就绪

    比特位的内容

    输入时:用户告诉内核,你要帮我关心的fd集合,将0-5的bit位都置为1,表示0-5都要关心!

    输出时:内核告诉用户,你关心的哪些fd上面的事件已经就绪,3号bit位为1,表示3号fd已经就绪

    写和异常也类似于读!

    参数timeout是等待方式,也是一个输入输出型参数,假设设置5秒,3秒就绪,那返回的就是剩余

    时间2秒!有三种等待方式

    1.只要不就绪,我就不返回,阻塞等待,timeout为NULL

    2.只要不就绪,立马返回,非阻塞等待,timeout为{0,0}

    3.设置好deadline,deadline之内,按1进行,deadline之外,按2进行,假设deadline为5秒,

    timeout为{5,0},5秒超时!

    上述三种方式,都是只要就绪,立马返回!!!

    位图在设置的时候,我们不能自己去进行位操作,而要调用下面的接口!!!

    用来清除描述词组set中相关fd的bit位

    用来测试描述词组set中相关fd的bit位是否为真

    用来设置描述词组set中相关fd的bit位

    用来清除描述词组set的全部bit位

    返回值,大于0表示等待的fd的数量,等于0表示超时,小于0表示出现错误!

    理解select执行过程

    例子如下:

    (1)执行fd_set set; FD_ZERO(&set);则set用位表示是0000,0000。

    (2)若fd=5,执行FD_SET(fd,&set);后set变为0010,0000(第5位置为1)

    (3)若再加入fd=2,fd=1,则set变为0010,0011,用户告诉内核

    (4)执行select(6,&set,0,0,0)阻塞等待

    (5)若fd=1,fd=2上都发生可读事件,则select返回,此时set变为0000,0011,内核告诉用户

    注意:没有事件发生的fd=5被清空

    如上,只是模拟了一次,select因为使用输入输出型参数表示不同的含义,意味着后面每一次,都需要对fd_set进行重新设置!

    如何得知之前都有哪些fd?

    用户必须定义数组或者其它容器结构,来把历史fd全部保存起来!!!

    代码实现

    前期准备

    创建一个第三方数组,用来保存历史fd

    因为我们要采用多路转接的方式,即使用select,所以监听之后不能直接accept,accept是阻塞

    式等待,因为它不知道有listen_sock上有新链接,对于所有的服务器,最开始的时候,只有

    listen_sock!!!

    初始化数组,同时定义一个读位图变量,并将监听套接字设为0号fd!

    然后是事件死循环,因为我们不知道有哪些关系读事件的fd要检测,所以只能遍历所有的fd,将关

    心读事件的fd添加到rfds中,以及找到最大fd!这里采用阻塞式等待!

    然后根据返回值来执行对应的操作!

    当等待成功时,即有事件就绪,需要循环遍历所有的fd,来找到哪些fd就绪了!如果为-1则表示该

    位置的fd未被使用!如果FD_ISSET为真,表示该fd合法

    合法的fd不一定是就绪的fd,所以需要判断是否是listen_sock

    如果是listen_sock,就accpt,链接成功,如果有服务器有剩余的fd,因为无法确定该链接是否有

    数据到来,而判断是否有数据只有select知道,所以只能将新的链接的sock保存在数组中,待下一

    轮循环时去读取数据,没有则关闭新的链接!

    如果是普通的sock,即读事件就绪!就可以读取数据,也分为三种情况,如果读取成功(s>0),就

    打印读取到的信息,如果对端断开了连接(s == 0),就关闭相应的fd,同时将数组中相应位置的值

    为-1,表示该位置的fd已经关了!如果小于0,表示读取错误,和对端断开连接处理一致!

    运行结果

    总结

    优点

    可以一次等待多个fd,在一定程度上,提高IO的效率

    缺点

    每次都要重新设置,每次完成之后,需要遍历检测

    fd_set,它能够让select同时检测的fd是有上限的!

    select底层需要轮询式的检测哪些fd上的哪些事件就绪了!

    select可能会较为高频率的进行用户到内核,内核到用户的频繁拷贝问题!

    通过缺点的第三点,我们也可以得出因为os也要轮询式的检测,而os又喜欢前闭后开的区间,所

    以就有了nfds = max_fd + 1!

    poll

    函数原型

    poll的第一个参数是一个结构体指针,是一个结构体数组的首地址!该结构体内部一个有三个变

    量,一个是要关心的fd,另外两个则是事件!第二个参数nfds则和select的一样,第三个参数

    timeout和select的也很类似,虽然类型不同,但意思都差不多,0表示非阻塞,-1表示阻塞,大于

    0,表示在这个时间内阻塞,超过这个时间非阻塞!

    事件是short类型,那就有16个bit位,就可用这些bit位,来表示那些事件,比如读事件,写事件,

    异常事件,如下图,每一个事件都是一个宏,都只占用一个bit位!

    添加读事件和写事件,events | = POLLIN,events |= POLLOUT

    检测读事件就绪,if(events & POLLIN) 

     代码实现

    如下图,用poll来等待0号描述符!

    运行结果

    poll的优点

    不同于select使用三个位图来表示三个fdset的方式,poll使用一个pollfd的结构体实现

    1.包含了要监视的event和发生的revent,不再使用select“参数-值”传递的方式,接口使用比

    select方便!

    2.poll并没有最大数量限制 (但是数量过大后性能也是会下降,因为os采用的是轮询遍历的方式)

    poll的缺点

    poll中监听的文件描述符数目增多时

    1.和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符

    2.每次调用poll都需要把大量的pollfd结构从用户态拷贝到内核中

    3.同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增

    长, 其效率也会线性下降

    epoll

    epoll_create: 创建一个epoll的句柄,自从linux2.6.8之后,size参数是被忽略的,所以可以随便

    填,只要大于0即可!

    注意:用完之后, 必须调用close()关闭

    epoll_ctl:用户告诉内核,只要调用一次,内核就永远记住了!

    第一个参数epfd是epoll_create的返回值

    第二个参数是操作,可填的内容如下所示

    EPOLL_CTL_ADD:注册新的fd到epfd中

    EPOLL_CTL_MOD:修改已经注册的fd的监听事件

    EPOLL_CTL_DEL:从epfd中删除一个fd

    第三个参数就是要操作的fd

    第四个参数是用户告诉内核,要关心的事件!结构体第一个变量与poll的event一样,第二个结构

    体变量中的fd是用户要内核关心的fd!

    epoll_wait:内核告诉用户

    events和epoll_ctl中的events一样,只不过其中的内容是内核告诉用户的!

    maxevents告诉内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的

    size!

    参数timeout与poll的timeout一样!

    epoll原理

    通知就绪时,CPU会通过中断的方式,从内核上下文切换到中断向量表,将外设上的数据拷贝到内

    存中!

    比如select、poll,内核会通过轮询的方式检测就绪的fd,但epoll没有这么做,而是在硬件与操

    系统之间建立了一个回调机制,可以将就绪的fd尾插到就绪队列中,就不用再去遍历了!

    凡是在红黑树中的节点,对应的fd和事件,就是OS系统需要关心的,红黑树就相当于select中的

    第三方数组!!!

    epoll_create的作用就是创建epoll模型

    epoll_ctl的作用:1.插入节点到红黑树;2.建立该fd对应的回调策略

    epoll_wait的作用:以O(1)的时间复杂度,检测是否有事件就绪,即判断就绪队列中是否有节点

    代码实现

    建立tcp,监听socket

    创建epoll模型,获得epfd

    将listen_sock和它所关心的事件,添加到内核

    事件循环

    有事件就绪时,循环遍历,用if语句条件为按位与的方式,是什么事件的fd就绪!以读事件为例!

    然后还要判断是普通sock,还是listen_sock,普通sock就可以直接读取数据,listen_sock就直

    Accept,连接到的sock,还不能读取数据,因为无法判断其是否有数据到来,只有epoll才知道

    是否有数据,所以只能托管给epoll!

    读取数据,成功就直接打印,s == 0时表示对端链接关闭,所以sock也要关闭,同时告诉内核不要

    再关心该sock了,即调用epoll_ctl,删除红黑树上的此节点,读取失败也做一样的处理!

    运行结果

    上面读取数据的处理,是有问题的,因为你无法保证读取到的是客户端给你发的一个完整的报文!

    同时,如果你还有数据没有读完,还在buffer中,下一轮循环时,之前buffer所占用的空间早就被

    释放了,数据也就没有了!

    epoll的工作方式

    LT:水平触发

    ET:边缘触发

    下面以拿快递的例子来解释这两种工作方式

    假设你在淘宝上买了5件东西,它是陆陆续续到达的,开始到达了2个,张三就给你打电话,要你来

    拿快递,而你此时正在打游戏,就说马上来拿,却依旧在打游戏,张三此时就去给别人打电话去

    了,过了不久,又给你打电话,叫你去拿快递,你说知道了!打了几个电话后,你嫌烦,就去拿快

    递去了,同时另外3个快递也来了,但是买的东西比较大或者重,你拿了2个之后,觉得累,就又坐

    着打游戏去了,张三发现你又不来拿了,就又给你打电话!

    下次你又买了5件东西,只是打电话的人换成了李四,李四就不是惯你毛病的人,开始来了3个,李

    四给你打电话,告诉你有3个快递到了,要你来拿,然后就挂断电话了,就不再继续打电话了,下

    次又来了2个快递,又给你打电话,告诉你有2个快递,要你来拿!

    水平触发就如同张三的工作方式,边缘触发就如同李四的工作方式!!!

    对于张三:底层只要有你的包裹,就会一直通知你

    对于李四:只有当快递点中有你的快递到来时,从无到有,从有到多的时候会给你打一个电话,除

    此之外,不会给你多打一个电话!通过它的这种通知策略,倒逼程序员一旦开始读取数据,就要一

    直读完!

    两种不同的通知方式,从通话效率上来说,李四的效率更高,因为一个人一天能打的电话量是有限

    的,而李四不会打多余的电话,张三就可能给一个人打很多个重复的电话

    select、poll、epoll默认为水平触发

    如下图,在事件中添加EPOLLET,就成为了边缘触发的工作方式

    ET模式下的fd,必须是非阻塞的原因

    ET -> 通知一次 -> recv/accept/read -> 准备读取 -> 我怎么保证我将本次全部读取完呢?-> 循环

    读取 -> 可能会在读取的最后一次我们会卡住(阻塞住了) -> 单进程 -> 如何解决?->ET模式下的所

    有的fd,必须将该fd设置为非阻塞!

    例如:你妈把每个月该给你的零花钱给了你爸,你每天找你爸要100,当第四天时,你爸告诉你,

    他只有30块钱了,直接给你了,你第五天就不会找你爸要钱了,因为你知道已经要完了这个月的零

    花钱了!这种情况还算好的!

    假设你爸总共只有400元,你前四天每天都要到了100元,第五天你依旧会去要,因为你无法判定

    是否还有零花钱,这种情况就很糟糕了!此时,本来没有数据了,你还要去读,那就会卡住,即阻

    塞住了,客户端因为没有收到响应,以为你没有读完,下次也不会给你发送数据了,所以ET

    模式下所有的fd,必须被设置为非阻塞!

    下面对于epoll基于ET模式进行服务器设计,写一个计算器!同时要针对上面写的epoll代码的缺

    点,要做到以下3点

    1.我们需要给每一个fd,都要有自己专属的输入输出缓冲区!

    2.虽然已经对等和拷贝在接口层面已经进行了分离,但是在代码逻辑上,依旧是耦合在一起的

    3.epoll最大的优势在于 就绪事件通知机制!

    代码实现

    对于Reactor.hpp文件

    首先定义一个事件节点

    每个sock都有自己的输入缓冲区和输出缓冲区

    一般处理IO的时候,我们只有三种接口需要处理,即读取、写入和异常,所有先定义了一个函数指

    针,然后在节点内添加了三个函数指针类型的成员

    针对上面所说的第二点与第三点,通过回调的方式,来进行解耦!

    设置Event回指Reactor的指针

    构造函数

    对于Reactor类

    首先是定义一个epfd成员,其次是定义一个unordere_map类型的成员,来让sock与其读写事件

    形成映射关系

    然后是构造函数

    初始化Reactor,即创建epoll模型

    插入事件,即让内核关心sock,同时将映射关系,插入到unordered_map中

    删除事件,即让内核不再关心sock,同时将映射关系,从unordered_map中移除

    针对上面所述的第三点,写一个就绪事件的派发逻辑

    首先定义一个结构体数组,来保存内核告诉用户已经就绪的事件,对于n个就绪事件,循环处

    代表差错处理,将所有的错误问题全部转化成为让IO函数去解决

    对于读事件就绪,直接调用回调方法,执行对应的读取,同时要保证这个sock还在events内

    判断sock是否还在events内

    对于写事件就绪,直接调用回调方法,执行对应的写入,同时要保证这个sock还在events内

    对于epoll_server.cc文件

    如下图的代码和之前的代码类似,所以不多赘述,只不过这里要多调用一个设置非阻塞接口!

    创建一个Reactor对象

    Reactor(反应堆模式):通过多路转接方案,被动的采用事件派发的方式,去反向的调用对应的回

    调函数

    向Reactor反应堆中加柴火

    首先得有柴火,Accepter:链接管理器,如下图

    将准备好的柴火放入反应堆Reactor中,同时设置成ET模式

    开始进行事件派发!

    对于Util.hpp文件

    将sock设置为非阻塞

    对于Accepter.hpp文件

    写一个Accepter接口,循环接收到来的sock,将要关心的事件告诉给内核!Recver、Sender、

    Errorer则负责真正的读取!

    对于Server.hpp文件

    Recver函数

    1.真正的读取

    把接收到的数据全部都写入到该sock自己的输入缓冲区里面,当s < 0时,就发生了读写错误,可

    能是IO被信号打断了,也有可能是没有数据读了,还有可能是真的出错了!

    2.分包,对于一个或者多个报文,解决粘包问题,如1+2X3+4X5+6X,要分解成1+2,3+4,5+6

    SplitSegment函数放在Util.hpp文件中,将分成的多个报文都装入到vector中,不足一个报文就不

    要去处理,放在inbuffer中即可!

    3.反序列化,即针对一个报文,提取有效参与计算或者存储的信息

    4.业务逻辑 -- 得到结果

     

    5.构建响应,同时,发送数据,即将数据写入到输出缓冲区内

     

    6.尝试直接或间接进行发送。写事件基本上都是就绪的,但是用户不一定是就绪的!所以对于

    写事件,我们通常都是按需设置,而不能在InsertEvent时,同时设置写事件和读事件!

    在Reactor.hpp文件中,写一个EnableRW的接口,设置读事件和写事件!

    Sender函数

    total是本轮累计发送的数据量,当total的大小为输出缓冲区的数据量时,就表示发送完毕!

    发送过程中可能会被信号中断,也可能数据没有发完,但是不能再发了,或者是发送失败!

    根据不同的情况,即SenderCore函数的返回值,做不同的处理!发送完毕,就直接关闭写事件,没

    有发完,就让它继续发,发送失败,就调用异常函数!

    Errorer函数

    发生异常,直接删除该sock,即不再让内核关心了!

    运行结果

  • 相关阅读:
    maven基础
    XTU-OJ 1412-Rotate Again
    性能优化-如何爽玩多线程来开发
    逻辑漏洞----权限类漏洞
    Taurus .Net Core 微服务开源框架:Admin 插件【2】 - 系统环境信息管理 - 【OS、Assembly】
    《大话设计模式》学习总结
    sklearn基础篇(五)-- 线性模型
    leetcode:1579. 保证图可完全遍历【并查集思路】
    Java注释
    在keil MDK中定义非初始化(noini)变量
  • 原文地址:https://blog.csdn.net/weixin_58867976/article/details/127657163