【Linux】高级IO --- 多路转接,select,poll,epoll

所有通过捷径所获取的快乐,无论是金钱、性还是名望,最终都会给自己带来痛苦

文章目录


一、五种IO模型

1.什么是高效的IO?(降低等待的时间比重)

后端服务器最常用的网络IO设计模式其实就是Reactor,也称为反应堆模式,Reactor是单进程,单线程的,但他能够处理多客户端向服务器发起的网络IO请求,正因为他是单执行流,所以他的成本就不高,CPU和内存这样的资源占用率就会低,降低服务器性能的开销,提高服务器性能。
而多进程多线程方案的服务器,缺点相比于Reactor就很明显了,在高并发的场景下,服务器会面临着大量的连接请求,每个线程都需要自己的内存空间,堆栈,自己的内核数据结构,所以大量的线程所造成的资源消耗会降低服务器的性能,多线程还会进行线程的上下文切换,也就是执行流级别的切换,每一次切换都需要保存和恢复线程的上下文信息,这会消耗CPU的时间,频繁的上下文切换也会降低服务器的性能。前面的这些问题都是针对于服务器来说的,对于程序员来说,多执行流的服务器最恶心的就是调试和找bug了,所以多执行流的服务器生态比较差,排查问题更加的困难,服务器不好维护,同时由于多执行流可能同时访问临界资源,所以服务器的安全性也比较低,可能产生资源竞争,数据损坏等问题。

谈完Reactor这种模式的好处之后,接下来理解一下什么是高效的IO,只有真正理解了IO,我们才能理解Reactor这种模式。
IO这件事我们并不陌生,在我们自己的电脑内部其实就无时不刻的在进行着IO,因为冯诺依曼体系已经决定了计算机是要无时不刻进行IO的,从存储设备中拿取数据到内存,将处理结果再返回至内存,从网络IO角度来讲,我们的计算机要从网卡这样的硬件中将数据拿到计算机内存,将数据处理完毕之后,可能还要再将数据从网卡发出去,这其实也是IO的过程,所以对于计算机来说IO是非常常见的一件事情。


3.
但上面对IO的理解还是不够深刻,以前在学习TCP网络套接字编程时,我们就谈到过,IO其实就是进行拷贝,例如send时,其实是把自己buffer缓冲区中的数据拷贝到内核的sk_buff中,recv时,其实是把内核的接收缓冲区中的数据拷贝到自己在应用层定义的buffer中,所以我们当时就认为IO其实就是数据拷贝。
但我们可以细微的想一想,只要你调用了recv,数据就一定能够拷贝到应用层buffer中吗?会不会sk_buff中没有数据呢?因为可能没有客户端向我的服务器发送数据。同理,只要你调用了send,数据就一定能够拷贝到内核sk_buff中吗?会不会sk_buff中已经堆满了数据,没有剩余空间了呢?
如果这些情况都存在的话,recv,send这样的接口会怎么做呢?答案是,这些接口一定会等!会等条件就绪的时候,再进行数据拷贝,recv会等sk_buff中有数据,send会等sk_buff中有剩余空间,等到条件就绪的时候,这些IO接口才会进行数据拷贝。所以我们今天要重新定义IO,IO不仅仅是数据拷贝,同时还需要进行等待,等待条件就绪时,这些接口才会进行数据拷贝,所以IO=等+数据拷贝

其实我们是遇到过等这样的情况的,以前在讲进程间通信的时候,管道通信时,我们就遇到过等待的情况,例如写端不向管道中写数据,此时读端就会阻塞,阻塞的本质其实就是在进行等待,等待写端向管道中写数据,所以读取数据的IO接口在进行等待的情况是比较常见的,但写数据时是不常见的,因为大多数情况下,写事件是直接就绪的,因为内核发送缓冲区中常常是有剩余空间的,TCP有自己的滑动窗口发送数据的策略,基本上写事件不就绪的情况很少见,但读事件不就绪还是很常见的,尤其是在网络环境下进行读取数据,因为数据包在send调用后,数据包会被发送到内核的sk_buff中,什么时候发送,怎么发送这些策略是由TCP来提供的,数据包在发送时,会经历延迟应答,查询路由表确定下一跳路径,在局域网中进行转发数据包,这些过程都是需要时间的,此时对端调用recv接收数据时,在这些时间窗口内,recv不就得等吗?

而所谓高效的IO,其实就是降低等待的时间比重,因为数据拷贝的时间比重基本上是确定的,他由硬件结构,操作系统优化,编译器优化等条件所决定,或者可以提高带宽,一次性拷贝较多的数据,但这些的做法其实都是固定的,对数据拷贝的效率提升并不大,而影响IO效率最大的因素其实就是等待,只要IO模型等待的时间比重很低,那么这个IO我们就称他是高效的。

2.有哪些IO模型?哪些模型是高效的?

