预备知识:fd 与”就绪”

fd 是什么

fd(file descriptor,文件描述符)是一个非负整数,是进程访问内核资源的句柄。

1
2
3
4
5
进程的 fd 表          内核打开文件表            实际对象
0 ─────────────► struct file ───────────► 终端(stdin)
1 ─────────────► struct file ───────────► 终端(stdout)
3 ─────────────► struct file ───────────► socket(TCP 连接)
4 ─────────────► struct file ───────────► 管道 / 磁盘文件 ...
  • Linux “一切皆文件”:普通文件、socket、管道、终端,以及 eventfd、timerfd、signalfd 都用 fd 表示。
  • 0/1/2 分别是标准输入、标准输出、标准错误。
  • 网络编程里,每个 TCP 连接对应一个 fd,”监视多个 fd”就是”监视多个连接”。
  • 普通磁盘文件不能放进 epoll(epoll_ctl 返回 EPERM):磁盘文件永远被认为”就绪”,多路复用对它没意义。

什么叫”就绪”

就绪 = 此刻对该 fd 执行读/写,不会阻塞。 并不保证数据一定读得完,只保证这次调用能立刻返回。

以 TCP socket 为例:

类型 触发条件
可读 ① 接收缓冲区有数据(≥ SO_RCVLOWAT,默认 1 字节)
② 对端关闭连接(收到 FIN,read 返回 0)
③ 监听 socket 的已完成连接队列有新连接(accept 不会阻塞)
④ socket 上有待处理的错误
可写 ① 发送缓冲区有足够空闲空间
② 非阻塞 connect 完成(成功或失败都会变可写)
③ 出错时也会触发可写
异常 select 的 exceptfds 对应带外数据(OOB);epoll 对应 EPOLLPRI

epoll 额外的事件位:

  • EPOLLIN / EPOLLOUT:可读 / 可写
  • EPOLLRDHUP:对端关闭或半关闭(不必等 read 返回 0 才发现)
  • EPOLLERR / EPOLLHUP:出错 / 挂断(无需主动注册,内核总会报告)

实用结论:新连接的发送缓冲区几乎总是空的,所以一开始就可写。若一上来就注册 EPOLLOUT 且用 LT 模式,epoll_wait 会一直立刻返回(忙等)。正确做法:只在”写不完”时才注册 EPOLLOUT,写完立即取消。

“为什么需要 I/O 多路复用”

传统”一连接一线程”模型,连接数上万时线程切换和内存开销太大。I/O 多路复用的核心思想是:用一个线程同时监视多个 fd,哪个就绪了就处理哪个,避免为每个连接阻塞等待。

一连接一线程 I/O 多路复用
内存 每线程栈约 8MB(虚拟),万级连接压力大 一个线程管成千上万连接
切换 频繁上下文切换 极少
适用 连接少、逻辑重 连接多、单次处理轻(IO 密集)

实际工业方案通常是 多线程 + 多路复用:one loop per thread(muduo 模型),每个线程各自一个 epoll,主线程负责 accept 并分发连接。

四种机制逐个讲

select(最古老,POSIX 通用)

1
2
int select(int nfds, fd_set* readfds, fd_set* writefds,
fd_set* exceptfds, struct timeval* timeout);
  • 用 fd_set 位图存 fd,默认最多 1024 个(FD_SETSIZE)。
  • 每次调用都要把 fd_set 从用户态拷贝到内核态,返回后还要遍历全部 fd 找就绪的,复杂度 **O(n)**。
  • fd_set 会被内核修改,所以每次循环都要重新设置。
  • 优点:跨平台、超时精度到微秒。

内部工作流程:

  1. 传入 3 个位图(读/写/异常)和 nfds(最大 fd + 1)。
  2. 内核把位图拷贝进内核。
  3. 内核从 0 遍历到 nfds-1,对每个被关注的 fd 调用其 poll 方法检查是否就绪。
  4. 都没就绪就睡眠;被唤醒后再遍历一遍。
  5. 把结果位图拷贝回用户态(覆盖原位图,所以每轮要重置)。
  6. 用户态再遍历一遍找出被置位的 fd。

