2. 从阻塞IO到IO多路复用
要理解 epoll 为什么会出现,必须先把 Linux 网络编程的底层对象和几种 IO 模型串起来看。很多初学者直接跳到 epoll 的 API,结果对“为什么需要它”缺乏直觉,遇到高并发场景时也无法判断该选哪种模型。本节从文件描述符这一基础概念出发,沿着阻塞 IO、非阻塞 IO、IO 多路复用的演进路径,说明 select/poll 的瓶颈究竟在哪里,以及 epoll 是被什么问题“逼”出来的。
一、文件描述符:一切 IO 的统一入口
在 Linux 中,文件描述符(File Descriptor,fd)是一个非负整数,是进程访问内核 IO 资源的句柄。无论是普通文件、管道、终端,还是网络套接字 socket,在内核里都被抽象成“文件”,进程通过 fd 来引用它们。socket 创建后返回的也是一个 fd,例如:
int listen_fd = socket(AF_INET, SOCK_STREAM, 0); // 返回如 3
int conn_fd = accept(listen_fd, ...); // 返回如 4
fd 本身只是索引,真正的状态(读缓冲区、写缓冲区、连接状态、阻塞标志等)保存在内核的 file 结构里。理解这一点很关键:IO 操作的本质是“用户态进程与内核缓冲区之间搬运数据”,而 fd 是这次搬运的凭据。后面所有模型讨论的,都是“进程如何知道某个 fd 已经可读/可写,以及如何等待”的问题。
二、阻塞 IO:最简单,也最昂贵
默认情况下,socket 是阻塞的。当进程调用 recv(fd, buf, len, 0) 时,如果该 fd 的接收缓冲区没有数据,进程会被内核挂起,从运行队列移入等待队列,直到数据到达才被唤醒。整个过程分两个阶段:
- 等待数据就绪:数据从网络到达网卡,经协议栈处理后进入 socket 接收缓冲区;
- 拷贝数据:内核把数据从内核缓冲区复制到用户态 buf。
阻塞 IO 的优点是编程模型直观,一个连接一个线程即可。但问题在于:一个线程在同一时刻只能阻塞在一个 fd 上。要同时处理 1 万个连接,就需要约 1 万个线程。线程本身占用内存(默认栈通常 8MB,可调小),上下文切换开销随线程数增长,调度器压力剧增。这就是经典的 C10K 问题:连接数一上去,系统资源先被线程耗尽,而不是被业务逻辑耗尽。
三、非阻塞 IO:轮询的代价
把 fd 设为非阻塞(fcntl(fd, F_SETFL, O_NONBLOCK))后,recv 在无数据时不再挂起,而是立即返回 -1 并置 errno = EAGAIN 或 EWOULDBLOCK。这样单线程就能“照看”多个 fd:
for (int i = 0; i < n; i++) {
int r = recv(fds[i], buf, len, 0);
if (r > 0) handle(buf, r);
else if (r == -1 && (errno == EAGAIN || errno == EWOULDBLOCK))
continue; // 本次无数据,跳过
else close(fds[i]);
}
但这是忙轮询:即使 1 万个 fd 里只有 3 个有数据,也要遍历全部 1 万次,绝大多数系统调用是空转。CPU 被大量无效的 recv 占满,效率极低。非阻塞 IO 解决了“一个线程只能等一个 fd”的问题,却引入了“不知道哪个 fd 就绪”的新问题。
四、IO 多路复用:让内核替我们“盯”fd
IO 多路复用的思路是:把一批 fd 交给内核,由内核监视它们的状态,进程只阻塞在“监视”这一个调用上,返回时告诉进程哪些 fd 就绪。这样单线程就能高效管理大量连接。select 和 poll 是这一思路的早期实现。
select 使用位图(fd_set)传递 fd 集合,受 FD_SETSIZE 限制,通常为 1024;每次调用都要把整个集合从用户态拷贝到内核态,返回后还要遍历所有 fd 用 FD_ISSET 判断谁就绪。poll 用 struct pollfd 数组替代位图,突破了 1024 的数量限制,但拷贝和遍历的问题依旧。
它们的共同瓶颈可以归纳为三点:
- O(n) 拷贝:每次调用都要把 fd 集合整体复制进内核;
- O(n) 遍历:返回后用户态必须逐个检查哪个 fd 就绪;
- O(n) 内核扫描:内核每次也要轮询整个集合,连接越多越慢。
当连接数达到数万时,即使只有少量活跃连接,select/poll 也要为全部 fd 付出拷贝和扫描成本,性能随连接数线性下降。这正是高并发服务器无法接受的。
五、epoll 的诞生动机与关键设计
epoll 要解决的核心问题就是:在连接数很大、活跃连接比例很低时,如何避免为“不活跃的 fd”反复付出拷贝和扫描成本。它的设计围绕三点展开:
- 注册与等待分离:
epoll_ctl把 fd 一次性注册进内核的红黑树,之后长期有效,不再每次调用都拷贝整个集合; - 事件回调机制:fd 就绪时由内核回调把它加入“就绪链表”,
epoll_wait直接取就绪链表,无需遍历全部 fd; - 只返回就绪项:返回的数组长度就是就绪 fd 个数,用户态无需再判断谁就绪。
因此 epoll 的复杂度从 select/poll 的 O(n) 降到与活跃连接数相关,在“连接多、活跃少”的典型互联网服务场景下优势巨大。需要强调的是,epoll 并非在所有场景都更快:如果连接数很少且几乎全部活跃,select/poll 的常数开销反而可能更小。选择模型要结合连接规模与活跃比例来判断。
把这条演进线记住:fd 是 IO 的句柄,阻塞 IO 卡在“一个线程等一个 fd”,非阻塞 IO 卡在“不知道谁就绪只能轮询”,select/poll 卡在“每次全量拷贝与扫描”,而 epoll 用“注册一次 + 就绪回调”把等待成本与总连接数解耦。理解了每个模型被什么问题卡住,后续学习 epoll 的 LT/ET 模式、epoll_ctl 操作和事件循环时,就能明白每一步设计背后的动机。