IO模型分为五种,分别是阻塞式IO,非阻塞IO,信号驱动IO,多路转接IO,异步IO。下面我们讲一个例子先来浅浅谈一下这5个模型IO的做法。
从前有一条小河,河里有许多条鱼,一个叫张三的少年就很喜欢钓鱼,他带着自己的鱼竿就去钓鱼了,但张三这个人很固执,只要鱼没上钩,张三就一直等着,什么都不干,死死的盯着鱼漂,只有鱼漂动了,张三才会动,然后把鱼钓上来,钓上来之后,张三就又会重复之前的动作,一动不动的等待鱼儿上钩。而此时走过来一个李四,李四这名少年也很喜欢钓鱼,但李四和张三不一样,李四左口袋装着《Linux高性能服务器编程》,右口袋装着一本《算法导论》,左手拿手机,右手拿了一根鱼竿,李四拿了钓鱼凳坐下之后,李四就开始钓鱼了,但李四不像张三一样,固执的死盯着鱼漂看,李四一会看会儿左口袋的书,一会玩会手机,一会儿又看算法导论,一会又看鱼漂,所以李四一直循环着前面的动作,直到循环到看鱼漂时,发现鱼漂已经动了好长时间了,此时李四就会把鱼儿钓上来,之后继续重复循环前面的动作。此时又来了一个王五少年,王五就拿着他自己的iphone14pro max和一根鱼竿外加一个铃铛,然后就来钓鱼了,王五把铃铛挂到鱼竿上,等鱼上钩的时候,铃铛就会响,王五根本不看鱼竿,就一直玩自己的iphone,等鱼上钩的时候,铃铛会自动响,王五此时再把鱼儿钓上来就好了,之后王五又继续重复前面的动作,只要铃铛不响,王五就一直玩手机,只有铃铛响了,王五才会把鱼钓上来。此时又来了一个赵六的人,赵六和前面的三个人都不一样,赵六是个小土豪,赵六手里拿了一堆鱼竿,目测有几百根鱼竿,赵六到达河边,首先就把几百根鱼竿每隔几米插上去,总共插了好几百米的鱼竿,然后赵六就依次遍历这些鱼竿,哪个鱼竿上的鱼漂动了,赵六就把这根鱼竿上的鱼钓上来,然后接下来赵六就又继续重复之前遍历鱼竿的动作进行钓鱼了。然后又来了一个钱七,钱七比赵六还有钱,钱七是上市公司的CEO,钱七有自己的司机,钱七不喜欢钓鱼,但钱七喜欢吃鱼,所以钱七就把自己的司机留在了岸边,并且给了司机一个电话和一个桶,告诉司机,等你把鱼钓满一桶的时候,就给我打电话,然后我就从公司开车过来接你,所以钱七就直接开车回公司开什么股东大会去了,而他的司机就被留在这里继续钓鱼了。

在上面的例子中,你认为谁的钓鱼方式更加高效呢?首先我们认为,如果一个人在不停的钓鱼,时不时的就收鱼竿,把鱼钓上来,等待鱼儿上钩的时间比重却很低,那么这个人在我看来他的钓鱼方式就是高效的。而如果一个人大部分的时间都是在等待,只有那么极少数次在收杆把鱼钓上来,那么这个人的钓鱼方式就是低效的。
而上面的例子中,鱼其实就是数据,鱼竿其实就是文件描述符fd,每个人都算是进程,但除了钱七的司机,这个司机算是操作系统,河流就是内核缓冲区,鱼漂就是就绪的事件,代表钓鱼这件事情已经就绪了,进程可以对数据做拷贝了。
其实赵六的方式是最高效的,也就是多路转接这种IO模型是最高效的,因为赵六的鱼竿多啊,钓上鱼的几率就大啊,其他人只有一根鱼竿,只能关心这一根鱼竿上的数据,自然就没有赵六的效率高,同理为什么渣男的女朋友多啊,因为广撒网嘛,找到女朋友的概率要比普通的老实人高啊,因为人家一次可以关心那么多的微信账号,哪个女孩发消息了人家就和谁聊天,肯定比你只有一个女孩的微信效率要高。
所以本文章主要来介绍多路转接这种IO模型,同时也会讲解阻塞和非阻塞IO,需要注意的是,实际项目中,最常用的就是阻塞IO,同时大部分的fd默认就是阻塞的,因为这种IO太简单了,越简单的东西往往就越可靠,代码编写也越简单,调试和找bug的难度也就越低,这样的代码可维护性很高,所以他就越常用。

3.五种IO模型的特性差别

阻塞,非阻塞,信号驱动在IO效率上是没有差别的,因为他们三个人都只有一根鱼竿,等待鱼上钩的概率都是一样的,相当于他们等待事件就绪的概率是相同的,所以从IO效率上来看,这三个模型之间是没有差别的。只不过三者等待的方式是不同的,阻塞是一直在进行等待,而非阻塞可能会使用轮询的方式来进行等待,在等待的时间段内,非阻塞可能还会做一些其他的事情,信号驱动和非阻塞一样,在等待的时间段内,信号驱动会做一些其他的事情,比如监管一下其他的连接是否就绪等等事情。所以从IO的效率角度来讲,这三种IO并无差别,因为IO的过程分为等待和数据拷贝,三者在这个工作上的效率都是一样的,只不过非阻塞和信号驱动的等待方式与阻塞IO不同。信号驱动只不过是被动的等待,阻塞和非阻塞都是主动的等待,当信号到来时,信号驱动IO会通过回调的方式来处理就绪的事件。

而多路转接相比前三种IO模型更为高效一些,因为他能够一次等待多个文件描述符,但这四种IO都有一个共同的特征,就是直接参与了IO的过程,这样的通信我们称之为同步通信,而异步IO是典型的异步通信,他将等待数据就绪的事情交给了内核来处理,当数据准备好后,操作系统会以信号或回调函数的方式来通知进程可以处理数据了,因为数据已经准备好了,这就是典型的异步通信。

所以以后在看到同步这个词时,一定要确定好他的大背景是什么,是同步通信还是异步通信,这指的是进程的消息通信机制不同,前者是同步IO的方式,也就是主动等待调用返回的结果,后者是异步IO的方式,调用是直接返回的,后续执行调用的操作系统会以某种方式来通知进程。
或者同步也有可能是线程的同步与互斥,线程同步指的是多个线程之间通过条件变量的方式来互相协同工作,完成某一件任务。

二、阻塞与非阻塞IO

每一个打开的文件所对应的文件描述符fd,默认全都是阻塞的,无论是系统文件fd,还是网络套接字sock,都默认是阻塞的IO方式。
设置fd为非阻塞的方式大概有三种,在open打开文件的时候,可以携带选项O_NONBLOCK,或者在使用网络IO接口,例如send,recv等时,可以携带额外的选项MSG_DONTWAIT,另一种是最常用的方式,就是fcntl系统调用,像open那样的方式只适合打开系统文件,对于网络套接字就不太适合,况且打开文件时,还要记住这么多的额外选项,对于程序员还是不太友好,像fcntl这样的方式,无论对于系统文件还是网络套接字都是完全适用的。

fcntl函数有5种功能,我们这里只使用第三种功能,也就是设置文件描述符的状态标记,先通过F_GETFL选项获取原有文件描述符的标志位,然后再通过F_SETFL选项将原有的标志位与O_NONBLOCK按位或之后,再重新设置回文件中。这样就可以将文件描述符设置为非阻塞了。

