小学校园门户网站建设方案,网站色彩学,网站源码运行,产品图册用什么软件做所有通过捷径所获取的快乐#xff0c;无论是金钱、性还是名望#xff0c;最终都会给自己带来痛苦 文章目录 一、五种IO模型1.什么是高效的IO#xff1f;#xff08;降低等待的时间比重#xff09;2.有哪些IO模型#xff1f;哪些模型是高效的#xff1f;3.五种IO模型的特…所有通过捷径所获取的快乐无论是金钱、性还是名望最终都会给自己带来痛苦 文章目录 一、五种IO模型1.什么是高效的IO降低等待的时间比重2.有哪些IO模型哪些模型是高效的3.五种IO模型的特性差别 二、阻塞与非阻塞IO三、select_server1.select系统调用详解2.select服务器代码编写3.select服务器的缺点 四、poll_server1.poll系统调用详解2.poll服务器代码编写3.poll所存在的缺点 五、epoll_server1.epoll系统调用详解2.epoll模型的底层原理2.1 软硬件交互时数据流动的整个过程2.2 epoll模型内核结构图2.3 关于epoll模型所产生的问题 3.epoll服务器代码编写4.总结select poll epoll的优缺点 一、五种IO模型
1.什么是高效的IO降低等待的时间比重
1. 后端服务器最常用的网络IO设计模式其实就是Reactor也称为反应堆模式Reactor是单进程单线程的但他能够处理多客户端向服务器发起的网络IO请求正因为他是单执行流所以他的成本就不高CPU和内存这样的资源占用率就会低降低服务器性能的开销提高服务器性能。 而多进程多线程方案的服务器缺点相比于Reactor就很明显了在高并发的场景下服务器会面临着大量的连接请求每个线程都需要自己的内存空间堆栈自己的内核数据结构所以大量的线程所造成的资源消耗会降低服务器的性能多线程还会进行线程的上下文切换也就是执行流级别的切换每一次切换都需要保存和恢复线程的上下文信息这会消耗CPU的时间频繁的上下文切换也会降低服务器的性能。前面的这些问题都是针对于服务器来说的对于程序员来说多执行流的服务器最恶心的就是调试和找bug了所以多执行流的服务器生态比较差排查问题更加的困难服务器不好维护同时由于多执行流可能同时访问临界资源所以服务器的安全性也比较低可能产生资源竞争数据损坏等问题。
2. 谈完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中已经堆满了数据没有剩余空间了呢 如果这些情况都存在的话recvsend这样的接口会怎么做呢答案是这些接口一定会等会等条件就绪的时候再进行数据拷贝recv会等sk_buff中有数据send会等sk_buff中有剩余空间等到条件就绪的时候这些IO接口才会进行数据拷贝。所以我们今天要重新定义IOIO不仅仅是数据拷贝同时还需要进行等待等待条件就绪时这些接口才会进行数据拷贝所以IO等数据拷贝
4. 其实我们是遇到过等这样的情况的以前在讲进程间通信的时候管道通信时我们就遇到过等待的情况例如写端不向管道中写数据此时读端就会阻塞阻塞的本质其实就是在进行等待等待写端向管道中写数据所以读取数据的IO接口在进行等待的情况是比较常见的但写数据时是不常见的因为大多数情况下写事件是直接就绪的因为内核发送缓冲区中常常是有剩余空间的TCP有自己的滑动窗口发送数据的策略基本上写事件不就绪的情况很少见但读事件不就绪还是很常见的尤其是在网络环境下进行读取数据因为数据包在send调用后数据包会被发送到内核的sk_buff中什么时候发送怎么发送这些策略是由TCP来提供的数据包在发送时会经历延迟应答查询路由表确定下一跳路径在局域网中进行转发数据包这些过程都是需要时间的此时对端调用recv接收数据时在这些时间窗口内recv不就得等吗
5. 而所谓高效的IO其实就是降低等待的时间比重因为数据拷贝的时间比重基本上是确定的他由硬件结构操作系统优化编译器优化等条件所决定或者可以提高带宽一次性拷贝较多的数据但这些的做法其实都是固定的对数据拷贝的效率提升并不大而影响IO效率最大的因素其实就是等待只要IO模型等待的时间比重很低那么这个IO我们就称他是高效的。
2.有哪些IO模型哪些模型是高效的
1. IO模型分为五种分别是阻塞式IO非阻塞IO信号驱动IO多路转接IO异步IO。下面我们讲一个例子先来浅浅谈一下这5个模型IO的做法。 从前有一条小河河里有许多条鱼一个叫张三的少年就很喜欢钓鱼他带着自己的鱼竿就去钓鱼了但张三这个人很固执只要鱼没上钩张三就一直等着什么都不干死死的盯着鱼漂只有鱼漂动了张三才会动然后把鱼钓上来钓上来之后张三就又会重复之前的动作一动不动的等待鱼儿上钩。而此时走过来一个李四李四这名少年也很喜欢钓鱼但李四和张三不一样李四左口袋装着《Linux高性能服务器编程》右口袋装着一本《算法导论》左手拿手机右手拿了一根鱼竿李四拿了钓鱼凳坐下之后李四就开始钓鱼了但李四不像张三一样固执的死盯着鱼漂看李四一会看会儿左口袋的书一会玩会手机一会儿又看算法导论一会又看鱼漂所以李四一直循环着前面的动作直到循环到看鱼漂时发现鱼漂已经动了好长时间了此时李四就会把鱼儿钓上来之后继续重复循环前面的动作。此时又来了一个王五少年王五就拿着他自己的iphone14pro max和一根鱼竿外加一个铃铛然后就来钓鱼了王五把铃铛挂到鱼竿上等鱼上钩的时候铃铛就会响王五根本不看鱼竿就一直玩自己的iphone等鱼上钩的时候铃铛会自动响王五此时再把鱼儿钓上来就好了之后王五又继续重复前面的动作只要铃铛不响王五就一直玩手机只有铃铛响了王五才会把鱼钓上来。此时又来了一个赵六的人赵六和前面的三个人都不一样赵六是个小土豪赵六手里拿了一堆鱼竿目测有几百根鱼竿赵六到达河边首先就把几百根鱼竿每隔几米插上去总共插了好几百米的鱼竿然后赵六就依次遍历这些鱼竿哪个鱼竿上的鱼漂动了赵六就把这根鱼竿上的鱼钓上来然后接下来赵六就又继续重复之前遍历鱼竿的动作进行钓鱼了。然后又来了一个钱七钱七比赵六还有钱钱七是上市公司的CEO钱七有自己的司机钱七不喜欢钓鱼但钱七喜欢吃鱼所以钱七就把自己的司机留在了岸边并且给了司机一个电话和一个桶告诉司机等你把鱼钓满一桶的时候就给我打电话然后我就从公司开车过来接你所以钱七就直接开车回公司开什么股东大会去了而他的司机就被留在这里继续钓鱼了。
2. 在上面的例子中你认为谁的钓鱼方式更加高效呢首先我们认为如果一个人在不停的钓鱼时不时的就收鱼竿把鱼钓上来等待鱼儿上钩的时间比重却很低那么这个人在我看来他的钓鱼方式就是高效的。而如果一个人大部分的时间都是在等待只有那么极少数次在收杆把鱼钓上来那么这个人的钓鱼方式就是低效的。 而上面的例子中鱼其实就是数据鱼竿其实就是文件描述符fd每个人都算是进程但除了钱七的司机这个司机算是操作系统河流就是内核缓冲区鱼漂就是就绪的事件代表钓鱼这件事情已经就绪了进程可以对数据做拷贝了。 其实赵六的方式是最高效的也就是多路转接这种IO模型是最高效的因为赵六的鱼竿多啊钓上鱼的几率就大啊其他人只有一根鱼竿只能关心这一根鱼竿上的数据自然就没有赵六的效率高同理为什么渣男的女朋友多啊因为广撒网嘛找到女朋友的概率要比普通的老实人高啊因为人家一次可以关心那么多的微信账号哪个女孩发消息了人家就和谁聊天肯定比你只有一个女孩的微信效率要高。 所以本文章主要来介绍多路转接这种IO模型同时也会讲解阻塞和非阻塞IO需要注意的是实际项目中最常用的就是阻塞IO同时大部分的fd默认就是阻塞的因为这种IO太简单了越简单的东西往往就越可靠代码编写也越简单调试和找bug的难度也就越低这样的代码可维护性很高所以他就越常用。
3.五种IO模型的特性差别
1. 阻塞非阻塞信号驱动在IO效率上是没有差别的因为他们三个人都只有一根鱼竿等待鱼上钩的概率都是一样的相当于他们等待事件就绪的概率是相同的所以从IO效率上来看这三个模型之间是没有差别的。只不过三者等待的方式是不同的阻塞是一直在进行等待而非阻塞可能会使用轮询的方式来进行等待在等待的时间段内非阻塞可能还会做一些其他的事情信号驱动和非阻塞一样在等待的时间段内信号驱动会做一些其他的事情比如监管一下其他的连接是否就绪等等事情。所以从IO的效率角度来讲这三种IO并无差别因为IO的过程分为等待和数据拷贝三者在这个工作上的效率都是一样的只不过非阻塞和信号驱动的等待方式与阻塞IO不同。信号驱动只不过是被动的等待阻塞和非阻塞都是主动的等待当信号到来时信号驱动IO会通过回调的方式来处理就绪的事件。
2. 而多路转接相比前三种IO模型更为高效一些因为他能够一次等待多个文件描述符但这四种IO都有一个共同的特征就是直接参与了IO的过程这样的通信我们称之为同步通信而异步IO是典型的异步通信他将等待数据就绪的事情交给了内核来处理当数据准备好后操作系统会以信号或回调函数的方式来通知进程可以处理数据了因为数据已经准备好了这就是典型的异步通信。
3. 所以以后在看到同步这个词时一定要确定好他的大背景是什么是同步通信还是异步通信这指的是进程的消息通信机制不同前者是同步IO的方式也就是主动等待调用返回的结果后者是异步IO的方式调用是直接返回的后续执行调用的操作系统会以某种方式来通知进程。 或者同步也有可能是线程的同步与互斥线程同步指的是多个线程之间通过条件变量的方式来互相协同工作完成某一件任务。
二、阻塞与非阻塞IO
1. 每一个打开的文件所对应的文件描述符fd默认全都是阻塞的无论是系统文件fd还是网络套接字sock都默认是阻塞的IO方式。 设置fd为非阻塞的方式大概有三种在open打开文件的时候可以携带选项O_NONBLOCK或者在使用网络IO接口例如sendrecv等时可以携带额外的选项MSG_DONTWAIT另一种是最常用的方式就是fcntl系统调用像open那样的方式只适合打开系统文件对于网络套接字就不太适合况且打开文件时还要记住这么多的额外选项对于程序员还是不太友好像fcntl这样的方式无论对于系统文件还是网络套接字都是完全适用的。 2. fcntl函数有5种功能我们这里只使用第三种功能也就是设置文件描述符的状态标记先通过F_GETFL选项获取原有文件描述符的标志位然后再通过F_SETFL选项将原有的标志位与O_NONBLOCK按位或之后再重新设置回文件中。这样就可以将文件描述符设置为非阻塞了。 3. 下面的运行结果就是典型的阻塞式IO当程序运行起来时执行流会在read处阻塞因为read今天读取的是0号文件描述符也就是键盘文件上的数据只要我不从键盘上输入数据的话read就会一直阻塞此时进程会被操作系统挂起直到硬件设备键盘上有数据时进程才会重新投入CPU的运行队列当我们输入数据后可以立马看到进程显示出了echo回应的结果同时进程又立马陷入阻塞等待我进行下一次的输入数据这样的IO方式就是典型的阻塞式同时也是最常用最简单的IO方式。 在这里额外补充一下linux命令行中表示输入结束的快捷键是ctrld当此热键被用户按下后代表0号文件描述符写端关闭此时读端会读到0read会返回0值此时进程除了输出提示信息read file end外还应该加一个break跳出循环结束进程。 4. 下面就是非阻塞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系统调用详解
1. select是我们学习的第一个多路转接IO接口select只负责IO过程中等待的这一步也就是说用户可能关心一些sock上的读事件想要从sock中读取数据直接读取可能recv调用会阻塞等待数据到来而此时服务器进程就会被阻塞挂起但服务器挂起就完蛋了服务器就无法给客户提供服务可能会产生很多无法预料的不好影响万一客户正转账呢服务器突然挂起了客户的钱没了但商家这里又没有收到钱客户找谁说理去啊所以服务器挂起是一个问题我们要避免产生这样的问题。 而select的作用就是帮用户关心sock上的读事件等sock中有数据时select此时会返回告知用户你所关心的sock上的读事件已经就绪了用户你可以调用recv读取sock中的数据了所以多路转接其实是把IO的过程分开来执行了用多路复用接口来监视fd上的事件是否就绪一旦就绪就会立马通知上层让上层调用对应的接口进行数据的处理等待和数据拷贝的工作分开执行这样的IO效率一定是高的因为像select这样的多路转接接口一次能够等待多个fd在返回时它可以把多个fd中所有就绪的fd全部返回并通知给上层。
2. 当程序运行时程序其实会在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值为2sselect等待的时间为3s。 select返回值有三种含义大于0代表就绪的文件描述符个数等于0代表select超时返回小于0代表select真的是出错返回了比如你让select监视一个根本就不存在的fd那此时select就会出错返回-1
3. 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服务器代码编写
1. 服务器暴露给外面的接口是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接口的缺点之一。
3. 下面是服务器处理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是不知道的啊但今天我们先不管因为大概率写事件是直接就绪的因为服务器前面又没有发送过什么东西发送缓冲区大概率是有空间的。 5. 下面是select_服务器的完整代码其实想要实现这个服务器还是很简单的需要注意的点就是select得借助第三方数组fd_array来保存用户关心的fd每一次调用select之前都需要重新将fd_array中用户关心的fd设置到select的参数中。 其他一些还需要注意的点就是处理读事件时listensock和通信的sock他们之间的处理逻辑是不同的应该由不同的模块去完成他们的逻辑就比如代码中的Accepter和Recver将两个sock的事件分开处理。
下面是封装的一些socket编程接口封装后用起来更简洁一些代码可读性更好 3.select服务器的缺点
1. select并不是多路转接中好的一个方案当然这并不代表他是有问题的只不过他用起来成本较高要关注的点也比较多所以我们说他并不是一个好的方案。
2. 同时select也有缺点比如select监视的fd是有上限的我的云服务器内核版本下最大上限是1024个fd主要还是因为fd_set他是一个固定大小的位图结构位图中的数组开辟之后不会在变化了这是内核的数据结构除非你修改内核参数否则不会在变化了所以一旦select监视的fd数量超过1024则select会报错。 除此之外select大部分的参数都是输入输出型参数用户和内核都会不断的修改这些参数的值导致每次调用select前都需要重新设置fd_set位图中的内容这在用户层面上会带来很多不必要的遍历拷贝的成本。 同时select还需要借助第三方数组来维护用户需要关心的fd这也是select使用不方便的一种体现。而上面的这些问题正是其他多路转接接口所存在的意义poll解决了很多select接口存在的问题。 四、poll_server
1.poll系统调用详解
1. poll接口主要解决了select接口的两个问题一个是select监视的fd有上限另一个是select每次调用前都需要借助第三方数组向fd_set里面重新设置关心的fd。 poll的第一个参数是结构体数组的地址数组中的每个元素都是struct pollfd结构体该结构体内部包含三个字段fd表示用户告知内核需要关心的fdevents表示用户告知内核需要关心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对输入和输出进行了解耦分离。 2. 下面是events和revents的取值最常用的就是POLLIN和POLLOUT分别代表关心fd上的读事件和写事件POLLRDBAND表示关心fd上的带外数据。这些大写的值其实都是宏这些宏会赋值给2字节short类型的events和revents 3. 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服务器代码编写
1. 下面将刚刚的select服务器代码用poll接口来改写实现一下。 poll服务器和刚刚的select服务器非常的相似只不过刚刚的select成员变量有一个fd_array用于存储用户关心的fd而现在的poll服务器用了一个_rfds变量用于存储结构体数组的起始地址这个结构体指针就是传给poll接口的第一个参数。 pollServer.hpp主要的接口和select一样只是把接口里面select部分替换成了poll接口的使用在初始化服务器时需要开辟一个结构体数组这个数组开辟在堆上这个数组其实比较标准的写法是搞成扩容版本的也就是vector但今天为了简便我们就搞成固定大小的数组。开辟完数组之后先将数组的每个结构体的内容做一下重新设置也就是初始化将fd设置为-1events和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来处理。 3. Accepter中可以直接调用accept系统调用获取上来用于IO的sock因为此时listensock上的读事件一定是就绪的在获得sock之后下一步要做的工作就是把sock交给poll接口进行监视监视sock上面的读事件而托管给poll监视的本质其实就是将sock放到结构体数组_rfds中所以只需要遍历_rfds找出空闲的结构体位置然后将sock和POLLIN事件设置到结构体中即可跳出循环同样也有两种情况一种是结构体数组没有空余的位置了但其实这种情况一般不会存在只要将数组设置为柔性数组可扩容即可另一种情况就非常的简单了填充struct pollfd的三个字段值就可以了这样就完成了服务器的IO代码模块儿。 4. pollserver的调用逻辑和select一模一样没什么好说的代码很好理解就是用智能指针来将服务器对象的生命周期和指针的生命周期进行绑定用RAII的方式来防止可能产生的内存泄露只要智能指针销毁则服务器对象资源也会跟着被销毁。
下面是完整的代码
3.poll所存在的缺点
1. 其实poll的优点就是解决了select支持的fd有上限以及用户输入信息和内核输出信息耦合的两个问题。 但poll的缺点其实在上面的代码已经体现出来了一部分内核在检测fd是否就绪时需要遍历整个结构体数组检测events的值同样用户在处理就绪的fd事件时也需要遍历整个结构体数组检测revents的值当rfds结构体数组越来越大时每次遍历数组其实就会降低服务器的效率为此内核提供了epoll接口来解决这样的问题。 与select相同的是poll也需要用户自己维护一个第三方数组来存储用户需要关心的fd及事件只不过poll不需要在每次调用前都重新设置关心的fd因为用户的输入和内核的输出是分离的分别在结构体的events和revents的两个字段做到了输入和输出分离。
五、epoll_server
1.epoll系统调用详解
1. 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我们随便传个128256即可。 3. 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代表接口调用失败。 4. 第三个接口是epoll_wait第一个参数也是epoll_create的返回值epoll模型的文件描述符第二个参数是纯输出型参数内核会将就绪的struct epoll_event结构体依次放入到这个数组中第三个参数代表用户传入的events结构体数组的大小timeout代表epoll_wait监视fd时的监视策略和poll的接口一样这里就不过多赘述了。 epoll_wait的返回值表示就绪的struct epoll_event结构体的数量。 2.epoll模型的底层原理
2.1 软硬件交互时数据流动的整个过程
1. 数据从软件内存中拷贝到硬件外设这个过程其实是比较好理解的因为数据可以贯穿协议栈层层向下封装报头最后由硬件对应的驱动程序将数据包交付给具体的硬件协议栈的最底层就是物理层。 但数据到来时操作系统是怎么知道网络中有数据到来了呢这个我们之前从来没有学过因为这属于计组的知识我们搞软件的学习他其实只是为了理解数据在IO流动时的整个过程。 当数据到达网卡时网卡有相应的8259中断设备该设备用于向CPU的某个针脚发送中断信号CPU有很多的针脚一部分的针脚会对应一个硬件的中断设备当CPU的针脚收到来自网卡中断设备的中断信号时该针脚就会被点亮触发高电平信号该针脚对应的寄存器CPU的工作台里面会将该点亮的针脚解释为二进制序列这个二进制序列就是该针脚对应的序号。 接下来CPU处理器就会根据这个序号查询一个叫做中断向量表的数据结构中断向量表在CPU启动的时候就已经被加载到了内存的特定位置中断向量表可以理解为一个数组结构存储着每个中断序号所对应的处理程序的入口地址其实就是函数指针而该函数内部会回调网卡的驱动方法将数据从硬件网卡拷贝到内存中的操作系统代码内部。上面一整套的逻辑过程全部都由操作系统来实现 至此就完成了数据从硬件到软件内存流动的过程数据到达操作系统内部后接下来的工作大家也很清楚就是向上贯穿协议栈拆分报头和有效载荷直到最后交给应用层软件里面的数据流动我们当然是很熟悉的。
2. 计算机的硬件中不仅仅只有网卡有终端设备像比较常见的硬件键盘也有他自己的中断设备我们在键盘上的每一次按键其实就会触发一次硬件中断。还有就是定时器模块他也有自己的中断设备可以在计算机整体的层面上对内核进程进行管理和调度。 2.2 epoll模型内核结构图
1. 当你调用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结构体内部增加指针就可以了让一个结构体同时在就绪队列和红黑树中。
2. 我们已经知道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模型的底层回调机制
4. 下面我稍微模拟了一下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有哪些细节 1epoll_wait会将所有就绪的fd依次按照顺序放到输出型参数events中用户在遍历数组处理就绪的事件时无须遍历多余的任何一个fd只需要遍历从0到epoll_wait的返回值个fd即可。 2如果就绪队列的节点数量很多epoll_wait的输出型参数数组一次拿不完也不用担心因为队列是先进先出下一次在调用epoll_wait时再拿就绪的事件也可以。 3select poll在使用的时候都需要程序员自己维护一个第三方数组来存储用户关心的fd及事件但epoll不需要因为内核为epoll在底层维护了一棵红黑树用户直接通过epoll_ctl来对红黑树的节点进行增删改即可无须自己在应用层维护第三方的数组。
3.epoll服务器代码编写
1. 初始化模块实现起来也非常的简单先正常进行服务器的socket创建bind绑定listen监听接下来就是创建epoll模型创建成功之后将listensock的读事件添加到epoll模型的红黑树中添加的方式也很简单只要定义一个struct epoll_event结构体将其中的events和data.fd字段填充好调用epoll_ctl即可将listensock及其事件添加到红黑树中。下一步就是申请epoll_wait的输出型参数的空间只需要new一下即可。
2. 服务器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的效率 2select 是一个跨平台的系统调用几乎在所有主流操作系统上都得到支持包括 Linux、Unix、Windows 等 poll缺点
1需要程序员自己维护一个第三方结构体数组来存储用户关心的fd及事件 2与select相同的是用户仍然需要遍历整个数组来找出就绪的文件描述符哪怕只有一个结构体的revents是就绪的来判断哪个是就绪的fd从而进行处理。内核也需要每次遍历结构体数组来判断哪个fd是就绪的。用户与内核大量的遍历集合会带来效率的降低。
poll优点 1一个进程最多能打开多少fdpoll就能最多同时监视多少fd数学上限为2^64个 2不需要每次在调用poll之前重新设置关心的fd及事件。 3poll跨平台移植性差 epoll缺点 1epoll不适用于小规模的连接因为epoll需要维护很多的内核数据结构更适用于高并发大规模的IO操作小规模的连接会由于epoll维护复杂的数据结构和回调机制等从而给系统带来不必要的开销 2 epoll跨平台移植性差
epoll优点 1一个进程最多能打开多少fdepoll就能最多同时监视多少fd.数学上限为2^32个 2不需要程序员自己维护第三方数组来存储用户关心的fd及事件因为内核会为epoll创建一棵红黑树直接向红黑树进行节点的增删改即可。 3用户同样也需要遍历结构体数组因为epoll_wait会将就绪的fd依次有顺序的放到用户传入的结构体数组events中所以用户是可以按需遍历的。但内核不需要遍历整棵红黑树来检测哪些节点上的fd就绪了因为epoll模型有他自己的底层回调机制大大减少内核遍历集合所带来的性能开销从而提高了效率。 4不需要每次在调用epoll前重新设置关心的fd及事件。