五种IO模型(详解非阻塞IO与多路转接)

IO耗时原因

对于传统阻塞式IO,其主要分为两部分:等待资源就绪 + 拷贝,而这其中时耗最高的其实是阻塞等待(占极大头),换言之,只要降低/移除IO的等待时间,就能极大提升IO效率,而下面将介绍5种IO模型,并重点讲解多路转接IO的具体实现方案

五种IO模型

接下来以钓鱼为例介绍五种IO:

  1. 张三:正常钓鱼,一直盯着鱼竿,当发现有鱼上钩就进行处理(阻塞式IO)
  2. 李四:一会看看鱼竿是否有鱼,一会看看书,聊聊天(非阻塞式IO)
  3. 王五:在鱼竿上挂一个铃铛,当有鱼上钩后铃铛会响(信号驱动)
  4. 赵六:使用100根鱼竿,然后循环监视100根鱼竿中哪个就绪了(多路转接)
  5. 田七:雇人钓鱼(异步IO)

核心思想如上,总体不难理解,接下来分别对五种IO的注意事项进行介绍

  1. 阻塞与非阻塞:当条件不就绪时,阻塞IO会阻塞至条件就绪,非阻塞式则会检测到IO条件不具备,出错返回;注意,非阻塞IO虽然可以更好的利用时间,做更多事,但单纯说IO效率,阻塞和非阻塞都是一致的,除非非阻塞式用其他时间做了其他IO(李四看书聊天不会提高鱼咬钩的概率)
  2. 赵六的钓鱼(多路转接)效率最高,因为鱼咬赵六钩的概率是100 / 104
  3. 上述五种方案中只有田七是异步IO,其余均为同步IO-》王五也算同步IO,即只要有人参与了IO(不管是等还是拷贝),都算是同步IO
  4. 目前信号IO用的极少,因为可能出现信号丢失的问题
  5. 多路转接IO在高性能服务器几乎是必备选项,异步 IO(如 Linux AIO / io_uring)使用门槛较高,在传统网络服务器中不如多路转接普及,但在高性能存储和新兴框架中正重新崛起

Linux非阻塞式IO实现方案

最推荐的方式是使用fcntl函数设置文件描述符为非阻塞模式,fcntl函数位于unistd.h与fcntl.h头文件,函数原型为int fcntl(int fd, int cmd, ... /* arg */ );

  1. 第一个参数fd为需要设置的文件描述符
  2. 第二个参数cmd为需要fcntl函数进行的操作,常见可以使用F_GETFL(获取文件描述符属性),F_SETFL(设置文件描述符属性)
  3. 当第二个参数为F_SETFL时,可以通过将当前 文件描述符属性 |= O_NONBLOCK 设置为非阻塞属性
  4. 当第二个参数为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);

  1. 返回值表示准备就绪的fd的个数,若为0表示超时(后续timeout函数相关,若timeout传nullptr,则不会出现返回0的情况),为-1表示出现错误(如要求接管的fd不合法)
  2. nfds表示需要select函数接管的fd范围,具体来说,当需要接管1,2,4,6,则需要传递其中最大值 + 1,即7,而具体接管该范围中哪些fd,以何种方式接管,就需要通过第2,3,4个参数指定
  3. 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
  4. writefds,与readfds类似,也是输入输出型参数(可传递nullptr表示忽略),但输入表示需要select函数接管的具体fd(写),输出也为就绪的写fd
  5. exceptfds,与readfds类似,也是输入输出型参数(可传递nullptr表示忽略),但输入表示需要select函数接管的具体fd(异常),输出为就绪的异常fd
  6. 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就绪

注意事项:

  1. 由于客户端关闭链接时,会发送FIN到服务端,所以此时接收缓冲区也会显示就绪,因此,select在检测到客户端关闭链接时,也会返回就绪
  2. select调用会更改位图的值,因此,需要使用一个数据结构记录下需要管理的fds,并在每次调用select前将位图进行更新
  3. select本质就是一个检测器(根据timeval参数选择阻塞 / 非阻塞),内部不会存储曾经检测果的fds,对于每次检测都仅会操作作为参数的位图

缺点:

  1. 每次调用select,都需要手动设置fd集合,从接口使用角度来说也非常不便
  2. 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  3. 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
  4. 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);

  1. 返回值表示就绪个数,当timeout为-1时(poll使用-1表示阻塞),不可能返回0(0表示超时),-1表出错(如监管的fd不合法)

  2. fds为需要监管的fd,类型为pollfd*,参数传递pollfd结构体数组,该结构体中包含三个元素:

    cpp 复制代码
    struct 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相关宏函数,可以使用 |= 进行设置,&= ~ 进行取消, & 进行验证

  3. nfds为fds数组的长度

  4. timeout为超时时间,单位是ms,设置为-1表示阻塞等待,设置为0表示直接查询,正数表示等待指定时间(会在此处阻塞),对于阻塞情况,一旦出现就绪或出现错误,则会立即返回

注意事项:

  1. 由于poll第一个参数的设置,所以其解决了select可监管fd过少的问题,具体监管数量由用户指定
  2. poll在调用时,会遍历传递进来的所有fd,因此,如果fd很多,则开销会很大
  3. poll调用时,不会更改events位图;但会以赋值方式更新revents,因此使用前无需将revents清零

优点:

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

  1. pollfd结构包含了要监视的event和发生的event,不再使用select"参数-值"传递的方式,接口使用比select更方便
  2. poll并没有最大数量限制