下面的运行结果就是典型的阻塞式IO,当程序运行起来时,执行流会在read处阻塞,因为read今天读取的是0号文件描述符,也就是键盘文件上的数据,只要我不从键盘上输入数据的话,read就会一直阻塞,此时进程会被操作系统挂起,直到硬件设备键盘上有数据时,进程才会重新投入CPU的运行队列,当我们输入数据后,可以立马看到进程显示出了echo回应的结果,同时进程又立马陷入阻塞,等待我进行下一次的输入数据,这样的IO方式就是典型的阻塞式,同时也是最常用,最简单的IO方式。
在这里额外补充一下,linux命令行中表示输入结束的快捷键是ctrl+d,当此热键被用户按下后,代表0号文件描述符写端关闭,此时读端会读到0,read会返回0值,此时进程除了输出提示信息"read file end"外,还应该加一个break,跳出循环,结束进程。

下面就是非阻塞IO的实验结果,非阻塞IO在没有读到数据时,并不会卡在read系统调用处不动,等待0号fd到来数据,而是read立马返回,同时read的返回值为-1,所以你可以看到,while循环里面在不停的打印>>>输出信息,因为read在未读取到数据时,会立马返回,并不会阻塞住。

下面有设置fd为非阻塞IO的接口SetNonBlock(),设置的方式也很简单,只需要先获得fd原有的标志位fl,然后再将fl与O_NONBLOCK按位或,重新设置到文件描述符fd即可,是不是很简单呢?

5.
非阻塞IO时,read的返回结果是-1,这样合理吗?底层没有数据,这算错误吗?其实这并不算错误,只不过当底层没有数据时,read以错误的方式返回了,但我们该如何区分read接口是真的调用失败了(比如read读取了一个不存在的fd),还是仅仅底层没有数据罢了,当然通过read的返回值我们是无法区分的,因为read在这两种情况下都返回-1,但可以通过错误码来区分,当非阻塞IO返回时,如果是底层没有数据,错误码会是EWOULDBLOCK或EAGAIN,如果read是真的出错调用了,会有相对应的错误码。
同时在非阻塞等待期间,进程也可以做一些其他的任务,例如打印一些日志,下载某些文件,执行某些SQL语句等等,执行的方式也很简单,先将这些函数加载到一个vector里,然后在非阻塞IO的循环内部执行一个宏函数,宏函数内部其实就是遍历vector容器,依次执行容器中的函数指针方法。
需要额外解释的一个错误码就是EINTR,代表interrupted,也就是系统调用被中断,有可能read在系统调用执行的过程中,内核会检查进程关于信号的三张表block表,pending表,handler表,而检查之前,恰好进程收到了来自操作系统的信号,所以此时有可能进程运行级别会重新变为用户态,转而执行用户定义的handler方法,也就是信号对应的处理函数,在执行完handler方法返回时,read系统调用会被中断,read返回-1,同时错误码被设置为EINTR。

三、select_server

1.select系统调用详解

select是我们学习的第一个多路转接IO接口,select只负责IO过程中等待的这一步,也就是说,用户可能关心一些sock上的读事件,想要从sock中读取数据,直接读取,可能recv调用会阻塞,等待数据到来,而此时服务器进程就会被阻塞挂起,但服务器挂起就完蛋了,服务器就无法给客户提供服务,可能会产生很多无法预料的不好影响,万一客户正转账呢,服务器突然挂起了,客户的钱没了,但商家这里又没有收到钱,客户找谁说理去啊,所以服务器挂起是一个问题,我们要避免产生这样的问题。
而select的作用就是帮用户关心sock上的读事件,等sock中有数据时,select此时会返回,告知用户你所关心的sock上的读事件已经就绪了,用户你可以调用recv读取sock中的数据了!所以多路转接其实是把IO的过程分开来执行了,用多路复用接口来监视fd上的事件是否就绪,一旦就绪就会立马通知上层,让上层调用对应的接口进行数据的处理,等待和数据拷贝的工作分开执行,这样的IO效率一定是高的,因为像select这样的多路转接接口,一次能够等待多个fd,在返回时,它可以把多个fd中所有就绪的fd全部返回并通知给上层。

当程序运行时,程序其实会在select这里进行等待,遍历一次底层的多个fd,看其中哪个fd就绪了,然后就将就绪的fd返回给上层,select的第一个参数nfds代表监视的fd中最大的fd值+1,其实就是select在底层需要遍历所有监视的fd,而这个nfds参数其实就是告知select底层遍历的范围是多大,后四个参数全部是输入输出型参数,兼具用户告诉内核 和 内核告诉用户消息的作用,比如timeout参数,输入时,代表用户告知内核select监视等待fd时的方式,nullptr代表select阻塞等待fd就绪,当有fd就绪时,select才会返回,传0代表非阻塞等待fd就绪,即select只会遍历检测一遍底层的fd,不管有没有fd就绪,select都会返回,传大于0的值,代表在该时间范围内select阻塞等待,超出该时间select直接非阻塞返回。假设你输入的timeout参数值为5s,如果在第3时select检测到有fd就绪并且返回时,内核会在select调用内部将timeout的值修改为2s,这就是输出型参数的作用,内核告知用户,timeout值为2s,select等待的时间为3s。
select返回值有三种含义,大于0代表就绪的文件描述符个数,等于0代表select超时返回,小于0代表select真的是出错返回了,比如你让select监视一个根本就不存在的fd,那此时select就会出错返回-1

select中间的三个参数也是输入输出型参数,作为用户与内核交流的桥梁,下面是fd_set结构体类型的源码,其实fd_set结构体也好理解,他其实就是一个位图结构,而所谓的位图结构就是用结构体包数组的方式来实现的,__fd_mask其实就是8字节的long int类型,__FD_SETSIZE/__NFDBITS的大小是16,所以这个位图我们可以直接看成大小为16的long int类型的数组,共有16×8×8个bit位,所以select最大也就支持监视1024个fd,这其实也是select的缺点之一,后面我们会总结select的缺点。
fd_set确实就是一个位图,但这个位图结构我们不应该自己去手动向其添加,删除或修改fd,而应该使用内核为我们提供的相应的位操作的接口来进行操作。用户可以通过向这些位操作的接口来告知内核,用户要关心哪些fd上的什么事件,select中间三个参数分别代表了用户要告知内核的信息,readfds是用户告知内核要关心fd上的读事件,writefds是用户告知内核要关心fd上的写事件,exceptfds是用户告知内核要关心fd上的异常事件,而当select调用返回时,fd_set又会作为输出型参数,内核告知用户你所关心的fd中,已经就绪的fd我给你放到fd_set结构体里面了,所以在select调用结束后,用户定义的fd_set结构体中的内容会被内核修改,从用户定义的需要内核关心的fd 修改为 就绪的fd事件。这就是输入输出型参数的作用,作为桥梁来达到用户与内核进行互相交流的目的。

