Linux 五种 I/O 模型与多路复用详解

发布时间:2026/9/4 8:01:14
Linux 五种 I/O 模型与多路复用详解 1 引言I/O 的两个阶段纵观我们学习计算机知识到现在I/O 一直相伴左右可我们还没有系统性地了解过总共有哪些类型的 I/O。要理解这些类型先把一次 I/O 拆成两个阶段等待数据从设备到达内核缓冲区例如等待网卡把数据送达、等待磁盘把数据读入页缓存拷贝数据在内核缓冲区与用户缓冲区之间搬运。我们常说 “硬件的 I/O 效率低”本质是 “等待的时间长”。以机械硬盘为例数据从外存拷贝到内存需要先寻址寻道移动磁臂到目标磁道旋转等待盘片转到目标扇区然后才是传输机械运动是毫秒级远慢于内存拷贝的纳秒级。固态硬盘没有磁头、磁臂但仍有寻址与介质访问延迟。无论采用哪种 I/O 模型只要进程参与了 “拷贝”这一步的数据搬运速率大体相同 —— 真正拉开差距的是 “等待阶段怎么等”。2 五种 I/O 模型总览I/O 大致可以分为阻塞式 I/O非阻塞式 I/O信号驱动式 I/O多路复用式 I/O异步 I/O其中前四种称为同步 I/O最后一种称为异步 I/O。关键区别在于进程是否参与了 I/O 的拷贝阶段无论等待阶段如何。判定标准不是 “是否等待”而是 “拷贝阶段进程是否参与”。五种模型的对照见表模型等待阶段谁负责拷贝阶段谁负责代表接口 / 关键字特点阻塞式 I/O进程睡眠进程read / write无数据即挂起实现最简单非阻塞式 I/O进程轮询进程O_NONBLOCK、fcntl立即返回需轮询或配合复用信号驱动式 I/O内核发信号通知进程SIGIO、F_SETOWN省去轮询有信号开销多路复用式 I/O内核统一等待进程select / poll / epoll一次等待覆盖 N 个 fd异步 I/O内核内核AIO / io_uring进程完全不参与 I/O 过程3 阻塞式 I/O阻塞式读取如果不存在可读数据调用线程就会被阻塞挂起、睡眠在那里直到数据就绪。需要说明的是阻塞语义主要体现在管道、socket、终端等 “可能暂时没有数据或空间” 的设备上普通文件的读写不会阻塞永远可读可写因此也不在多路复用的讨论范围内。4 非阻塞式 I/O非阻塞式读取如果不存在可读数据不会阻塞在那里而是立即返回程序继续往下执行。这里有一个容易被误导的说法 ——“非阻塞 I/O 效率高于阻塞式 I/O”。严格地说就单次 I/O 的数据拷贝而言阻塞与非阻塞的搬运速率是一样的非阻塞并不能让数据拷贝更快。非阻塞的优势在于 “等待阶段” 线程不被挂起可以把时间用去做其他事情从而提升整体并发效率。但要注意纯粹的 “非阻塞 忙轮询” 并不自动高效 —— 轮询本身也消耗 CPU如果轮询期间没有别的任务可做反而更浪费。非阻塞的真正价值在于配合事件循环 / 多路复用把 “等” 的时间让给其他 fd 的任务。4.1 如何设置非阻塞一切源于 struct file 的 f_flags我们曾在学习 fork、read、write、recvrecvfrom、sendsendto…… 的时候都有选项可以选择非阻塞但这些方法的本质只是临时修改 struct file 中的一个属性。struct file { union { struct llist_node fu_llist; struct rcu_head fu_rcuhead; } f_u; struct path f_path; struct inode *f_inode; /* cached value */ const struct file_operations *f_op; spinlock_t f_lock; atomic_long_t f_count; unsigned int f_flags; /* 文件状态标志含 O_NONBLOCK */ fmode_t f_mode; loff_t f_pos; struct fown_struct f_owner; /* 异步 I/O 所有权信号驱动用*/ struct file_ra_state f_ra; u64 f_version; ... };f_flags表示这个文件的属性其中包括 “阻塞还是非阻塞”O_NONBLOCK。判断是否非阻塞f_flags O_NONBLOCK不为零即对应标志位被置 1。可以想成位图一个比特位标记一种状态。所以只要能够修改这个属性就能完全控制不需要额外记忆其他方法。用 fcntl 来操作int fcntl(int fd, int cmd, ... /* arg */);cmd 共有五类复制一个现有的描述符F_DUPFD获得 / 设置文件描述符标志F_GETFD 或 F_SETFD获得 / 设置文件状态标志F_GETFL 或 F_SETFL获得 / 设置异步 I/O 所有权F_GETOWN 或 F_SETOWN获得 / 设置记录锁F_GETLK、F_SETLK 或 F_SETLKW。fcntl 是可变参数函数但并非所有 cmd 都需要填充 arg。一个文件默认是阻塞的设置成非阻塞的标准写法int flags fcntl(fd, F_GETFL); /* 读回文件状态标志 */ if (flags -1) { /* 出错处理 */ } fcntl(fd, F_SETFL, flags | O_NONBLOCK); /* 追加 O_NONBLOCK 位 */4.2 返回值与 errno读取时0 表示对端关闭读到 EOF0 表示读取成功0 表示读取失败。但在非阻塞情况下没有可读内容时返回值也是 0且系统层面并不通过返回值区分 “错误” 与 “暂时没有数据”。那到底靠什么分辨 0 的情况答错误码errno可以把它想成内核中一个 “当前线程私有” 的全局变量出错时被设置。相关的两条EAGAIN The file descriptor fd refers to a file other than a socket and has been marked nonblocking (O_NONBLOCK), and the read would block. EAGAIN or EWOULDBLOCK The file descriptor fd refers to a socket and has been marked nonblocking (O_NONBLOCK), and the read would block. POSIX.1-2001 allows either error to be returned for this case, and does not require these constants to have the same value, so a portable application should check for both possibilities.ssize_t n read(fd, buf, sizeof(buf)); if (n 0) { if (errno EAGAIN || errno EWOULDBLOCK) { /* 非阻塞下暂时无数据可稍后重试或先去处理其他 fd */ } else { /* 真正的错误 */ } }注意在 Linux 上EAGAIN与EWOULDBLOCK同值11判断两者其一即可可移植写法仍建议同时判断。4.3 补充CTRLD 与 EOF普及一个常识在终端按 CTRLD 表示让读取操作 “读到文件底部”—— 它把当前输入缓冲刷新若缓冲为空read 返回 0即 EOF。这类似于 C 流中把 eofbit 状态置为 1。5 信号驱动式 I/O什么叫信号驱动型 I/O理解成只要 fd 可读 / 可写内核就会通过信号SIGIO的方式提醒进程去操作。这种方式和非阻塞式 I/O 一样 “利用了等待时间”并且比非阻塞式更省 CPU非阻塞式需要不断轮询检查是否可读而这里直接等信号即可。代价是信号处理有上下文切换开销、触发时机不够精确所以它也不常用。其 “异步 I/O 所有权” 的设置正是前面 fcntl 的F_GETOWN/F_SETOWN。6 多路复用式 I/O多路转接—— 最常用、效率最高设想一个场景项目中存在 2000 个文件描述符需要 I/O 操作。如果逐个阻塞等待串行低效如果逐个非阻塞轮询忙转烧 CPU。多路复用采用 “同一段时间共同等待多个文件描述符” 的方式把对 N 个 fd 的就绪检测合并进一次系统调用谁就绪就处理谁从而极大提高整体 I/O 效率。这也正是 “多路转接” 名称的由来 —— 把多个 fd 的就绪 “转接” 到一次调用上。6.1 什么是 “事件就绪”读事件就绪fd 的接收缓冲区有数据可读或对端关闭此时 read 返回 0对 listen socket 而言全连接队列非空可 accept。写事件就绪fd 可写即发送缓冲区有空间可写。异常事件有带外紧急数据等。下面实现多路转接的三套接口本质思想都是在 “读 / 写事件就绪时进行通知”。6.2 selectint select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);第一个参数 nfds所有被监视 fd 的最大值 1。因为底层需要按 fd 下标遍历位图比如要管理 3、5、9 号 fd这里要填 10。第二个参数 readfds想管理读事件的文件描述符集。第三个参数 writefds想管理写事件的文件描述符集。第四个参数 exceptfds需要处理异常事件的文件描述符集。第五个参数 timeout指向struct timeval的指针表示最多等待多久。NULL无限期阻塞等待{tv_sec0, tv_usec0}立即返回轮询非阻塞正数等待该时长。struct timeval结构体struct timeval { time_t tv_sec; /* 秒 */ suseconds_t tv_usec; /* 微秒10^-6 s */ };fd_set是 long 类型的整数数组本质是位图。在 Ubuntu 22.04x86_64 glibc下FD_SETSIZE 1024fd_set 大小为128 字节 1024 位最多标记 0~1023 共1024 个文件描述符fd_set 的位图思想第 i 个比特位为 1表示管理 fd i。例如第 3 位为 1表示 fd 3 需要管理。glibc 提供了宏来避免手动位运算FD_ZERO(set); /* 清空集合 */ FD_SET(fd, set); /* 把 fd 对应位置 1 */ FD_CLR(fd, set); /* 把 fd 对应位清零 */ FD_ISSET(fd, set); /* 判断 fd 是否就绪非 0 表示就绪 */注意fd 为负数或 FD_SETSIZE 时FD_SET/FD_CLR是未定义行为。select 的关键点除了第一个参数 nfds其他四个都是输入输出型参数。timeval返回时表示 “剩余等待时间”若在规定时间内没有就绪返回的tv_sec/tv_usec被置为 0。readfds/writefds/exceptfds调用时传入 “要管理哪些 fd”返回时被内核改写为 “哪些 fd 就绪”。若超时且无就绪三个集合全部清零。因此每次调用 select 都必须重新设置参数—— 这是它最不方便的地方。判断某个 fd 是否就绪不要自己去数位图下标而是用FD_ISSET(fd, set)比对若对应位为 0表示未就绪。返回值0 表示共有多少个 fd 就绪0 表示超时且无 fd 就绪-1 表示出错errno。只要返回值 0 就必然是发生了异常。select 缺点每次调用都要手动设置 fd 集合接口使用不便每次调用都要把 fd 集合从用户态拷贝到内核态fd 很多时开销很大每次调用内核都要遍历传入的所有 fdfd 很多时开销也很大支持的 fd 数量太小受FD_SETSIZE 1024限制。6.3 pollint poll(struct pollfd *fds, nfds_t nfds, int timeout); struct pollfd { int fd; /* 文件描述符 */ short events; /* 请求监视的事件位图用 | 登记 */ short revents; /* 返回的就绪事件内核写入用 检测 */ };第一个参数 fds需要管理的 pollfd 数组注意是数组指针不是按值传。events 用|登记标志位revents 表示就绪事件。events 与 revents 分离调用后 events 不会被改写 —— 相比 select不需要每次重新写参数。检测特定事件就绪用。异常事件只出现在 revents 中。第二个参数 nfdspollfd 数组的长度 —— 因此 poll 不再受FD_SETSIZE限制不再被系统束缚。第三个参数 timeout等待时间单位毫秒。0 表示无限阻塞0 表示立即返回轮询0 表示等待该毫秒数。与 select 不同select 用NULL指针表示阻塞poll 用负数表示阻塞且 poll 的 timeout 不是输出型参数。返回值与 select 一样。poll 缺点内核态开销与 select 相同poll 返回后需要轮询 pollfd 数组来找出就绪的描述符每次调用 poll 都要把大量 pollfd 结构从用户态拷贝到内核大量客户端在同一时刻往往只有很少就绪随着监视的描述符数量增长效率线性下降。但相比 selectpoll 操作更简单且应用场景有所提升无 1024 上限。6.4 epollint epoll_create(int size); int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);epoll 是最常用的多路复用方式。先明确使用场景多路复用监听的是 “可能暂时不可用、需要等待” 的 fd。普通文件永远就绪不需要监听而管道匿名管道、FIFO、socket、eventfd、timerfd、signalfd、tty 等凡支持 poll 接口的 fd都可以用 epoll 监听。工程上 epoll 主要面向大量 socket 连接如 C10K 级并发。epoll_create(size)表面上创建了一个 struct file 并注册进当前进程的 fd 表返回其文件描述符其private_data指向内核中的 struct eventpoll。eventpoll 内部并非只有一棵红黑树而是三件套 —— 红黑树rbr管理所有注册的 epitem、就绪双向链表rdllist挂已就绪的 epitem、等待队列wq让 epoll_wait 睡眠。size 表示 “预期监视的文件描述符个数”Linux 2.6.8 之后已被忽略只要大于 0 即可现代推荐用epoll_create1(0)。epoll_ctl(epfd, op, fd, event)管理红黑树。第一个参数 epfd对应的 epoll 文件描述符。第二个参数 opEPOLL_CTL_ADD注册新的 fd 到 epfdEPOLL_CTL_MOD修改已注册 fd 的监听事件EPOLL_CTL_DEL从 epfd 删除一个 fd。第三个参数 fd想管理的文件描述符。第四个参数 eventstruct epoll_event { uint32_t events; /* 监听/返回的事件 */ epoll_data_t data; }; typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t;events 和 poll 的 events 一样是位图可选EPOLLIN可读含对端 socket 正常关闭EPOLLOUT可写EPOLLPRI有带外紧急数据可读EPOLLERR发生错误无需注册总是会被上报EPOLLHUP被挂断无需注册总是会被上报EPOLLET设为边缘触发Edge Triggered相对水平触发Level TriggeredEPOLLONESHOT只监听一次触发后如需继续监听该 socket需再次用EPOLL_CTL_MOD重新武装。epoll_ctl 就是对红黑树的管理。ADD 时内核会创建一个 epitem 结构体作为红黑树节点MOD / DEL 都是对这个 epitem 操作struct epitem { struct rb_node rbn; /* 红黑树节点 */ struct list_head rdllink; /* 双向链表节点就绪链表 */ struct epoll_filefd ffd; /* 事件句柄信息 */ struct eventpoll *ep; /* 指向所属 eventpoll 对象 */ struct epoll_event event; /* 期待发生的事件类型 */ };epoll_wait(epfd, events, maxevents, timeout)第一个参数 epfd红黑树对应的 epoll 文件描述符。第二个参数 events输出型参数内核把已就绪的事件写入 events 数组通过 epoll_data_t 中的 fd 找到对应文件描述符events 表示就绪的事件。第三个参数 maxevents单次最多返回的 epoll_event 个数。第四个参数 timeout等待时间语义同 poll0 阻塞、0 轮询、0 毫秒。返回值与 select /poll 一样。机制当某个被监视 fd 的事件就绪内核把对应 epitem 挂到 eventpoll 的就绪链表rdllistepoll_wait 实际上就是从这个链表里取出就绪节点返回。6.5 epoll 的通知机制为什么不需要遍历前面讲过 socket 底层存在数据就绪回调void (*sk_data_ready)(struct sock *sk, int bytes);epoll_ctl (ADD) 时内核创建 epitem 插入红黑树同时把该 fd 加入其 “等待队列”并把自己的回调ep_poll_callback负责把 epitem 挂到就绪链表并唤醒等待者注册进该文件对象的等待队列。之后当数据真正到达文件对象会唤醒等待队列ep_poll_callback 被调用把 epitem 拷入就绪链表。数据从网卡到就绪链表硬件侧补全网卡收到帧后通过DMA直接写入内存中的环形缓冲区rx ring不占 CPU网卡触发硬件中断驱动在硬中断里只做必要登记NAPI机制下随即关闭该队列中断转入软中断softirq批量处理软中断把 sk_buff 逐层上交链路层MAC 校验、协议分派含 ARP 处理→ 网络层IP 组包、路由→ 传输层TCP 按四元组定位 socket完成校验与重组处理后的数据放入对应 socket 的接收缓冲区接收队列内核感应到有数据来了调用该 socket 的sk_data_ready回调唤醒该 socket 等待队列上的等待者ep_poll_callback被触发把对应 epitem 加入 eventpoll 的就绪链表并唤醒阻塞在 epoll_wait 上的线程epoll_wait返回进程从 events 中取出就绪的 fd 进行读写。对比 select /poll它们每次都要把全部 fd 拷贝进内核、逐个调用 poll 检查就绪状态而 epoll 只在 “有事件发生” 时被回调就绪集合直接可用无需遍历全部 fd。这正是 epoll 在大量连接下高效近似 O (1)与就绪事件数相关的根本原因。普通文件不能用 epoll 的真正原因是普通文件没有等待队列 / 唤醒回调机制永远就绪无从 “通知”而不是 “文件类型不合法”。6.6 LT 与 ET水平触发与边缘触发水平触发LT默认只要 “条件仍成立”如接收缓冲区还有数据没读完epitem 就会一直待在就绪链表每次 epoll_wait 都会上报。即使你一次只读一部分剩余数据下次仍会再被通知。边缘触发ET只在 “条件从不满足变为满足” 的边沿通知一次。通知后无论你是否读完都不会再次上报除非下一次新的数据到达缓冲区重新从空变非空。所以ET 要求一旦被通知就要尽可能把所有数据读完读到 EAGAIN否则剩余数据要等下一个事件沿才能被读到可能长时间滞留。。即ET会将epitem从就绪链表中删除经典场景listen 套接字收到三次握手、全连接队列非空时epoll 会通知但你不知道总共有多少个连接。LT 下每次只 accept 一个也没关系只要队列里还有连接下次 epoll_wait 仍会通知ET 下如果一次没把全连接队列 accept 完队列剩余的连接不会再有通知必须循环 accept 到返回 EAGAIN或队列为空否则会漏掉连接。ET 为何通常效率更高延迟应答的配合TCP 正常收包后主机会稍作延迟再 ACK延迟应答机制把多个包合并确认减少小包与 ACK 的往返。ET 迫使程序尽快把接收缓冲区读空内核能及时向对端通告更大的接收窗口对端在窗口内可连续发送更多数据配合延迟应答整体吞吐更高。LT 也能做到只是不强制取决于程序是否每次读空。这是 “通常” 的优势并非绝对。6.7 如何读取读到 EAGAIN 为止read 返回值的含义读到数据返回实际字节数返回 0 表示对端关闭EOF返回 -1 且 errno EAGAIN 表示 “本次没有更多数据”。流式 socket 完全可能先返回部分数据、随后缓冲区还有更多短读不能作为 “没有更多数据” 的证据。唯一可靠的做法是循环读非阻塞下读到 -1 / EAGAIN 才停。for (;;) { ssize_t n read(fd, buf, sizeof(buf)); if (n 0) { /* 处理 n 字节 */ continue; } if (n 0) { /* 对端关闭 */ break; } if (errno EAGAIN || errno EWOULDBLOCK) break; /* 读空退出 */ /* 其他错误 */ break; }6.8 select /poll/epoll 对比维度selectpollepoll数据结构fd_set 位图固定 1024pollfd 数组无上限红黑树 就绪链表每次调用是否重设集合是fd_set 被内核改写不必events/revents 分离注册一次即可内核态开销全量拷贝 线性遍历全量拷贝 线性遍历事件回调驱动近似 O (1)最大 fd 数FD_SETSIZE1024无固定上限无固定上限超时语义NULL 阻塞{0,0} 轮询正数 限时0 阻塞0 轮询0 毫秒同 poll适用场景fd 少、简单、可移植fd 较多、无上限大量连接C10K 级平台可移植Windows 也有类 UnixLinux 专有7 异步 I/O不常用将 I/O 操作含拷贝完全交给内核进程 A 直接把任务分配给内核之后不再关心该任务的相关 I/O 操作当然这同样没有提高数据搬运效率。进程发起后立即返回内核完成后通知。Linux 传统异步接口libaio用得少、限制多近年 io_uring 提供了更完善的异步方案。8 总结阻塞 / 非阻塞决定 “等待阶段线程是否挂起”非阻塞的收益来自 “把等待让给其他任务”而不是让单次拷贝更快。信号驱动把等待交给信号省去轮询。多路复用一次等待覆盖 N 个 fdselect /poll 靠 “全量拷贝 线性遍历”epoll 靠 “回调 就绪链表”因此 epoll 在大量连接下最高效。异步 I/O 连拷贝都交给内核。工程要点errno 用 EAGAIN判断 “暂时无数据”用 fcntl 的F_GETFL/F_SETFL设置O_NONBLOCKET 下循环读到 EAGAINLT 下可读一部分、下次继续