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、recv(recvfrom)、send(sendto)…… 的时候都有选项可以选择非阻塞,但这些方法的本质,只是临时修改 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 表示对端关闭(读到 EOF);>0 表示读取成功;<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 补充:CTRL+D 与 EOF
普及一个常识:在终端按 CTRL+D 表示让读取操作 “读到文件底部”—— 它把当前输入缓冲刷新;若缓冲为空,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 select
int 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_sec=0, tv_usec=0}:立即返回(轮询,非阻塞);- 正数:等待该时长。
struct timeval结构体:
struct timeval { time_t tv_sec; /* 秒 */ suseconds_t tv_usec; /* 微秒(10^-6 s) */ };fd_set是 long 类型的整数数组,本质是位图。在 Ubuntu 22.04(x86_64 glibc)下,FD_SETSIZE = 1024,fd_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 很多时开销很大;
- 每次调用内核都要遍历传入的所有 fd,fd 很多时开销也很大;
- 支持的 fd 数量太小(受
FD_SETSIZE = 1024限制)。
6.3 poll
int 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 中。 - 第二个参数 nfds:pollfd 数组的长度 —— 因此 poll 不再受
FD_SETSIZE限制(不再被系统束缚)。 - 第三个参数 timeout:等待时间,单位毫秒。<0 表示无限阻塞;0 表示立即返回(轮询);>0 表示等待该毫秒数。与 select 不同:select 用
NULL指针表示阻塞,poll 用负数表示阻塞;且 poll 的 timeout 不是输出型参数。 - 返回值:与 select 一样。
poll 缺点(内核态开销与 select 相同):
- poll 返回后,需要轮询 pollfd 数组来找出就绪的描述符;
- 每次调用 poll 都要把大量 pollfd 结构从用户态拷贝到内核;
- 大量客户端在同一时刻往往只有很少就绪,随着监视的描述符数量增长,效率线性下降。
但相比 select,poll 操作更简单,且应用场景有所提升(无 1024 上限)。
6.4 epoll
int 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 文件描述符。
- 第二个参数 op:
EPOLL_CTL_ADD:注册新的 fd 到 epfd;EPOLL_CTL_MOD:修改已注册 fd 的监听事件;EPOLL_CTL_DEL:从 epfd 删除一个 fd。
- 第三个参数 fd:想管理的文件描述符。
- 第四个参数 event:
struct 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 Triggered);EPOLLONESHOT:只监听一次;触发后如需继续监听该 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:等待时间,语义同 poll(<0 阻塞、0 轮询、>0 毫秒)。
- 返回值:与 select /poll 一样。
机制:当某个被监视 fd 的事件就绪,内核把对应 epitem 挂到 eventpoll 的就绪链表(rdllist);epoll_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 对比
| 维度 | select | poll | epoll |
|---|---|---|---|
| 数据结构 | fd_set 位图(固定 1024) | pollfd 数组(无上限) | 红黑树 + 就绪链表 |
| 每次调用是否重设集合 | 是(fd_set 被内核改写) | 不必(events/revents 分离) | 注册一次即可 |
| 内核态开销 | 全量拷贝 + 线性遍历 | 全量拷贝 + 线性遍历 | 事件回调驱动,近似 O (1) |
| 最大 fd 数 | FD_SETSIZE=1024 | 无固定上限 | 无固定上限 |
| 超时语义 | NULL = 阻塞;{0,0}= 轮询;正数 = 限时 | <0 = 阻塞;0 = 轮询;>0 = 毫秒 | 同 poll |
| 适用场景 | fd 少、简单、可移植 | fd 较多、无上限 | 大量连接(C10K 级) |
| 平台 | 可移植(Windows 也有) | 类 Unix | Linux 专有 |
7 异步 I/O(不常用)
将 I/O 操作(含拷贝)完全交给内核:进程 A 直接把任务分配给内核,之后不再关心该任务的相关 I/O 操作(当然这同样没有提高数据搬运效率)。进程发起后立即返回,内核完成后通知。Linux 传统异步接口(libaio)用得少、限制多;近年 io_uring 提供了更完善的异步方案。
8 总结
- 阻塞 / 非阻塞决定 “等待阶段线程是否挂起”;非阻塞的收益来自 “把等待让给其他任务”,而不是让单次拷贝更快。
- 信号驱动把等待交给信号,省去轮询。
- 多路复用一次等待覆盖 N 个 fd:select /poll 靠 “全量拷贝 + 线性遍历”,epoll 靠 “回调 + 就绪链表”,因此 epoll 在大量连接下最高效。
- 异步 I/O 连拷贝都交给内核。
- 工程要点:errno 用
== EAGAIN判断 “暂时无数据”;用 fcntl 的F_GETFL/F_SETFL设置O_NONBLOCK;ET 下循环读到 EAGAIN;LT 下可读一部分、下次继续