在 Linux 中,一切皆文件。网络套接字、管道、终端输入输出都通过文件描述符(fd)来操作。当程序需要同时处理多个连接时,I/O 多路复用技术成为核心方案。本文从 fd 基础出发,对比 select、poll、epoll 的实现差异与适用场景。
一、文件描述符的本质
文件描述符是一个非负整数,进程通过它访问内核中的打开文件表。每个进程默认拥有 0(stdin)、1(stdout)、2(stderr)。
/* 查看当前进程打开的文件描述符 */ int fd = open("data.txt", O_RDONLY); printf("fd = %d\n", fd); /* 通常返回 3 */ close(fd);
每个 fd 在内核中对应一个 struct file,包含文件位置、访问模式、inode 指针等。fd 只是进程 fd 表中的索引。
二、阻塞 vs 非阻塞 I/O
默认情况下,套接字是阻塞的:read() 没有数据时会挂起进程,直到数据到达。非阻塞模式通过 fcntl 设置:
int flags = fcntl(sockfd, F_GETFL, 0); fcntl(sockfd, F_SETFL, flags | O_NONBLOCK); /* 非阻塞 read:无数据立即返回 -1,errno 设为 EAGAIN */ ssize_t n = read(sockfd, buf, sizeof(buf)); if (n == -1 && errno == EAGAIN) { /* 暂无数据,可处理其他 fd */ }
非阻塞 I/O 必须与多路复用配合使用,否则 CPU 会空转在轮询中。
三、select:最古老的方案
select 通过三个 fd_set 位图监控可读、可写、异常事件。最大限制是 FD_SETSIZE(通常为 1024)。
fd_set readfds; FD_ZERO(&readfds); FD_SET(sockfd, &readfds); struct timeval tv = {5, 0}; /* 超时 5 秒 */ int ret = select(sockfd + 1, &readfds, NULL, NULL, &tv); if (ret > 0 && FD_ISSET(sockfd, &readfds)) { /* sockfd 可读 */ }
select 的缺陷:每次调用都要把 fd_set 从用户态拷贝到内核态;返回后需要遍历所有 fd 检查状态;最大 fd 数受限。
四、poll:移除上限,但仍需遍历
poll 使用数组替代位图,没有 1024 限制,但本质上仍是线性扫描。
struct pollfd fds[2]; fds[0].fd = sockfd; fds[0].events = POLLIN; int ret = poll(fds, 2, 5000); /* 超时 5000ms */ if (ret > 0 && fds[0].revents & POLLIN) { /* 可读 */ }
五、epoll:事件驱动,真正的 scalable
epoll 通过红黑树管理 fd,通过回调机制在事件发生时主动通知,避免了每次遍历全部 fd。
/* 1. 创建 epoll 实例 */ int epfd = epoll_create1(0); /* 2. 注册 fd 到 epoll */ struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = sockfd; epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev); /* 3. 等待事件 */ struct epoll_event events[10]; int n = epoll_wait(epfd, events, 10, -1); for (int i = 0; i < n; i++) { if (events[i].data.fd == sockfd) { /* 处理新连接或数据 */ } }
六、LT 与 ET 模式
- LT(水平触发):只要 fd 处于可读状态,epoll_wait 就会持续报告。默认模式,编程简单。
- ET(边缘触发):仅在状态变化时报告一次。要求必须一次性读写完所有数据,配合非阻塞 fd 使用。
/* ET 模式:events 加入 EPOLLET */ ev.events = EPOLLIN | EPOLLET; epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
ET 模式减少了 epoll 触发次数,理论上更高效,但代码复杂度高。Nginx 使用 ET,Redis 使用 LT。
七、三种方案对比
- select:兼容性好,但受 1024 限制,性能随 fd 增加线性下降
- poll:无数量限制,但仍需遍历全部 fd,O(n) 复杂度
- epoll:事件回调,O(1) 活跃通知,适合高并发(C10K/C10M)
八、极简 echo 服务器
#include <stdio.h> #include <string.h> #include <unistd.h> #include <sys/epoll.h> #include <arpa/inet.h> int main(void) { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr = {0}; addr.sin_family = AF_INET; addr.sin_port = htons(8080); addr.sin_addr.s_addr = INADDR_ANY; bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)); listen(listen_fd, 128); int epfd = epoll_create1(0); struct epoll_event ev, events[64]; ev.events = EPOLLIN; ev.data.fd = listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev); while (1) { int n = epoll_wait(epfd, events, 64, -1); for (int i = 0; i < n; i++) { if (events[i].data.fd == listen_fd) { int conn = accept(listen_fd, NULL, NULL); ev.events = EPOLLIN; ev.data.fd = conn; epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &ev); } else { char buf[1024]; int fd = events[i].data.fd; ssize_t r = read(fd, buf, sizeof(buf)); if (r <= 0) { close(fd); continue; } write(fd, buf, r); } } } }
编译:
gcc -o echo echo.c && ./echo,另开终端用 nc localhost 8080 测试。