让我们来和历史碰个面,当时在学linux信号的时候,我们所说的三张表中的blocked表其实就是位图结构,当时我们也是用操作系统提供的接口来对blocked表进行操作的,所以这里的fd_set位图与当时我们所学的信号集是恰恰相似的。

2.select服务器代码编写

服务器暴露给外面的接口是initServer( )和start( ),用于初始化服务器和启动服务器,在start中,服务器就要开始accept拿取完成三次握手的全连接了,但服务器能直接accept拿取连接吗?服务器怎么确定当前内核监听队列中一定有连接就绪呢?只有select才能监视listensock上的读事件是否就绪,所以我们要先把listensock上的读事件添加到fd_set位图结构中,让内核帮我们关心listensock上的读事件,等listensock上的读事件就绪之后,服务器再调用accept拿取连接,此时accept就不会阻塞等待,而是直接拿取就绪好的连接。
上面的工作我们可以根据select的返回值来进行,当返回值大于0时,直接调用HandlerReadEvent接口处理rfds位图中就绪的读事件


2.
在HandlerReadEvent中,要处理的读事件可能有两种情况,一种是listensock上的读事件,一种是普通sock上的读事件,所以要通过rfds位图里面的有效的bit位来判断是哪一种读事件,但其实这里就会有一个问题产生,既然fd_set中可能有很多的fd存在,那服务器要处理的事件就会很多,accept拿取连接之后,得到一个通信的sock,这个通信的sock能直接进行recv读取数据吗?当然也不能,这个sock必须也要交给select监视,等到sock就绪的时候,才能够recv读取数据,但HandlerReadEvent如何告知select,你不仅要帮我关心listensock,还要帮我关心通信使用的sock呢?
总不能通过输出型参数来添加到rfds位图吧,select最恶心的地方是,内核会修改rfds位图,如果你要通过输出型参数的方式来将关心的fd添加到rfds中,则每次select调用返回后,你需要记录下来你所关心的所有fd,然后在下一次select调用之前,将这些所有的关心的fd再重新放到rfds里面,重点是你需要记录下来所有的fd,像listensock作为服务器的私有成员变量,这个记录下来并不麻烦,但如果后期accept100多个连接呢?难道定义100多个sock,把所有sock记录下来,然后再逐个添加到fd_set中吗?这样未免也太麻烦了吧!
所以在select这里,需要借助一个第三方数组fd_array来存储用户需要关心的fd,在每次调用select前将这个数组中合法的fd全部添加到fd_set中,然后让select帮我们去关心。我们添加第三方数组来存储fd,主要还是因为select接口中位图参数rfds是输入输出型参数,每次select接口返回时,内核都会修改rfds中的值,所以这就导致下一次调用select前,我们需要重新设置rfds位图中关心的fd,这也是select接口的缺点之一。

下面是服务器处理listensock上的读事件时,Accepter接口的实现,今天我们的服务器就不处理写事件了,等后面写Reactor网络库的时候,到时候我们把所有的事件都处理一遍,今天我们就只处理读事件。
在accept拿取到通信的sock后,我们要把通信的sock添加到fd_array中,在下一次调用select的时候,让内核帮我们关心sock上的读事件。添加的时候,这里其实也有坑,由于fd_set位图最大只能支持1024个bit位,所以select能够同时监视的fd是有上限的,在添加sock到fd_array的循环内部,我们要找出空余的bit位,然后将sock添加到这个bit位里面,此时跳出循环就有两种情况,一种是fd_set真的满了,另一种是fd_set有空余的位置。
添加完成之后,当执行流重新回到start里面时,执行select前,会将fd_array中合法的fd全部添加到rfds位图中。

4.
下面是处理通信sock时的接口Recver,进入Recver后sock确实就绪了,但直接上来就recv读取数据,其实还是有问题的,最典型的问题就是黏包问题,你怎么保证你一次就能够读取上来一个完整的报文呢?如果你循环读取,又如何保证后面调用的recv不会阻塞呢?其实这个不用保证,这个问题算是比较多虑的,因为只要进入了Recver接口,这就能够保证sock底层是有数据的,如果一次不能读到一个完整的报文,那就可以再读第二次,第三次......,直到读取上来的数据能够解析出一个完整的报文,读取出一个完整的报文后,我们要对报文进行反序列化,将字节流式的数据进行解析,得到结构化的数据,当然这些就是应用层的处理工作了,我们不详谈。
当recv读到0,说明写端把sock关闭了,则服务器也应该关闭套接字,同时将fd_array中的套接字置为无效,也就是置为-1,今天我们服务器的应用层处理工作非常简单,其实就是将客户端发送过来的消息反回去即可,func是main中传到select_server类的一个回调方法,用于业务逻辑处理客户端发送过来的信息,处理完之后,直接调用send将响应发回给客户端,同样这里也是有问题的,你怎么保证send一定能够发送数据呢?sock的写事件就绪,你send是不知道的啊,但今天我们先不管,因为大概率写事件是直接就绪的,因为服务器前面又没有发送过什么东西,发送缓冲区大概率是有空间的。

下面是select_服务器的完整代码,其实想要实现这个服务器还是很简单的,需要注意的点就是select得借助第三方数组fd_array来保存用户关心的fd,每一次调用select之前都需要重新将fd_array中用户关心的fd设置到select的参数中。
其他一些还需要注意的点就是处理读事件时,listensock和通信的sock他们之间的处理逻辑是不同的,应该由不同的模块去完成他们的逻辑,就比如代码中的Accepter和Recver将两个sock的事件分开处理。