缺点:

  1. poll返回后,需要轮询pollfd来获取就绪的描述符
  2. 每次调用poll都需要把大量的pollfd结构从用户态拷贝到内核中
  3. 同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会下降

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> 头文件

  1. int epoll_create(int size) 创建一个epoll实例,若成功创建则返回一个文件描述符,失败返回-1,参数size表示epoll管理的最大fd数量,但该参数在2.6.8版本中已弃用,后续使用随意传参即可(但不要传0或负数,内核仍会检验,会导致返回-1并设置错误码,一般建议传1)
  2. 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即可
  3. 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原理:

  1. epoll_create创建一个epoll实例,返回一个epoll文件描述符,该文件描述符指向内核中的一个epoll对象,可以通过其索引到操作系统中创建的一棵红黑树与一个就绪队列(本质为双向链表),红黑树与该就绪队列共享节点(同一节点可以连入不同数据结构),而每个节点中最重要的信息就是fd与其event

  2. 初始状态下,红黑树和队列均为空,后续使用ctl函数(ctl函数本质就是修改红黑树,注册节点时会在节点中添加对应的回调方法),会malloc新节点并将其链入红黑树,当节点的fd处于就绪状态时,操作系统会自动将该节点也链入队列中

  3. 调用wait函数的本质是取出队列中的节点信息,默认情况下,若取出但不消费或消费不完全,则后续操作系统仍会将该节点链入队列中,直到消费完毕(LT)

  4. 该红黑树功能就是维护fd与event等信息的映射关系,而队列本质是一个生产消费者模型

  5. 网络协议栈中,存在一种回调机制,当事件就绪时,操作系统就会自动触发回调方法,将红黑树中的就绪节点链入到队列中,具体来说,读事件的自动化实现依赖于网卡,当网卡中存在就绪事件,就会触发硬件中断,将数据交付到网卡驱动中,网卡驱动又会将数据交付给操作系统接收缓冲区

  6. epoll_wait查询是否具备就绪事件仅需要检测队列是否为空,因此,时间复杂度为O(1),强于select与poll的O(n)

  7. 调用wait函数时,可能events参数数组大小不够,不过没关系,剩余没读完的数据会被保留在队列中,可以下次继续读取,且当fd就绪后被消费完毕,只要不使用ctl将其从红黑树中删除,就仅会从队列中删除,然后在红黑树中继续等待就绪事件

  8. event结构体结构如下:

    cpp 复制代码
    struct 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优点:

  1. 接口使用方便,无需用户直接管理数据结构,而是通过三个函数进行黑盒修改
  2. 数据拷贝轻量,设置需要epoll管理的fd时,仅需要通过ctl进行ADD申请堆区空间并链入红黑树,当事件就绪,无需开辟新空间,而是直接将红黑树的节点共享给队列
  3. 当事件就绪,会触发网卡硬件中断,将就绪信息交给网卡驱动,后逐步到对应节点中调用其回调方法,因此,OS无需遍历红黑树查找是否具备就绪事件,当节点中有事件就绪,会自动调用回调方法将节点链入队列中,即O(1)时间复杂度就能将就绪时间对应节点链入队列
  4. 管理的fd数量无限制
  5. 注意:有说法说队列中的数据能直接映射到用户的_events数组,无需拷贝,此种说法是不正确的,内核仍需进行拷贝数据至用户空间

epoll两种模式:LT与ET

首先明确,LT和ET两种模式并非作用全局,而是单个红黑树节点的属性

  1. LT(水平触发)模式:即默认模式,行为是,当ctl设置的fd1的事件就绪,OS就会调用fd1节点的回调方法,且若上层未消费/未消费完全,则后续仍会将fd1节点链入队列(因为其中还有未被消费的资源)
  2. ET(边缘触发)模式:需要通过ctl + EPOLLET宏进行设置,行为是,当ctl设置的fd1的事件就绪,OS调用fd1节点回调方法,若上层未消费/未消费完全,则后续该节点不会被重新链入队列中,除非fd1有新资源就绪,举例来说,fd1为TCP通信套接字,设置为EPOLLIN,当对端发送"!!!",本端使用read读取"!",则fd1中仍有资源就绪,但OS检测到后不会将该节点重新链入队列中,除非对端发送新数据给fd1,则OS会再次将fd1节点链入队列(ET下,只有数据从无到有/从有到多才会触发回调函数
  3. 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可能会重复提醒,造成额外消耗

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 上不可改

注意:

  1. 内核始终知道缓冲区有数据,未达阈值只是"不判定就绪、不通知"
  2. SO_RCVLOWAT 别设太大,否则长期不达阈值 → epoll 不通知
  3. ET 模式风险最大:ET 只在状态跃变时通知一次,数据若永远不达
    低水位线 → 静默滞留缓冲区,极难排查
相关推荐
luj_176813 分钟前
尾椎藏今生记忆?骨盆对应不确定性
开发语言·网络·c++·经验分享·算法
Lzg_na15 分钟前
WiFi吞吐量和实际文件传输时速率差异原因以及定位
运维·网络
深圳多奥智能一卡(码、脸)通系统15 分钟前
智能梯控SDK可直接用于第三方开发对接的接口文档说明书
服务器·前端·算法
技术猿禁19 分钟前
Linux运维开发
linux·运维·运维开发
民乐团扒谱机22 分钟前
【读论文】基于波分复用和时分复用的单纤高精度双向时间传递系统
运维·服务器·网络
Shadow(⊙o⊙)23 分钟前
HTTP中URL,状态码详解
网络·网络协议·http
烂蜻蜓24 分钟前
Flask入门教程(十五):错误处理与日志——打造健壮的Web应用
前端·python·flask
脚踏实地,坚持不懈!25 分钟前
Android ANR 内核底层全解析:从一次触摸到“应用无响应”
android·linux
风哥2号26 分钟前
华为高斯数据库恢复工具FGOGDU(FGEDU openGauss DUL)
数据库·opengauss·华为高斯