资讯动态

计算机网络 之【高级IO】(IO模型、fcntl、select\poll\epoll)

发布时间:2026/9/26 10:44:37 来源:尧图企业网站定制
目录1.五种 I/O 模型2.非阻塞 I/O 与 fcntl 详解2.1.fcntl4SetNoBlock5非阻塞错误码3.select3.1.fd_set 与 FD_XXX 宏3.2.select 参数详解3.3.select的优缺点3.4.简易selectServer实现的细节4.poll4.1.poll参数详解poll 和 select 的核心区别4.2.poll的改进与局限4.3.简易pollServer实现的细节5.epoll5.1.epoll 的三个核心接口5.2.epoll原理5.3.LT水平触发与 ET边缘触发5.4.关于 EPOLLOUT 的按需设置5.5.简易epollServer实现的细节1.五种 I/O 模型I/O 等待 拷贝提高 I/O 效率的本质是降低等待的比重模型是否同步等待方式拷贝参与效率关键点阻塞 I/O同步进程阻塞等待内核数据就绪进程参与拷贝等待时 CPU 无法处理其他任务非阻塞 I/O同步轮询检查数据是否就绪不阻塞进程参与拷贝轮询浪费 CPU但可同时处理多路信号驱动 I/O同步数据就绪时内核发信号通知进程参与拷贝等待不占用 CPU但信号处理复杂多路复用 I/O同步select/poll/epoll统一等待进程参与拷贝单线程监听多个 fd效率高异步 I/O异步内核完成等待 拷贝后通知内核完成全部工作进程仅发起请求完全不参与 I/O同步IO进程主动等待或轮询并参与数据拷贝阶段异步IO进程发起请求后立即返回内核做完所有事情后再通知进程阻塞 vs 非阻塞等待数据就绪时进程是否挂起多路复用也是阻塞数据拷贝阶段效率一致等待数据就绪阶段阻塞IO让出CPU高效非阻塞IO忙轮询低效在同步 I/O 范畴内多路复用(尤其是 epoll)因为可以单执行流管理大量连接且只通知就绪 fd效率最高。但异步 I/O 理论上更高效只是编程模型复杂只有遇到明确性能瓶颈且确定是I/O模型导致的才值得考虑切换到异步 I/O了解2.非阻塞 I/O 与 fcntl 详解2.1.fcntlfcntl (file control) 是 POSIX 系统中用于对已打开的文件描述符进行各种控制操作的系统调用1函数原型#include fcntl.h int fcntl(int fd, int cmd, ... /* arg */);功能修改已打开文件描述符的属性常见 cmdF_GETFL获取当前文件状态标志返回值即标志位图失败返回 -1F_SETFL设置文件状态标志如 O_NONBLOCK、O_APPEND情况返回值errno设置说明失败-1是任何错误发生时返回 -1具体错误原因通过 errno 获取成功取决于具体命令否保持不变不同命令返回不同值详见下表成功时不同命令的返回值命令cmd成功返回值说明F_DUPFD新的文件描述符≥ arg复制文件描述符返回最小未使用的描述符F_DUPFD_CLOEXEC新的文件描述符≥ arg复制并设置 close-on-exec 标志F_GETFD0或FD_CLOEXEC标志值返回文件描述符标志当前仅定义 FD_CLOEXECF_SETFD0成功设置文件描述符标志F_GETFL文件状态标志整数返回如O_RDONLY、O_NONBLOCK等标志的组合F_SETFL0成功设置部分文件状态标志如O_NONBLOCK、O_ASYNCF_GETLK0成功获取锁信息struct flock 被填充F_SETLK0成功设置或释放记录锁非阻塞F_SETLKW0成功设置记录锁阻塞等待也可能被信号中断返回 -1F_GETOWN进程ID或进程组ID正数返回接收 SIGIO/SIGURG 信号的进程/进程组F_SETOWN0成功设置接收信号的进程/进程组F_GETPIPE_SZ管道缓冲区大小字节数获取管道的容量Linux 特有F_SETPIPE_SZ管道缓冲区大小字节数设置并返回实际设置的管道容量Linux 特有F_GET_SEALS密封标志位掩码整数获取文件的密封标志Linux 特有用于 memfdF_ADD_SEALS0成功添加密封标志Linux 特有2主要功能分类复制一个现有的描述符cmdF_DUPFD.获得/设置文件描述符标记(cmdF_GETFD或F_SETFD).获得/设置文件状态标记(cmdF_GETFL或F_SETFL).获得/设置异步I/O所有权(cmdF_GETOWN或F_SETOWN).获得/设置记录锁(cmdF_GETLK,F_SETLK或F_SETLKW).3文件状态标志标志值说明示例O_RDONLY0只读不可修改O_WRONLY1只写不可修改O_RDWR2读写不可修改O_APPEND0x2000追加模式每次写追加到末尾O_NONBLOCK0x4000非阻塞模式无数据时立即返回O_ASYNC0x2000 (不同系统)异步 I/O数据就绪发送信号O_SYNC0x1000同步写等待数据落盘4SetNoBlock获取/设置文件状态标记, 将一个文件描述符设置为非阻塞int SetNoBlock(int fd) { int fl fcntl(fd, F_GETFL); if (fl 0) { perror(fcntl F_GETFL); return -1; } if (fcntl(fd, F_SETFL, fl | O_NONBLOCK) 0) { perror(fcntl F_SETFL); return -1; } return 0; }5非阻塞错误码设置O_NONBLOCK后当底层数据未就绪时操作返回值errno说明读read/recv-1EAGAIN或EWOULDBLOCK值相同无数据可读写write/send-1EAGAIN或EWOULDBLOCK都判断确保可移植性发送缓冲区满这不是真正的错误而是资源临时不可用需要稍后重试#include errno.h #include unistd.h ssize_t n read(fd, buf, sizeof(buf)); if (n 0) { if (errno EAGAIN || errno EWOULDBLOCK) { // 数据未就绪不是错误 // 等待 epoll/select 通知可读 return 0; // 或 -2 表示需要重试 } // 真正的错误 perror(read error); return -1; } // 成功读取 n 字节3.select3.1.fd_set 与 FD_XXX 宏fd_set 本质是一个位图在内核中以 unsigned long 数组实现位图大小由 FD_SETSIZE 宏定义(通常 1024)因此单个 select 能监听的最大 fd 编号为 1023操作宏宏原型功能FD_ZEROFD_ZERO(fd_set *set)清空集合所有位清零FD_SETFD_SET(int fd, fd_set *set)将 fd 加入集合对应位置1FD_CLRFD_CLR(int fd, fd_set *set)将 fd 从集合移除对应位置0FD_ISSETFD_ISSET(int fd, fd_set *set)检查 fd 是否在集合中0不在集合中3.2.select 参数详解select 是 I/O 多路复用函数用于监视多个文件描述符的状态变化struct timeval { time_t tv_sec; /* seconds */ suseconds_t tv_usec; /* microseconds */ };int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);nfds监听的最大 fd 值 1内核据此缩小遍历范围timeout输入输出型参数为NULL时阻塞等待tv_sec tv_usec 0时非阻塞轮询否则就等待指定时间返回时会被更新为剩余时间可能需要周期重复设置fd_set 也是输入输出型参数输入时表示关心的 fd返回时表示就绪的 fd。因此每次调用前必须重新设置返回值含义说明 0就绪的文件描述符数量有多少个 fd 发生了事件0超时在指定时间内没有 fd 就绪-1错误调用失败查看 errno3.3.select的优缺点1优点单进程引入 select 后进程不再被迫在某个慢速连接上傻等而是由 select 统一监控所有连接内核会精确唤醒进程并告知哪个 fd 先就绪程序随即优先处理该事件并立即返回从而在单线程内达成了并发的效果——即哪个客户端先发数据就先服务谁完全解除了由于某个连接阻塞导致其他连接饥饿的耦合关系单进程 select 实现的是I/O 多路复用式并发——它通过内核事件通知让单个线程在多个连接之间高效切换属于并发模型的一种只是它不依赖多线程。而多线程是另一种并发模型它通过多个执行流来承载并发在多核 CPU 上还能进一步实现并行2缺点fd 数量有上限可重新编译内核修改但效率下降每次调用需拷贝fd 集合到内核返回时再拷贝回来内核采用线性扫描所有监听的 fd效率随 fd 数量线性下降用户态需要遍历整个数组找出就绪 fd3.4.简易selectServer实现的细节gitee.com/dongfanqi/homework6/commit/3c29645059401652d7bbae1c40b29ccee26a87e21listenfd 在 selec中监听的是读事件新连接到来等价于读事件就绪必须先通过 select 检测到 listenfd 可读后再调用 accept否则 accept 在无连接时会阻塞阻塞模式或返回 EAGAIN非阻塞模式“新连接到达”在语义上等同于“监听 socket 有新的数据/对象可被读取”2select 采用水平触发LT模式只要 fd 上还有未处理的数据/事件select 就会持续通知上层可以不处理下次 select 仍然会返回该 fd此时调用 read/accept 等操作不会阻塞因为数据已就绪3为了让文件描述符能在 select 循环中跨函数传递并保持完整监控列表必须使用自定义数组来持久化存储所有需要监听的文件描述符。这是因为 select 的 fd_set 是输入输出型参数每次调用后只会保留就绪的 fd导致原始监听集合丢失因此需要借助数组充当“全量备份”——在每轮循环前遍历数组重建 fd_set调用 select 等待事件事后再遍历数组找出就绪的 fd 进行处理4在使用 select 实现 I/O 多路复用时必须遍历自定义数组来找出当前最大的文件描述符值因为 select 的第一个参数 nfds 要求传入“最大文件描述符 1”以限定内核的监听范围。具体做法是先将 max_fd 初始化为监听套接字然后在将每个有效客户端 fd 加入 fd_set 的同一循环中逐一比较并更新 max_fd确保 select 能够覆盖数组中所有需要监控的描述符5每次 accept 获取的新连接 fd 绝不能立即阻塞读写必须存入自定义数组的空闲槽位完成“注册”随后交由 select 统一托管等待这是因为新连接刚建立时内核接收缓冲区极可能是空的若此时直接调用 recv会导致单执行流服务端瞬间卡死丧失处理其他并发连接的能力6select 返回后仅能告知文件描述符“可读”但无法区分这种“可读”究竟是监听套接字上的连接就绪需调用 accept 接纳新客户端还是普通客户端套接字上的数据就绪需调用 recv 读取内容因此必须在主循环中引入一个事件派发器的逻辑通过判断 遍历自定义数组 与 FD_ISSET判断决定当前 fd 是否为监听 fd若是则执行注册逻辑将新连接存入数组若否则执行读取逻辑4.poll4.1.poll参数详解int poll(struct pollfd *fds, nfds_t nfds, int timeout);参数struct pollfd { int fd; // 要监控的文件描述符 short events; // 关心的事件输入参数 short revents; // 实际发生的事件输出参数由内核填充 };参数含义fdsstruct pollfd数组首地址存放所有要监控的 fd 及事件nfds数组有效元素个数timeout超时时间毫秒-1表示永久阻塞事件宏事件宏含义POLLIN数据可读包括连接就绪POLLOUT数据可写POLLERR发生错误POLLHUP连接挂起对端关闭poll 和 select 的核心区别对比项selectpollfd 集合存储方式位图fd_set固定大小 1024动态数组struct pollfd[]最大 fd 数量限制硬限制FD_SETSIZE通常 1024无上限只受内存限制参数分离输入输出共用fd_set需每次重建分离设计events是输入revents是输出遍历复杂度O(n) 但 n 上限固定O(n)n 可以是任意大移植性几乎所有 Unix 系统POSIX 标准Windows 有WSAPoll4.2.poll的改进与局限struct pollfd 将事件输入events 与事件输出revents分离无需每次重置事件类型通过宏位图定义POLLIN、POLLOUT 等突破了 select 的 fd 数量限制数组大小由用户指定内存超限属于硬件问题缺点内核仍需要线性扫描所有监听的 fd数量大时效率低4.3.简易pollServer实现的细节gitee.com/dongfanqi/homework6/commit/3c29645059401652d7bbae1c40b29ccee26a87e2实现细节selectpoll共同点说明等待机制将当前进程/线程挂载到所有监听 fd 的等待队列上完全相同内核睡眠等待任意 fd 就绪时唤醒进程内核遍历方式线性扫描所有监听的 fd0 ~ nfds-1线性扫描用户传入的pollfd数组0 ~ nfds-1O(n) 时间复杂度n 为监听 fd 总数事件就绪检测调用每个 fd 对应的poll方法file-f_op-poll完全相同内核通过 VFS 层调用设备驱动的 poll 函数检查状态用户态/内核态拷贝select调用时拷贝fd_set到内核返回时拷贝回用户态poll调用时拷贝pollfd数组到内核返回时拷贝回用户态每次调用都有O(n) 的内存拷贝开销输入输出参数特性fd_set在返回时被修改覆盖为就绪 fd 集合pollfd的revents字段被修改保留events不变都需要区分“关心的事件”和“就绪的事件”就绪事件通知返回就绪 fd 总数用户需遍历整个fd_set找出具体就绪的 fd返回就绪 fd 总数用户需遍历整个pollfd数组检查revents用户态二次遍历不可避免超时精度struct timeval微秒级int timeout毫秒级底层均转换为内核的jiffies或高精度定时器poll 在接口设计上将输入事件events与输出事件revents分离使得用户无需在每次调用前重建监听集合同时通过传入用户自定义大小的数组打破了 select 对文件描述符数量的硬性限制但其底层内核实现与 select 几乎完全一致——依然是线性扫描所有监听的文件描述符、依然需要在用户态与内核态之间拷贝整个数组、依然需要用户层二次遍历才能定位就绪事件因此在大量并发场景下性能瓶颈与 select 无异最终被内核基于事件驱动回调的 epoll 所取代5.epoll5.1.epoll 的三个核心接口epoll_create —— 创建 epoll 实例项目说明函数原型int epoll_create(int size);头文件#include sys/epoll.h参数size历史遗留参数内核已忽略但必须 0建议填1返回值成功返回epoll 实例的文件描述符epfd失败返回-1并设置errno核心作用在内核中创建一个包含红黑树存储所有监听fd和就绪链表缓存已触发事件的eventpoll对象并返回一个文件描述符作为后续高效事件管理的操作句柄资源释放使用完毕后必须close(epfd)否则造成内核内存泄漏现代替代Linux 2.6.8 推荐使用epoll_create1(0)支持EPOLL_CLOEXEC标志避免 fork 后 fd 泄露epoll_ctl —— 控制 epoll 事件增/删/改项目说明函数原型int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);头文件#include sys/epoll.h参数详解epfdepoll_create返回的 epoll 实例 fdop操作类型取值如下•EPOLL_CTL_ADD—— 向 epoll 实例注册新的 fd•EPOLL_CTL_MOD——修改已注册 fd 的监听事件•EPOLL_CTL_DEL——移除已注册的 fdfd要操作的目标文件描述符监听 socket 或客户端 socketevent指向struct epoll_event的指针指定要监听的事件类型和携带的用户数据返回值成功返回0失败返回-1并设置errno核心作用对内核中的 epoll 红黑树执行增、删、改操作相当于“告诉内核我关心这个 fd 的哪些事件”常见错误EEXIST重复 ADD、ENOENT对不存在的 fd 执行 MOD/DEL、EBADFfd 无效struct epoll_event字段说明字段类型说明eventsuint32_t监听的事件掩码常用•EPOLLIN—— 数据可读•EPOLLOUT—— 数据可写•EPOLLET—— 边缘触发模式•EPOLLRDHUP—— 对端半关闭•EPOLLERR/EPOLLHUP—— 错误/挂起内核自动监听dataepoll_data_t联合体携带用户自定义数据•data.fd—— 存放 fd 本身•data.ptr—— 存放自定义结构体指针更灵活epoll_event 结构体的 data 成员是一个联合体最常用的是 data.fd用于在事件触发时告知用户是哪个 fd 就绪了epoll_wait —— 等待事件就绪项目说明函数原型int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);头文件#include sys/epoll.h参数详解epfdepoll 实例 fdevents输出参数指向用户态数组内核将就绪的事件拷贝到这里maxevents数组容量表示一次最多能接收多少个就绪事件timeout超时时间毫秒•-1—— 永久阻塞直到有事件•0—— 立即返回不阻塞•0—— 等待指定毫秒数返回值•0—— 就绪的 fd 数量•0—— 超时无事件•-1—— 出错检查errno核心作用当有事件就绪或超时时返回内核只将真正就绪的 fd 填入 events 数组用户态只需遍历返回的 n 个就绪项而无需遍历全部被监控的 fd关键优势内核事件通知机制是 O(1) 的就绪事件通过回调直接入队且返回给用户态时只拷贝实际就绪的 n 个事件而非全部监控的 N 个整体调用开销与监控总数 N 无关只与就绪数 n 成正比若就绪队列中事件数量超过 maxevents内核只取前 maxevents 个拷贝到用户态剩余节点保留在就绪队列中下次 epoll_wait 继续返回5.2.epoll原理epoll_create() ──→ 创建 epfd内核红黑树 就绪队列 │ ↓ epoll_ctl(ADD) ──→ 将 listen_fd 和 client_fd 挂到红黑树上 │ ↓ epoll_wait() ──→ 按时等待内核把就绪 fd 从就绪队列拷贝到用户态数组 │ ↓ 只遍历就绪的 n 个 fd而不是全部监控的 fd红黑树存储所有监听的 fd 及其事件epoll_ctl 增删改操作就绪队列双向链表存放已就绪 fd 对应的节点读回调机制从网卡检测到数据的那一刻起首先网卡触发硬中断内核在硬中断处理中激活软中断软中断通过回调函数将数据包读取到内核内存并推入协议栈随后协议栈进行数据解析处理操作在确认数据有效负载进入Socket接收队列后回调Socket层的默认通知函数该函数遍历Socket等待队列执行epoll_ctl预先注册的ep_poll_callback回调将对应的epitem结构体挂入eventpoll的就绪链表并唤醒阻塞在epoll_wait上的用户进程事件监测从主动轮询转变为被动通知写回调机制是发送缓冲区从满到非满的被动反向通知当网卡完成数据发送、通过硬中断-软中断路径释放内核缓冲区空间后协议栈触发 ep_poll_callback 将对应 epitem 挂入就绪链表并唤醒进程从而将“能否写入”的主动轮询转化为“空间腾出即通知”的硬件驱动事件模型。1epoll优点select/poll 模式 用户进程 --[每次调用拷贝全量fd列表]-- 内核 --[O(N)轮询检查状态]-- 返回 epoll 模式 用户进程 --[epoll_ctl仅拷贝一次fd]-- 内核红黑树 硬件中断 --[回调直接注入]-- 内核就绪链表 用户进程 --[epoll_wait仅从链表取数据]-- 返回 O(n)//n表示已就绪的fd个数epoll 优点消除无效遍历 (回调机制) 与重复拷贝 (红黑树存储) 提升 fd 修改效率 (红黑树VS数组)红黑树常驻免拷贝链表就绪免轮询中断回调改 O(N) 为 O(1)事件驱动回调硬件中断与协议栈进行事件监听操作操作系统无需主动轮询空闲连接不消耗 CPUO(1) 就绪检测epoll_wait 只需检查就绪链表是否为空时间复杂度极低O(N) 有效获取仅需遍历实际发生事件的 N 个就绪 fd无 select/poll 的全量遍历浪费无上限连接基于红黑树管理全量 fd大小仅受系统内存限制无重复拷贝fd 事件数据在内核中常驻 (红黑树节点) 用户态与内核态仅交换就绪事件列表2选择红黑树而不是哈希表的原因哈希表的 O(1) 假设了完美的哈希函数、无碰撞、且内核愿意承受扩容时的延迟。而服务器 5 万个长连接同时活跃时一次 rehash 导致的延迟可能触发服务崩溃红黑树的 O(logN) 虽然理论常数更大但在 N10 万时深度不超过 18 层操作耗时方差极小。更重要的是红黑树赋予了 epoll “无痛清理” 的能力进程退出时内核只需一个中序遍历就能有序、无空洞、无额外分配地销毁所有 epitem描述 fd 及其事件的数据结构红黑树换来了 epoll 在任意负载形态下的“性能可预测性”5.3.LT水平触发与 ET边缘触发模式通知时机特点LT默认只要 fd 对应缓冲区有数据epoll_wait就会通知编程简单若不处理数据会反复通知可能导致忙等ET仅当 fd 状态发生变化时如无数据→有数据通知高效要求非阻塞 I/O并一次性读完数据否则可能丢失事件ET 与 LT 效率对比若在 LT 下也采用非阻塞 I/O 并一次性读完数据两者效率接近。ET 的优势在于减少事件通知次数避免无谓的用户态/内核态切换LT 只读一次的情况下ET 的通知效率更高ET 的 IO 效率也更高因为 ET 下程序员会把数据全部取走tcp 就会给对方通告更大的窗口从而在概率上让对方一次可以发送更多数据ET 只通知一次会倒逼程序员在每次通知时把本轮数据都全部取走循环读取直到读取出错fd默认是阻塞的所以 ET 模式需要将fd设置为非阻塞边缘触发 (ET) 与 水平触发 (LT) 的底层区别LT 在 epoll_wait 将 fd 返回给用户后内核不会把该节点从“就绪队列”里彻底摘除而是留在队列里做一个标记。下一次 epoll_wait 如果发现数据还没读完这个 fd 依然在队列里会再次返回。这保证了“只要还有数据就一直通知”ET 在内核回调 ep_poll_callback 后数据加入队列。用户 epoll_wait 取走数据时该节点直接从就绪队列移除。如果用户没有一次性把缓冲区读空且后续没有新数据到达即没有新的硬件中断触发新的回调这个 fd再也不会被返回给用户5.4.关于 EPOLLOUT 的按需设置写缓冲区通常有空闲空间若一直监听 EPOLLOUTepoll_wait 会频繁返回浪费 CPU正确做法默认不监听 EPOLLOUT当需要发送数据时直接调用 write/send若返回 EAGAIN说明缓冲区满此时将数据存入用户态输出缓冲区并监听 EPOLLOUT当 EPOLLOUT 触发尝试继续发送输出缓冲区数据若发送完毕取消监听 EPOLLOUT读事件必须常设监听以持续感知对端数据到达而写事件必须按需设置即仅在应用层有数据待发送且因内核缓冲区满导致 write 返回 EAGAIN 时才临时注册 EPOLLOUT并在数据全部发出后立即移除以防止写事件始终就绪引发 epoll_wait 空转与 CPU 满载5.5.简易epollServer实现的细节gitee.com/dongfanqi/homework6/commit/3c29645059401652d7bbae1c40b29ccee26a87e21编程技巧禁止拷贝的类通过继承编译器自动禁止拷贝2使用类封装操作不同类进行组合并使用智能指针管理3epoll_ctl ADD 时需要 struct epoll_event 保存用户关心的 fd便于后续通知用户4epoll_ctl DEL 时确保 fd 合法先移除再关闭。若先执行 close(fd)内核虽会异步清理 epoll 节点但在清理完成前该整数值 fd 可能被操作系统立即回收并分配给新创建的连接导致 epoll 中残留的旧 epitem 节点错误地将事件注入到不相关的新连接上造成严重的逻辑串扰或程序崩溃只有先调用 EPOLL_CTL_DEL 将该 fd 从红黑树和等待队列中彻底摘除才能确保后续 close 的安全性5直接调用 write/send发送数据然后根据返回值判断缓冲区是否已满进而设置监听

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