O(n) 体现在:内核遍历 + 用户遍历 + 来回拷贝,连接越多越慢。

poll(select 的改进)

1
int poll(struct pollfd* fds, nfds_t nfds, int timeout);
  • 用 pollfd 数组代替位图,没有 1024 上限。
  • 事件(events)和结果(revents)分开,不用每次重置。
  • 但本质没变:仍然**每次拷贝整个数组 + 线性遍历,O(n)**。

epoll(Linux 高性能方案)

1
2
3
int epoll_create1(int flags);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* ev); // ADD/MOD/DEL
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);

核心改进:

  • fd 只注册一次(epoll_ctl),不用每次调用重复传入,省掉拷贝。
  • 内核用 红黑树管理所有被监视的 fd,用 就绪链表存放已就绪的 fd。
  • 当 fd 就绪时,内核通过回调把它挂到就绪链表,epoll_wait 直接返回就绪列表,复杂度 **O(1)**(与总连接数无关,只与活跃连接数有关)。
  • 无 fd 数量硬上限(受系统内存和 ulimit 限制)。

内部原理:

epoll_create 在内核创建一个 eventpoll 对象,包含三样东西:

  • 红黑树:存所有注册的 fd(ADD/MOD/DEL 快速查找去重,O(log n))
  • 就绪链表 rdllist:存已就绪的 fd
  • 等待队列:存阻塞在 epoll_wait 上的线程

流程:

  1. epoll_ctl(ADD):把 fd 插入红黑树,**同时给该 socket 的等待队列挂一个回调 ep_poll_callback**。
  2. 网卡收到数据 → 中断 → 协议栈处理 → 数据进入 socket 接收缓冲区 → 触发回调 → 回调把该 fd 挂到 rdllist,并唤醒 epoll_wait 的线程。
  3. epoll_wait:链表为空就睡眠,非空就把就绪事件拷贝到用户态返回。

关键点:不再遍历所有 fd,而是事件到来时由回调主动把就绪 fd 送进链表。

两种触发模式(必考):

LT(水平触发,默认) ET(边缘触发)
触发时机 只要缓冲区有数据/可写就一直通知 仅在状态变化时通知一次
编程要求 可以不一次读完 必须循环读到 EAGAIN,且 fd 必须设为非阻塞
特点 简单、不易丢事件 系统调用次数少、效率高,但容易写错

用例子理解:socket 缓冲区有 2000 字节,这次只读了 500 字节——

  • LT:epoll_wait 返回前,内核发现该 fd 仍然就绪,会把它重新放回就绪链表,下次还会通知。
  • ET:不会放回。除非又有新数据到达,否则永远不再通知,剩下 1500 字节就”饿死”了。

ET 模式读数据(fd 必须非阻塞):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
while (true) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) {
// 处理数据
} else if (n == 0) {
close(fd); // 对端关闭
break;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) break; // 读空了
if (errno == EINTR) continue; // 被信号打断,重试
close(fd); // 真正的错误
break;
}
}

监听 socket 用 ET 时,accept 也要循环到 EAGAIN,否则多个连接同时到达会漏掉。

设为非阻塞:

1
2
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

IOCP(Windows 的完成端口)

  • 关键区别:select/poll/epoll 都是 Reactor 模型(通知你”可以读了”,你自己去读),而 IOCP 是 Proactor 模型(异步 I/O,内核替你把数据读好,通知你”读完了”)。
  • 流程:创建完成端口 → 将 socket 与之绑定 → 投递异步操作(WSARecv/AcceptEx 等)→ 工作线程调用 GetQueuedCompletionStatus 获取完成事件。
  • 通常配合线程池,线程数约等于 CPU 核数的 1~2 倍。
