IO耗时原因
对于传统阻塞式IO,其主要分为两部分:等待资源就绪 + 拷贝,而这其中时耗最高的其实是阻塞等待(占极大头),换言之,只要降低/移除IO的等待时间,就能极大提升IO效率,而下面将介绍5种IO模型,并重点讲解多路转接IO的具体实现方案
五种IO模型
接下来以钓鱼为例介绍五种IO:
- 张三:正常钓鱼,一直盯着鱼竿,当发现有鱼上钩就进行处理(阻塞式IO)
- 李四:一会看看鱼竿是否有鱼,一会看看书,聊聊天(非阻塞式IO)
- 王五:在鱼竿上挂一个铃铛,当有鱼上钩后铃铛会响(信号驱动)
- 赵六:使用100根鱼竿,然后循环监视100根鱼竿中哪个就绪了(多路转接)
- 田七:雇人钓鱼(异步IO)
核心思想如上,总体不难理解,接下来分别对五种IO的注意事项进行介绍
- 阻塞与非阻塞:当条件不就绪时,阻塞IO会阻塞至条件就绪,
非阻塞式则会检测到IO条件不具备,出错返回;注意,非阻塞IO虽然可以更好的利用时间,做更多事,但单纯说IO效率,阻塞和非阻塞都是一致的,除非非阻塞式用其他时间做了其他IO(李四看书聊天不会提高鱼咬钩的概率) - 赵六的钓鱼
(多路转接)效率最高,因为鱼咬赵六钩的概率是100 / 104 - 上述五种方案中只有田七是异步IO,其余均为同步IO-》王五也算同步IO,即
只要有人参与了IO(不管是等还是拷贝),都算是同步IO 目前信号IO用的极少,因为可能出现信号丢失的问题多路转接IO在高性能服务器几乎是必备选项,异步 IO(如 Linux AIO / io_uring)使用门槛较高,在传统网络服务器中不如多路转接普及,但在高性能存储和新兴框架中正重新崛起
Linux非阻塞式IO实现方案
最推荐的方式是使用fcntl函数设置文件描述符为非阻塞模式,fcntl函数位于unistd.h与fcntl.h头文件,函数原型为int fcntl(int fd, int cmd, ... /* arg */ );
- 第一个参数fd为需要设置的文件描述符
- 第二个参数cmd为需要fcntl函数进行的操作,常见可以使用F_GETFL(获取文件描述符属性),F_SETFL(设置文件描述符属性)
- 当第二个参数为F_SETFL时,可以通过将当前
文件描述符属性 |= O_NONBLOCK设置为非阻塞属性 - 当第二个参数为F_GETFL时,会将当前作为第一个参数的文件描述符的属性作为返回值返回
注:对于被设置非阻塞属性的文件描述符,使用其进行IO函数调用时,当事件未就绪,函数常会返回-1,并设置errno为EAGAIN或EWOULDBLOCK(EAGAIN是UNIX早期错误码,现代Linux系统中EAGAIN常 == EWOULDBLOCK),EINTR表示被信号中断
非阻塞IO代码示例链接:https://gitee.com/xiao-dongs-code-repository/gitee_dmk/blob/master/Linux/2026_5_19/nonblock_test.cpp
Linux多路转接IO实现方案
传统IO方案如read,recvfrom,recv等都是集合了等 + 拷贝,而由于等待是IO消耗的大头,所以,便出现了将等和拷贝分离的多路转接IO方案,以下三种方案,均是让操作系统进行等待操作,当资源就绪时通知用户,如此,read等IO函数便可直接进行拷贝操作了
select函数
select函数用于接管IO中的等待资源就绪工作,位于<sys/select.h>头文件,函数原型为:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
返回值表示准备就绪的fd的个数,若为0表示超时(后续timeout函数相关,若timeout传nullptr,则不会出现返回0的情况),为-1表示出现错误(如要求接管的fd不合法)- nfds表示需要select函数接管的fd范围,具体来说,当需要接管1,2,4,6,则
需要传递其中最大值 + 1,即7,而具体接管该范围中哪些fd,以何种方式接管,就需要通过第2,3,4个参数指定 - readfds,为输入输出型参数(
可传递nullptr表示忽略),输入时表示描述需要select函数接管的具体fd(读),输出表示select在指定时间内所接管的已经就绪的fd(读),需要注意,select会经常对第2,3,4个参数进行修改,而该参数的类型为fd_set,本质是一张位图(1表示接管,0表示不接管),并且同样,不建议直接操作位图,应使用系统提供的宏函数进行相关操作:- FD_ZERO(fd_set *set):全部置0,即清空位图
- FD_SET(int fd, fd_set *set):将位图第fd位置1(从第0位开始),即设置位图一位
- FD_CLR(int fd, fd_set *set):将位图第fd位置0(从第0位开始),即取消位图一位
- FD_ISSET(int fd, fd_set *set):判断第fd位是否为1
- writefds,与readfds类似,也是输入输出型参数(
可传递nullptr表示忽略),但输入表示需要select函数接管的具体fd(写),输出也为就绪的写fd - exceptfds,与readfds类似,也是输入输出型参数(
可传递nullptr表示忽略),但输入表示需要select函数接管的具体fd(异常),输出为就绪的异常fd - timeout,为输入型参数,可传递timeval对象指针或nullptr,具体来说:
- 传递对象指针时,类型timeval中包含两个成员,第一个表示秒数,第二个表示微秒数,整体用于表示select函数等待时间,如果等待时间超过timeout,则返回0,表示所接管fd没有任何一个就绪,若返回-1,则表示出错(常见为 EBADF、EINVAL、EINTR),
若在等待时间内有位图中的fd就绪,则停止阻塞,直接返回,也可传timeval{0, 0}对象的指针,表示不等待,直接进行检测 传NULL时,表示select永久阻塞等待,直到有fd就绪时停止- 注:第五个参数为nullptr时,select不可能返回0,因为0表示超时;而第五个参数为{0, 0}对象的指针时可以返回0,即超时,表示没有任何fd就绪
- 传递对象指针时,类型timeval中包含两个成员,第一个表示秒数,第二个表示微秒数,整体用于表示select函数等待时间,如果等待时间超过timeout,则返回0,表示所接管fd没有任何一个就绪,若返回-1,则表示出错(常见为 EBADF、EINVAL、EINTR),
注意事项:
由于客户端关闭链接时,会发送FIN到服务端,所以此时接收缓冲区也会显示就绪,因此,select在检测到客户端关闭链接时,也会返回就绪select调用会更改位图的值,因此,需要使用一个数据结构记录下需要管理的fds,并在每次调用select前将位图进行更新- select本质就是一个检测器(根据timeval参数选择阻塞 / 非阻塞),内部不会存储曾经检测果的fds,对于每次检测都仅会操作作为参数的位图
缺点:
每次调用select,都需要手动设置fd集合,从接口使用角度来说也非常不便每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大- 同时每次调用select都需要在内核
遍历传递进来的所有fd,这个开销在fd很多时也很大 select支持的文件描述符数量太小(为FD_SETSIZE)->最主要
select实现多路转接示例链接:
https://gitee.com/xiao-dongs-code-repository/gitee_dmk/blob/master/Linux/2026_5_20/select_TCP.hpp
poll函数
为了解决使用select使用困难,以及文件描述符数量太小的问题,poll函数应运而生,其位于<poll.h>头文件中,函数原型为:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
-
返回值表示就绪个数,当timeout为-1时(poll使用-1表示阻塞),不可能返回0(0表示超时),-1表出错(如监管的fd不合法)
-
fds为需要监管的fd,类型为pollfd*,参数传递pollfd结构体数组,该结构体中包含三个元素:
cppstruct pollfd { int fd; // 需要管控的文件描述符 short int events; // 位图,表示需要关注的事件类型->不会被poll更改 short int revents; // 位图,表示实际就绪的事件类型->每次调用poll都会以赋值方式更新,而非在旧值上追加,因此使用前无需将revents清零 };其中位图中使用的常用宏有:
cpp#define POLLIN 0x001// 读事件 #define POLLOUT 0x004// 写事件 #define POLLERR 0x008// 错误事件 #define POLLHUP 0x010// 挂断事件而设置位图操作
没有提供官方poll相关宏函数,可以使用 |= 进行设置,&= ~ 进行取消, & 进行验证 -
nfds为fds数组的长度
-
timeout为超时时间,
单位是ms,设置为-1表示阻塞等待,设置为0表示直接查询,正数表示等待指定时间(会在此处阻塞),对于阻塞情况,一旦出现就绪或出现错误,则会立即返回
注意事项:
- 由于poll第一个参数的设置,所以其
解决了select可监管fd过少的问题,具体监管数量由用户指定 - poll在调用时,会遍历传递进来的所有fd,因此,
如果fd很多,则开销会很大 - poll调用时,不会更改events位图;但
会以赋值方式更新revents,因此使用前无需将revents清零
优点:
不同于select使用三个位图来表示三个fdset的方式,poll使用一个pollfd的指针实现
- pollfd结构包含了要监视的event和发生的event,不再使用select"参数-值"传递的方式,接口使用比select更方便
- poll并没有最大数量限制
缺点:
- poll返回后,需要轮询pollfd来获取就绪的描述符
- 每次调用poll都需要把大量的pollfd结构从用户态拷贝到内核中
同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会下降
poll实现多路转接示例链接:
https://gitee.com/xiao-dongs-code-repository/gitee_dmk/blob/master/Linux/2026_5_21/poll_TCP.hpp
epoll
epoll机制主要借助三个函数实现,分别为epoll_create, epoll_ctl, epoll_wait, 这些函数均位于 <sys/epoll.h> 头文件
- int epoll_create(int size) 创建一个epoll实例,若成功创建则返回一个文件描述符,失败返回-1,参数size表示epoll管理的最大fd数量,
但该参数在2.6.8版本中已弃用,后续使用随意传参即可(但不要传0或负数,内核仍会检验,会导致返回-1并设置错误码,一般建议传1) - int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
- epfd参数需传递create函数返回的文件描述符
- op参数表示需要epoll_ctl进行的操作,共有三种,分别为:EPOLL_CTL_ADD(添加需要管理的fd与event属性), EPOLL_CTL_MOD(修改), EPOLL_CTL_DEL(删除)
- fd参数表示需要操作系统管理的fd
- event参数表示需要epoll_ctl进行设置的属性,常见的有
EPOLLIN(可读),EPOLLOUT(可写),EPOLLET(边缘触发,需配合非阻塞式IO使用)(类似可写事件,epoll具备EPOLLOUT与EPOLLWRNORM两个宏,目前一般判断EPOLLOUT即可),参数可使用栈区数据指针,内核本身会深copy指针指向的数据,不过要重点注意,event进行|=操作前要先确保其进行了0初始化({}或= {0}均可),不让最终event的值很可能不符合预期导致bug - 成功返回0,失败返回-1
- 需要注意,
ctl只能移除合法的fd,也就是说,要先移除再close,移除时最后一个参数传递nullptr即可
- int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
- epfd参数表示通过epoll_create函数创建的epoll文件描述符
- events为输出参数,表示用于存放返回的就绪事件(
epoll_wait函数会从前向后存储数据,也就是说,需要遍历该数组时,仅需for(int i = 0; i < n/*(函数返回值)*/; i++),无需maxevents遍历整个数组) - maxevents参数表示就绪fd的events(数组形式)的容量,即数组元素的个数
- timeout参数表示等待时间(单位ms),-1表示永久阻塞,0表示立即返回,大于0表示等待指定时间,阻塞会持续至出现就绪fd或出现错误
- 返回-1表示错误,0表示时间到达,正数表示就绪的fd数量
只有使用ctl设置的事件就绪才会将红黑树对应节点链入队列中,且就算对应fd也有其他事件就绪,返回的events中也只会包含ctl设置的事件(如TCP连接后通信用套接字,若只设置了EPOLLIN,则就算fd大部分情况处于可写事件,也不会被链入队列中,就算因为可读事件触发而被链入,events[i]中也不会包括EPOLLOUT)注:特例,EPOLLERR 和 EPOLLHUP 会无条件上报
epoll原理:
-
epoll_create创建一个epoll实例,返回一个epoll文件描述符,该文件描述符指向内核中的一个epoll对象,可以通过其索引到操作系统中创建的一棵红黑树与一个就绪队列(本质为双向链表),红黑树与该就绪队列共享节点(同一节点可以连入不同数据结构),而每个节点中最重要的信息就是fd与其event
-
初始状态下,红黑树和队列均为空,后续使用ctl函数(
ctl函数本质就是修改红黑树,注册节点时会在节点中添加对应的回调方法),会malloc新节点并将其链入红黑树,当节点的fd处于就绪状态时,操作系统会自动将该节点也链入队列中 -
调用
wait函数的本质是取出队列中的节点信息,默认情况下,若取出但不消费或消费不完全,则后续操作系统仍会将该节点链入队列中,直到消费完毕(LT) -
该红黑树功能就是维护fd与event等信息的映射关系,而队列本质是一个生产消费者模型 -
网络协议栈中,存在一种回调机制,当事件就绪时,操作系统就会自动触发回调方法,将红黑树中的就绪节点链入到队列中,具体来说,读事件的自动化实现依赖于网卡,当网卡中存在就绪事件,就会触发硬件中断,将数据交付到网卡驱动中,网卡驱动又会将数据交付给操作系统接收缓冲区
-
epoll_wait查询是否具备就绪事件仅需要检测队列是否为空,因此,时间复杂度为O(1),强于select与poll的O(n) -
调用wait函数时,可能events参数数组大小不够,不过没关系,剩余没读完的数据会被保留在队列中,可以下次继续读取,且
当fd就绪后被消费完毕,只要不使用ctl将其从红黑树中删除,就仅会从队列中删除,然后在红黑树中继续等待就绪事件 -
event结构体结构如下:
cppstruct epoll_event { uint32_t events; /* 管理的fd的事件(属性) */ epoll_data_t data; /* OS不修改的用户数据,本质是联合,结构如上,调ctl设置进节点,调用wait时会返回给用户,可用于获取fd */ } __EPOLL_PACKED; typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t;
epoll优点:
接口使用方便,无需用户直接管理数据结构,而是通过三个函数进行黑盒修改数据拷贝轻量,设置需要epoll管理的fd时,仅需要通过ctl进行ADD申请堆区空间并链入红黑树,当事件就绪,无需开辟新空间,而是直接将红黑树的节点共享给队列当事件就绪,会触发网卡硬件中断,将就绪信息交给网卡驱动,后逐步到对应节点中调用其回调方法,因此,OS无需遍历红黑树查找是否具备就绪事件,当节点中有事件就绪,会自动调用回调方法将节点链入队列中,即O(1)时间复杂度就能将就绪时间对应节点链入队列- 管理的
fd数量无限制 - 注意:有说法说队列中的数据能直接映射到用户的_events数组,无需拷贝,此种说法是不正确的,
内核仍需进行拷贝数据至用户空间
epoll两种模式:LT与ET
首先明确,LT和ET两种模式并非作用全局,而是单个红黑树节点的属性
LT(水平触发)模式:即默认模式,行为是,当ctl设置的fd1的事件就绪,OS就会调用fd1节点的回调方法,且若上层未消费/未消费完全,则后续仍会将fd1节点链入队列(因为其中还有未被消费的资源)ET(边缘触发)模式:需要通过ctl + EPOLLET宏进行设置,行为是,当ctl设置的fd1的事件就绪,OS调用fd1节点回调方法,若上层未消费/未消费完全,则后续该节点不会被重新链入队列中,除非fd1有新资源就绪,举例来说,fd1为TCP通信套接字,设置为EPOLLIN,当对端发送"!!!",本端使用read读取"!",则fd1中仍有资源就绪,但OS检测到后不会将该节点重新链入队列中,除非对端发送新数据给fd1,则OS会再次将fd1节点链入队列(ET下,只有数据从无到有/从有到多才会触发回调函数)- LT与ET的区别:
- LT在阻塞/非阻塞模式下都能正确运行,但
ET只能在非阻塞模式下运行,原因是,若不将资源消费完全,后续若没有新资源到达,则旧资源剩余数据将会永远无法拿到(wait无法再次得到该节点的event),所以,就需要循环read来确保资源被消费完毕,但使用while + read会导致数据读取完毕后的下一次while中read阻塞住,由于采取单线程单进程,程序整体会被阻塞,因此,就必须采取非阻塞模式(可能会有最后一次数据读取size < sizeof(buffer) - 1的情况,该情况可通过 < 判断循环是否结束,但也会存在最后一次读取恰好 size == sizeof(buffer) - 1的情况,则必须采取非阻塞read,因此,通常不进行区分,也无法区分,ET应必须使用非阻塞read) ET模式约束程序员,必须采取非阻塞方式,即确定性,但LT可采取阻塞或非阻塞,逻辑无法直接确定- LT复杂度较低,但ET效率高于LT,原因是,
ET强制非阻塞读取,可以确保接收缓冲区足够大,一次更易接收更多对端数据,且ET的wait通知都是有效的,LT可能会重复提醒,造成额外消耗
- LT在阻塞/非阻塞模式下都能正确运行,但
epoll实现多路转接示例链接(LT)
https://gitee.com/xiao-dongs-code-repository/gitee_dmk/blob/master/Linux/2026_5_22/epoll_LT_TCP.hpp
epoll实现多路转接示例链接(ET)
https://gitee.com/xiao-dongs-code-repository/gitee_dmk/blob/master/Linux/2026_5_24/epoll_et.hpp
补充知识:低水位线
低水位线(SO_RCVLOWAT / SO_SNDLOWAT)------ socket 层选项,非 epoll 独有,其存在会影响 select / poll / epoll 的"就绪判定"
SO_RCVLOWAT:接收缓冲区数据 >= 该值才判定"可读就绪",Linux 默认 = 1
SO_SNDLOWAT:发送缓冲区可用空间 >= 该值才判定"可写就绪",默认 2048,且 Linux 上不可改
注意:
- 内核始终知道缓冲区有数据,未达阈值只是"不判定就绪、不通知"
- SO_RCVLOWAT 别设太大,否则长期不达阈值 → epoll 不通知
- ET 模式风险最大:ET 只在状态跃变时通知一次,数据若永远不达
低水位线 → 静默滞留缓冲区,极难排查