下面是封装的一些socket编程接口,封装后用起来更简洁一些,代码可读性更好

3.select服务器的缺点

select并不是多路转接中好的一个方案,当然这并不代表他是有问题的,只不过他用起来成本较高,要关注的点也比较多,所以我们说他并不是一个好的方案。

同时select也有缺点,比如select监视的fd是有上限的,我的云服务器内核版本下最大上限是1024个fd,主要还是因为fd_set他是一个固定大小的位图结构,位图中的数组开辟之后不会在变化了,这是内核的数据结构,除非你修改内核参数,否则不会在变化了,所以一旦select监视的fd数量超过1024,则select会报错。
除此之外,select大部分的参数都是输入输出型参数,用户和内核都会不断的修改这些参数的值,导致每次调用select前,都需要重新设置fd_set位图中的内容,这在用户层面上会带来很多不必要的遍历+拷贝的成本。
同时select还需要借助第三方数组来维护用户需要关心的fd,这也是select使用不方便的一种体现。而上面的这些问题,正是其他多路转接接口所存在的意义,poll解决了很多select接口存在的问题。

四、poll_server

1.poll系统调用详解

poll接口主要解决了select接口的两个问题,一个是select监视的fd有上限,另一个是select每次调用前都需要借助第三方数组,向fd_set里面重新设置关心的fd。
poll的第一个参数是结构体数组的地址,数组中的每个元素都是struct pollfd结构体,该结构体内部包含三个字段,fd表示用户告知内核需要关心的fd,events表示用户告知内核需要关心fd上面的什么事件,revents表示内核告知用户,你所关心的fd上面的revents已经就绪了,poll的第二个参数是nfds,表示该结构体数组的大小,nfds_t其实是unsigned long int类型的重定义,在64位系统下是8字节的大小,所以nfds这个数的最大值为2^64次方大小,也就是42亿×42亿,最大是18446744073709600000,在计算机中不可能存在这么多的文件描述符,所以虽然在数学意义上结构体数组fds也是有上限的,但在计算机中,这个上限是没有任何意义的,因为不可能存在这么多的文件描述符,所以我们就认为poll解决了select监视的fd有上限的问题。
同时结构体中fd和events字段是输入型参数,revents是输出型参数,用户告知内核要关心的fd,内核告知用户已经就绪的fd,在poll中是解耦开来的,不像在select中这两件事都是通过输入输出型参数来完成的,耦合在了一起,所以poll是不需要借助第三方数组的,直接向结构体数组中添加结构体即可,无须每次在调用poll前进行重新设置,因为poll对输入和输出进行了解耦分离。

下面是events和revents的取值,最常用的就是POLLIN和POLLOUT,分别代表关心fd上的读事件和写事件,POLLRDBAND表示关心fd上的带外数据。这些大写的值其实都是宏,这些宏会赋值给2字节short类型的events和revents

poll的返回值含义与select相同,大于0表示就绪的fd个数,等于0代表超时返回,小于0代表出错返回,timeout代表poll监视fd时的策略,大于0代表该数值范围内阻塞式监视,超过该数值则直接非阻塞返回,小于0代表一直阻塞式监视,直到某些fd就绪,等于0代表非阻塞监视,poll会遍历一遍用户关心的fd,无论是否有fd就绪,poll都会直接返回。poll接口的timeout参数和select接口不太一样,select接口的timeout参数是输入输出型参数,而poll接口的timeout参数是纯输入型参数,只有用户会对timeout做出修改,内核并不会。

2.poll服务器代码编写

下面将刚刚的select服务器代码用poll接口来改写实现一下。
poll服务器和刚刚的select服务器非常的相似,只不过刚刚的select成员变量有一个fd_array用于存储用户关心的fd,而现在的poll服务器用了一个_rfds变量,用于存储结构体数组的起始地址,这个结构体指针就是传给poll接口的第一个参数。
pollServer.hpp主要的接口和select一样,只是把接口里面select部分替换成了poll接口的使用,在初始化服务器时,需要开辟一个结构体数组,这个数组开辟在堆上,这个数组其实比较标准的写法是搞成扩容版本的,也就是vector,但今天为了简便,我们就搞成固定大小的数组。开辟完数组之后,先将数组的每个结构体的内容做一下重新设置,也就是初始化,将fd设置为-1,events和revents设置为0,代表无事件。
初始化完数组之后,我们可以直接添加listensock的读事件到结构体数组的第一个位置上,因为服务器首先需要关心的一定是listensock的读事件,所以我们就可以这么做,至此服务器的初始化工作就顺利完成。


2.
下面是服务器的运行逻辑,这个运行逻辑写起来可比select简单多了,因为poll接口不需要在每次调用前重新设置fd到结构体中,所以写起来非常的简洁,当poll的返回值大于0时,说明有事件就绪了,那我们就执行HandlerReadEvent( )接口。
在HandlerReadEvent( )中,需要遍历整个结构体数组,而结构体数组的大小在初始化时就确定了,也就是num大小,num是一个全局静态的常量大小我初始设置为2048,所以HandlerReadEvent中要遍历整个结构体数组,看哪个结构体中的revents值已经被内核设置了,如果被设置,那就说明该结构体是就绪的,HandlerReadEvent就应该处理这个结构体上已经就绪的事件。今天pollServer和selectServer一样,都只处理读事件,所以我们可以在for循环内部先判断fd是否合法,然后在判断events是否被设置为POLLIN,如果没有被设置,说明这个fd并不需要被处理,再走到下面的分支语句,其实就是两种情况的判断了,一种是listensock上的读事件处理,另一种就是通信sock上的读事件处理,这两种事件和selectServer一样,分别交给Accepter和Recver来处理。

Accepter中可以直接调用accept系统调用,获取上来用于IO的sock,因为此时listensock上的读事件一定是就绪的,在获得sock之后,下一步要做的工作就是把sock交给poll接口进行监视,监视sock上面的读事件,而托管给poll监视的本质,其实就是将sock放到结构体数组_rfds中,所以只需要遍历_rfds,找出空闲的结构体位置,然后将sock和POLLIN事件设置到结构体中即可,跳出循环同样也有两种情况,一种是结构体数组没有空余的位置了,但其实这种情况一般不会存在,只要将数组设置为柔性数组可扩容即可,另一种情况就非常的简单了,填充struct pollfd的三个字段值就可以了,这样就完成了服务器的IO代码模块儿。

