Linux IO复用
这是一个非常核心的概念,是构建高性能网络服务器(如 Nginx、Redis、Node.js)的基石。
什么是IO复用?
“复用” 指的是用一个进程(或线程)来同时监听多个 I/O 事件(例如多个网络套接字的可读、可写、异常等),而不是为每个连接创建一个线程/进程。
-
传统阻塞 I/O 的问题:如果一个进程使用 read() 系统调用从 socket 读取数据,但数据还没到来,进程就会被操作系统挂起(阻塞),什么也做不了,直到数据到达。如果要处理 1000 个连接,就需要 1000 个线程或进程,上下文切换的开销巨大,资源消耗也极高。
-
I/O 复用的解决方案:让一个进程“守护”在多个 socket 上。当其中任何一个 socket 有我们关心的事件发生时(比如数据可读了),这个进程就会被唤醒,然后处理那个就绪的 socket。这样,一个进程就可以高效地管理成千上万的网络连接。
一个生动的比喻:
一个餐厅(操作系统)有很多桌客人(Socket 连接)。
-
阻塞 I/O:一个服务员(进程)专门服务一桌客人。客人点完菜,服务员就一直站在厨房门口等菜做好,期间不能做任何其他事。餐厅需要雇佣大量服务员。
-
I/O 复用:一个服务员(进程)同时照看多桌客人。她记下每桌客人的需求(注册到 epoll),然后就去忙别的了。当厨房通知“A桌的菜好了”(事件就绪),服务员就去给A桌上菜。一个服务员就能照看整个大厅。
为什么需要 I/O 复用?
-
高性能与高并发:在连接数非常高的场景下(C10K、C1000K 问题),创建大量进程/线程是不可行的。I/O 复用可以用 1 个或少量几个 进程/线程处理所有连接,极大减少资源消耗。
-
避免阻塞:应用程序无需在等待某个慢速 I/O 操作(如网络请求)时完全停止,可以充分利用 CPU 时间去处理其他已经就绪的任务。
主要的 I/O 复用机制
Linux 提供了三种主要的机制,它们不断演进,能力也越来越强。
select - 第一代
工作原理:
-
应用程序将所有需要监听的文件描述符 (fd) 的集合通过 FD_SET 宏添加到 fd_set 中。
-
调用 select 函数,将 fd_set(可读、可写、异常集合)传入内核。
-
内核会线性扫描所有传入的 fd,判断是否有事件发生。
-
当有事件发生或超时后,select 返回,并修改 fd_set 集合,只保留就绪的 fd。
-
应用程序需要遍历整个 fd_set 才能找到哪些 fd 就绪了。
缺点:
-
监听的 fd 数量有上限:通常由 FD_SETSIZE(默认 1024)定义,编译时确定,难以修改。
-
性能随 fd 数量线性下降:每次调用都需要将整个 fd_set 从用户态拷贝到内核态,内核也需要遍历所有 fd,返回后用户态还需要再次遍历。fd 越多,开销越大。(重新遍历是因为:select 系统调用修改的是用户空间中的原始 fd_set,而不是内核内部的拷贝。)
-
内核会修改传入的 fd_set,因此每次调用前都需要重新设置参数。
-
你调用 FD_SET 将你关心的 fd 添加到 readfds 等集合中。
-
然后你调用 select,并将这些集合传入内核。
-
select 返回时,内核会修改这些集合。它会将所有未就绪的 fd 对应的比特位清零,只保留那些就绪的 fd 的比特位为 1。
-
这意味着,下一次调用 select 之前,你必须使用 FD_SET 重新添加所有你关心的 fd,因为上一次调用可能已经把你精心设置的集合改得面目全非了。
-
fd_set 是一个数据结构,用于代表一个“文件描述符的集合”。它本质上是一个位图(bit mask),其中的每一个比特(bit)代表一个文件描述符(file descriptor, fd)。
-
如果某个比特被设置为 1,则表示这个文件描述符被包含在集合中,是 select 需要去监听的对象。
-
如果某个比特为 0,则表示这个文件描述符不在当前集合中,select 会忽略它。
函数签名:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
poll - 第二代
工作原理:
poll 解决了 select 的一些问题,但本质相似。
-
应用程序提供一个 struct pollfd 数组,每个元素包含要监听的 fd 和感兴趣的事件。
-
调用 poll 函数,将该数组传入内核。
-
内核遍历这个数组,检查每个 fd 的事件。
-
返回时,内核会修改每个 pollfd 中的 revents 字段,表示哪些事件就绪。
-
应用程序需要遍历整个数组,检查每个元素的 revents 字段。
对比 select 的改进:
-
没有最大数量限制(仅受系统资源限制)。
-
使用事件的方式,而不是原始的 fd 集合,提供了更多的事件类型。
-
内核不会“破坏”传入的参数,无需每次调用前重置。
缺点:
- 和 select 一样,性能会随着监听 fd 数量的增加而线性下降。因为内核和用户空间都需要遍历整个列表。
数据结构与函数:
struct pollfd {int fd; /* 文件描述符 */short events; /* 请求的事件(我们关心什么) */short revents; /* 返回的事件(实际发生了什么) */
};int poll(struct pollfd *fds, nfds_t nfds, int timeout);
epoll - 第三代 (Linux 2.6+,现代首选)
epoll 是 Linux 为解决 select/poll 的性能瓶颈而设计的,是高性能网络编程的事实标准。
核心改进:
-
无需遍历:epoll 使用一个红黑树在内核中管理所有需要监听的 fd,使得添加和删除 fd 的效率很高(O(log n))。
-
事件回调:当某个 fd 有事件发生时,内核会通过回调函数将其直接插入到一个就绪链表中。
-
只返回就绪的 fd:epoll_wait 调用返回时,只需从就绪链表中取出元素即可,直接拿到了就绪的 fd,无需遍历所有监听的 fd。这使得效率与活跃连接数(就绪的 fd 数)成正比,而与总连接数无关。在处理大量空闲连接时,性能优势巨大。
三个核心 API:
- epoll_create / epoll_create1:创建一个 epoll 实例,返回一个文件描述符(epfd)。
int epoll_create1(int flags); // 常用,flags 可设为 0
- epoll_ctl:向 epoll 实例(epfd)中添加、修改或删除需要监听的 fd 及其事件。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// op: EPOLL_CTL_ADD, EPOLL_CTL_MOD, EPOLL_CTL_DEL
- epoll_wait:等待事件发生。它阻塞调用,直到有事件发生或超时。返回的是发生事件的 epoll_event 数组,直接就是就绪的 fd,无需遍历。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
工作模式:
-
水平触发 (LT - Level-Triggered):默认模式。只要 fd 处于就绪状态(例如,socket 接收缓冲区不为空,可读),epoll_wait 就会持续通知你。如果你这次没有读完数据,下次调用 epoll_wait 它还会再次通知你。编程更简单,不容易遗漏事件。
-
边缘触发 (ET - Edge-Triggered):只有当 fd 状态发生变化时(例如,从不可读变为可读),epoll_wait 才会通知你一次。如果一次没有读完数据,且没有新的数据到来,剩余的数据将不会触发新的通知。ET 模式效率更高,但要求应用程序必须一次性读完或写完所有数据(通常使用非阻塞 I/O 循环读写,直到返回 EAGAIN 或 EWOULDBLOCK 错误)。
对比
特性 | select | poll | epoll |
---|---|---|---|
性能 | 随 fd 数量线性下降 | 随 fd 数量线性下降 | 与活跃 fd 数相关,性能高 |
最大连接数 | 有上限 (FD_SETSIZE) | 无上限 (系统资源限制) | 无上限 (系统资源限制) |
工作效率 | 每次调用都需全量传入/传出和遍历 | 每次调用都需全量传入/传出和遍历 | 内核红黑树管理,只返回就绪的fd |
事件粒度 | 较粗,仅可读、可写、异常 | 较细,更多事件类型 | 非常精细,支持丰富的事件 |
触法模式 | LT | LT | 支持 LT 和 ET(高性能) |
跨平台 | 几乎所有平台 | 大多数 UNIX | Linux 特有 |
使用一个简单的epoll代码框架
#include <sys/epoll.h>
#include <unistd.h>#define MAX_EVENTS 10int main() {int listen_sock, conn_sock, nfds, epollfd;struct epoll_event ev, events[MAX_EVENTS];// 1. 创建 epoll 实例epollfd = epoll_create1(0);if (epollfd == -1) { /* 错误处理 */ }// 2. 添加监听套接字到 epoll,关注可读事件(新连接)ev.events = EPOLLIN; // 水平触发 LT// ev.events = EPOLLIN | EPOLLET; // 边缘触发 ETev.data.fd = listen_sock;if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) { /* 错误处理 */ }for (;;) {// 3. 等待事件发生nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1); // -1 表示永久阻塞if (nfds == -1) { /* 错误处理 */ }// 4. 处理就绪的事件 (nfds 个)for (int n = 0; n < nfds; ++n) {if (events[n].data.fd == listen_sock) {// 有新连接到来conn_sock = accept(listen_sock, ...);// ... 设置 conn_sock 为非阻塞(如果是ET模式必须)ev.events = EPOLLIN | EPOLLET; // 为新连接设置ET模式ev.data.fd = conn_sock;epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock, &ev);} else {// 已有连接可读或可写if (events[n].events & EPOLLIN) {// 可读事件,读取数据// ... 如果是ET模式,必须循环 read 直到 EAGAIN}if (events[n].events & EPOLLOUT) {// 可写事件,发送数据}// ... 处理其他事件,如 EPOLLERR, EPOLLHUP}}}close(epollfd);return 0;
}
小结
-
现代高性能网络程序首选 epoll。
-
理解 LT 和 ET 模式的区别及其编程模型至关重要。
-
select 和 poll 因其可移植性,在一些简单的场景或非 Linux 平台上仍有价值,但在 Linux 上处理大量连接时,应毫不犹豫地选择 epoll。