1
2
3
4
5
6
7
8
9
// 1. 创建完成端口
HANDLE iocp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
// 2. 把 socket 绑定到完成端口
CreateIoCompletionPort((HANDLE)sock, iocp, (ULONG_PTR)ctx, 0);
// 3. 投递异步读请求(带 buffer 和 OVERLAPPED),立即返回
WSARecv(sock, &wsabuf, 1, NULL, &flags, &overlapped, NULL);
// 4. 工作线程等待"完成"事件
GetQueuedCompletionStatus(iocp, &bytes, &key, &pOverlapped, INFINITE);
// 此时数据已经在 buffer 里了,直接处理即可
  • 内核维护完成队列;工作线程池从中取已完成的事件。
  • 内核控制并发线程数(创建时指定),并优先唤醒最近运行过的线程(LIFO),减少上下文切换。

Reactor vs Proactor 类比:

  • Reactor:外卖做好了,店家打电话通知你,你自己下楼去取(内核通知可读,你自己 read)。
  • Proactor:你提前下单并说明送到哪,外卖直接送到你手里,送到后再通知你(你提前提供 buffer,内核读完放进去,再通知”读完了”)。

对比总结(面试时可直接背)

select poll epoll IOCP
平台 跨平台 Unix Linux Windows
fd 上限 1024 无 无 无
数据结构 位图 数组 红黑树+就绪链表 完成队列
时间复杂度 O(n) O(n) O(1)(活跃数) O(1)
模型 Reactor Reactor Reactor Proactor
用户态/内核拷贝 每次 每次 注册一次 —

面试高频追问

  1. epoll 一定比 select 快吗?
    不一定。连接数少、且大部分都活跃时,select/poll 因为简单,开销差不多甚至更低;epoll 的优势在连接数大、活跃连接占比小的场景。

  2. ET 为什么必须非阻塞?
    ET 要求一次性读完数据,需要循环 read 直到返回 EAGAIN。如果 fd 是阻塞的,最后一次 read 会卡住整个线程。

  3. LT 为什么也建议配非阻塞?
    “就绪”通知可能是过期的:比如多线程下另一个线程已把数据读走,或内核收到数据但校验失败丢弃。此时对阻塞 fd 再 read 会卡死线程;非阻塞 fd 只会返回 EAGAIN,更安全。

  4. epoll 为什么高效?(三点)
    ① fd 注册一次,避免重复拷贝;② 红黑树管理,增删查 O(log n);③ 事件驱动回调 + 就绪链表,不用遍历全部 fd。

  5. epoll 的 EPOLLONESHOT 是什么?
    事件触发一次后自动禁用该 fd,需要重新 MOD 启用。
    解决的问题:线程 A 正在处理 fd 5,fd 5 又来新数据,epoll_wait 唤醒线程 B 也去处理 fd 5,两个线程同时操作同一连接导致数据错乱。EPOLLONESHOT 保证同一时刻只有一个线程处理该 fd,处理完再重新武装。

  6. Reactor 和 Proactor 区别?
    Reactor:内核通知”可读/可写”,应用自己完成 I/O;Proactor:内核完成 I/O 后通知应用。Linux 的真异步 I/O 支持较弱(io_uring 是新方向),所以 Linux 上主流是 Reactor。

  7. 为什么不直接用多线程?
    见前文对比表:万级连接下线程栈内存和上下文切换开销过大;通常采用”多线程 + 多路复用”组合。

一分钟回答模板

“I/O 多路复用是让一个线程同时监听多个 fd 的机制。select 用位图,有 1024 限制,每次都要拷贝并线性扫描;poll 改用数组去掉了数量上限,但仍是 O(n);epoll 是 Linux 下的方案,fd 只注册一次,内核用红黑树管理、就绪链表返回,复杂度与活跃连接数相关,适合高并发,并且支持 LT 和 ET 两种模式,ET 需要配合非阻塞 I/O 循环读取。IOCP 是 Windows 的方案,和前三者最大不同是它属于 Proactor 异步模型,由内核完成读写后再通知应用,通常搭配线程池使用。”