4.
pollserver的调用逻辑和select一模一样,没什么好说的,代码很好理解,就是用智能指针来将服务器对象的生命周期和指针的生命周期进行绑定,用RAII的方式来防止可能产生的内存泄露,只要智能指针销毁,则服务器对象资源也会跟着被销毁。

下面是完整的代码

3.poll所存在的缺点

其实poll的优点就是解决了select支持的fd有上限,以及用户输入信息和内核输出信息耦合的两个问题。
但poll的缺点其实在上面的代码已经体现出来了一部分,内核在检测fd是否就绪时,需要遍历整个结构体数组检测events的值,同样用户在处理就绪的fd事件时,也需要遍历整个结构体数组检测revents的值,当rfds结构体数组越来越大时,每次遍历数组其实就会降低服务器的效率,为此,内核提供了epoll接口来解决这样的问题。
与select相同的是,poll也需要用户自己维护一个第三方数组来存储用户需要关心的fd及事件,只不过poll不需要在每次调用前都重新设置关心的fd,因为用户的输入和内核的输出是分离的,分别在结构体的events和revents的两个字段,做到了输入和输出分离。

五、epoll_server

1.epoll系统调用详解

epoll是公认的最高效的多路转接接口,man手册中描述epoll是为了处理大量的句柄而作了改进的poll,也就是extendPoll扩展性的poll,我们上面说到过,当大量的句柄到来时,poll会由于频繁的遍历所有的句柄而导致效率降低,epoll的出现就解决了这样的问题。
虽然说epoll是作了改进的poll,但在接口的使用和底层实现上,epoll和poll天差地别,在linux内核2.5.44版本时,就引入了epoll接口,而现在主流的linux内核版本已经是3点几了。


2.
epoll_create会在内核帮我们创建一个epoll模型,这个epoll模型非常的重要,可以帮助我们理解epoll高效的原因,以及他工作的机制,所谓的epoll模型其实也是一个struct file结构体,所以epoll_create创建epoll模型成功后,会返回一个文件描述符,而epoll_create的size参数早在内核版本2.6以后就已经被忽略了,在早期的linux内核版本中,该参数指定的是epoll模型创建时,内核数据结构的初始大小,但现在这个参数已经没什么用了,因为内核会根据用户的需要,自动调整epoll模型的大小,epoll模型其实主要是红黑树+就绪队列+底层的回调机制,这些都是内核数据结构。
虽然size没什么用,但在给epoll_create传参的时候,该参数必须大于0,我们随便传个128,256即可。

epoll_ctl的第一个参数就是epoll_create的返回值,也就是epoll模型的文件描述符,第二个参数代表你想使用epoll_ctl的什么功能,例如添加fd关心的事件,修改fd关心的事件,删除fd关心的事件,可以传宏EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL来表示使用epoll_ctl的什么功能,第三个参数是用户要关心的fd,第四个参数是一个结构体,其中包含了两个字段,一个是32位大小的events,表示用户告知内核要关心fd上的什么事件,这些事件所对应的宏如右图源码所示,每个宏的32个bit位只有一个为1,其他全为0,最常用的宏还是EPOLLIN和EPOLLOUT,另一个字段是一个联合体data,这个联合体中有一个重要的字段就是fd,同样也是告诉内核要关心的fd是什么。
epoll_ctl返回0代表接口调用成功,返回-1代表接口调用失败。

第三个接口是epoll_wait,第一个参数也是epoll_create的返回值,epoll模型的文件描述符,第二个参数是纯输出型参数,内核会将就绪的struct epoll_event结构体依次放入到这个数组中,第三个参数代表用户传入的events结构体数组的大小,timeout代表epoll_wait监视fd时的监视策略,和poll的接口一样,这里就不过多赘述了。
epoll_wait的返回值表示就绪的struct epoll_event结构体的数量。

2.epoll模型的底层原理

2.1 软硬件交互时,数据流动的整个过程

数据从软件内存中拷贝到硬件外设,这个过程其实是比较好理解的,因为数据可以贯穿协议栈,层层向下封装报头,最后由硬件对应的驱动程序将数据包交付给具体的硬件,协议栈的最底层就是物理层。
但数据到来时,操作系统是怎么知道网络中有数据到来了呢?这个我们之前从来没有学过,因为这属于计组的知识,我们搞软件的学习他,其实只是为了理解数据在IO流动时的整个过程。
当数据到达网卡时,网卡有相应的8259中断设备,该设备用于向CPU的某个针脚发送中断信号,CPU有很多的针脚,一部分的针脚会对应一个硬件的中断设备,当CPU的针脚收到来自网卡中断设备的中断信号时,该针脚就会被点亮,触发高电平信号,该针脚对应的寄存器(CPU的工作台)里面会将该点亮的针脚解释为二进制序列,这个二进制序列就是该针脚对应的序号。
接下来CPU处理器就会根据这个序号,查询一个叫做中断向量表的数据结构,中断向量表在CPU启动的时候就已经被加载到了内存的特定位置,中断向量表可以理解为一个数组结构,存储着每个中断序号所对应的处理程序的入口地址,其实就是函数指针,而该函数内部会回调网卡的驱动方法,将数据从硬件网卡拷贝到内存中的操作系统代码内部。(上面一整套的逻辑过程,全部都由操作系统来实现)
至此就完成了数据从硬件到软件内存流动的过程,数据到达操作系统内部后,接下来的工作大家也很清楚,就是向上贯穿协议栈,拆分报头和有效载荷,直到最后交给应用层,软件里面的数据流动我们当然是很熟悉的。

计算机的硬件中,不仅仅只有网卡有终端设备,像比较常见的硬件键盘,也有他自己的中断设备,我们在键盘上的每一次按键其实就会触发一次硬件中断。还有就是定时器模块,他也有自己的中断设备,可以在计算机整体的层面上,对内核进程进行管理和调度。

2.2 epoll模型内核结构图

当你调用epoll_create时,内核会在底层创建一个epoll模型,该epoll模型主要由三个部分组成,红黑树+就绪队列+底层的回调机制。
红黑树中的每个节点其实就是一个struct epoll_event结构体,当上层在调用epoll_ctl进行添加fd关心的事件时,其实就是向红黑树中插入节点,所以epoll_ctl对于fd关心事件的增删改,本质其实就是对内核中创建出来的红黑树进行节点的增删改,所以用户告知内核,你要帮我关心什么fd,底层就是对红黑树进行管理。
就绪队列中存放的是已经就绪的struct epoll_event结构体,内核告知用户哪些fd上的事件就绪时,其实就是把就绪队列中的每个节点依次拷贝到用户调用epoll_wait时,传入的纯输出型参数结构体数组events中。就绪队列是一个双向链表Doubly Linked List。
所以所谓的事件就绪的本质,其实就是将红黑树中的节点链入到就绪队列中,链入的过程其实也很简单,只要在红黑树节点内部多增加一个链表节点类型的指针即可,这个指针可以先初始化为nullptr,当该节点中fd关心的事件就绪时,再将这个指针指向就绪队列中的尾部节点即可。
一个节点是可以同时在多个数据结构当中的,做法很简单,只要增加数据结构中元素类型的指针即可,通过修改指针的指向就可以把节点链入到新的数据结构里,在逻辑上我们把就绪队列和红黑树分开了,但在代码实现上,只需要在struct epoll_event结构体内部增加指针就可以了,让一个结构体同时在就绪队列和红黑树中。

我们已经知道epoll模型的大概原理了,但还有一个问题,操作系统怎么知道红黑树上的哪些节点就绪了呢?难道操作系统也要遍历整棵红黑树,检测每个节点的就绪情况?操作系统其实并不会这样做,如果这样做的话,那epoll还谈论什么高效呢?你epoll不也得遍历所有的fd吗?和我poll遍历有什么区别呢?红黑树是查找的效率高,不是遍历的效率高,如果遍历所有的节点,红黑树其实和链表遍历在效率上是差不多的,一点都不高效!
那操作系统是怎么知道红黑树上的哪个节点就绪了呢?其实是通过底层的回调机制来实现的,这也是epoll接口公认非常高效的重要的一个实现环节!
当数据到达网卡时,我们知道数据会经过硬件中断,CPU执行中断向量表等步骤来让数据到达内存中的操作系统内部,在OS内贯穿网络协议栈时,在传输层数据会被拷贝到struct file结构体中的receive_queue接收缓冲区中,这个struct file结构体对应的文件描述符,其实就是accept上来的用于通信的sockfd,在这个结构体内部有一个非常重要的字段private_data,该指针会指向一个回调函数,这个回调函数就会把该sock对应的struct epoll_event结构体链入到就绪队列中,因为此时数据已经拷贝到内核的socket接收缓冲区了,事件已经就绪了,所以当内核在拷贝数据的同时,还会调用private_data回调方法,将该sock对应的红黑树节点链入到就绪队列中,所以操作系统根本不用遍历什么红黑树来检测哪些节点是否就绪,当数据到来时,底层的回调机制会自动将就绪的红黑树节点链入到就绪队列里。

3
.总结一下fd事件就绪时,底层的工作流程。
当数据到达网络设备网卡时,会以硬件中断作为发起点,将中断信号通过中断设备发送到CPU的针脚,接下来CPU会查讯中断向量表,找到中断序号对应的驱动回调方法,在回调方法内部会将数据从硬件设备网卡拷贝到软件OS里。数据包在OS中会向上贯穿协议栈,到达传输层时,数据会被拷贝到struct file的内核缓冲区中,同时OS会执行一个叫做private_data的回调函数指针字段,在该回调函数内部会通过修改红黑树节点中的就绪队列指针的内容,将该节点链入到就绪队列,内核告知用户哪些fd就绪时,只需要将就绪队列中的节点内容拷贝到epoll_wait的输出型参数events即可,这就是epoll模型的底层回调机制!

下面我稍微模拟了一下private_data指针回调的方式,可以用该指针存储一个函数指针,在回调时,只需要先将指针的类型从void类型转换为函数指针的类型,然后再调用即可。
而所谓的epoll模型其实就是红黑树+就绪队列+底层的回调机制。

2.3 关于epoll模型所产生的问题

1.为什么说epoll模型是高效的呢?

因为大部分的工作操作系统都帮我们做了,比如添加节点到红黑树,我们只需要调用epoll_ctrl即可,返回就绪的fd,直接相当于返回就绪队列中的节点即可,上层直接就可以拿到就绪的fd,检测是否就绪的工作也不用遍历,而是当底层数据就绪时,会有回调机制自动将红黑树的节点链入到就绪队列中,操作系统也无须遍历红黑树进行就绪检测,上层在拿到就绪的fd后,可以确定范围的遍历输出型参数struct epoll_event数组,而不是盲目的遍历整个数组的所有元素。

2.为什么选用红黑树作为epoll模型的底层数据结构?

因为红黑树的搜索效率非常的高,可以达到logN的时间复杂度,所以无论是epoll_ctl的插入,删除还是修改,这些工作的首要前提是先找到目标节点或目标位置,找到之后,再进行具体的操作,而找到这一步红黑树的效率就非常的高。

有人可能会说红黑树需要旋转调整平衡啊,虽然在逻辑上我们感觉红黑树的旋转调平衡很费时间,可能会造成红黑树的效率降低,但其实并不是这样的,所谓的旋转调平衡只是在逻辑上复杂而已,在实际操作上仅仅只是修改节点内的指针而已,对红黑树的效率影响并不大。

同时红黑树对于平衡的要求并没有AVL高,所以在旋转调平衡的次数上,红黑树要比AVL树少很多,在整体效率上是要比AVL树高的,这也是使用红黑树,不使用AVL树的原因。

3.epoll_wait有哪些细节?

(1)epoll_wait会将所有就绪的fd,依次按照顺序放到输出型参数events中,用户在遍历数组处理就绪的事件时,无须遍历多余的任何一个fd,只需要遍历从0到epoll_wait的返回值个fd即可。

(2)如果就绪队列的节点数量很多,epoll_wait的输出型参数数组一次拿不完也不用担心,因为队列是先进先出,下一次在调用epoll_wait时,再拿就绪的事件也可以。

(3)select poll在使用的时候,都需要程序员自己维护一个第三方数组来存储用户关心的fd及事件,但epoll不需要,因为内核为epoll在底层维护了一棵红黑树,用户直接通过epoll_ctl来对红黑树的节点进行增删改即可,无须自己在应用层维护第三方的数组。

3.epoll服务器代码编写

初始化模块实现起来也非常的简单,先正常进行服务器的socket创建,bind绑定,listen监听,接下来就是创建epoll模型,创建成功之后,将listensock的读事件添加到epoll模型的红黑树中,添加的方式也很简单,只要定义一个struct epoll_event结构体,将其中的events和data.fd字段填充好,调用epoll_ctl即可将listensock及其事件添加到红黑树中。下一步就是申请epoll_wait的输出型参数的空间,只需要new一下即可。

服务器start的逻辑也很简单,先调用epoll_wait进行fd的监视,当返回值大于0时,调用接口HandlerEvent进行时间的处理,由于今天只处理读事件,所以只需要两个分支语句就可以实现,HandlerEvent的参数为readyNum,表示就绪的事件个数,遍历_revs数组时,只需要遍历0-readyNum个结构体即可,不用遍历任何一个多余的fd。
然后在accept的分支语句中,只需要将就绪的连接拿上来即可,然后把sock设置到红黑树即可,等待下一次就绪时recv sock上的数据。
在recv的分支语句中,只读取一次的话,其实和前面的两个服务器代码一样,还是存在黏包问题的,下一篇文章Reactor会解决所有的问题。需要提醒一下的是,建议先从红黑树中移除节点,然后再关闭sock,如果你先关闭了sock,则fd就会变为无效,如果此时调用epoll_ctl移除节点,传入的参数sock就是无效的,则此时epoll_ctl会报错!
(epoll_server写起来是不很简单呢?因为越高效的接口,需要程序员做的事情就会越少,内核做的事情会越多,代码编写的成本就会低一些。)

下面是完整的epoll_server代码

下面是服务器的调用逻辑,和之前的select poll没有什么区别,还是很简单的。

4.总结select poll epoll的优缺点

select缺点:

(1)支持的文件描述符有上限,我的内核版本下最大是1024

(2)需要程序员自己维护一个第三方数组来存储用户关心的fd及事件

(3)由于输入和输出耦合,导致每次调用select前都需要向select重新设置关心的fd及事件

(4)用户需要每次遍历整个fd_set位图,来判断哪个就绪的fd需要处理,如果你有一个非常大的文件描述符集合,即使只有一个文件描述符就绪,你也需要检查所有的位。内核也需要每次遍历fd_set位图,来判断哪个fd就绪。用户与内核大量的遍历fd_set集合会带来效率的降低。
select优点:

(1)能够同时监听多个文件描述符,使得一个进程或线程能够同时管理多个IO操作,提升IO的效率

(2)select 是一个跨平台的系统调用,几乎在所有主流操作系统上都得到支持,包括 Linux、Unix、Windows 等


poll缺点:

(1)需要程序员自己维护一个第三方结构体数组来存储用户关心的fd及事件

(2)与select相同的是,用户仍然需要遍历整个数组来找出就绪的文件描述符,哪怕只有一个结构体的revents是就绪的,来判断哪个是就绪的fd从而进行处理。内核也需要每次遍历结构体数组,来判断哪个fd是就绪的。用户与内核大量的遍历集合会带来效率的降低。

poll优点:

(1)一个进程最多能打开多少fd,poll就能最多同时监视多少fd(数学上限为2^64个)

(2)不需要每次在调用poll之前重新设置关心的fd及事件。

(3)poll跨平台移植性差


epoll缺点:

(1)epoll不适用于小规模的连接,因为epoll需要维护很多的内核数据结构,更适用于高并发大规模的IO操作,小规模的连接会由于epoll维护复杂的数据结构和回调机制等,从而给系统带来不必要的开销

(2) epoll跨平台移植性差

epoll优点:

(1)一个进程最多能打开多少fd,epoll就能最多同时监视多少fd.(数学上限为2^32个)

(2)不需要程序员自己维护第三方数组来存储用户关心的fd及事件,因为内核会为epoll创建一棵红黑树,直接向红黑树进行节点的增删改即可。

(3)用户同样也需要遍历结构体数组,因为epoll_wait会将就绪的fd依次有顺序的放到用户传入的结构体数组events中,所以用户是可以按需遍历的。但内核不需要遍历整棵红黑树来检测哪些节点上的fd就绪了,因为epoll模型有他自己的底层回调机制,大大减少内核遍历集合所带来的性能开销,从而提高了效率。

(4)不需要每次在调用epoll前重新设置关心的fd及事件。

相关推荐
Kika写代码18 分钟前
【微信小程序】页面跳转基础 | 我的咖啡店-综合实训
服务器·微信小程序·小程序
PieroPc19 分钟前
Python 自动化 打开网站 填表登陆 例子
运维·python·自动化
Aileen_0v023 分钟前
【AI驱动的数据结构:包装类的艺术与科学】
linux·数据结构·人工智能·笔记·网络协议·tcp/ip·whisper
州周1 小时前
Ftp目录整个下载
linux·服务器·数据库
Jackey_Song_Odd1 小时前
Ubuntu 24.04.1 解决部分中文字符(门、径)显示错误的问题
linux·ubuntu
kaixin_learn_qt_ing1 小时前
Linux export命令
linux
余额不足121381 小时前
C语言基础十六:枚举、c语言中文件的读写操作
linux·c语言·算法
冷曦_sole1 小时前
linux-19 根文件系统(一)
linux·运维·服务器
AI大模型学徒1 小时前
Linux(二)_清理空间
linux·运维·服务器
花鱼白羊1 小时前
TCP Vegas拥塞控制算法——baseRtt 和 minRtt的区别
服务器·网络协议·tcp/ip