多路复用:select、poll、epoll 与高并发服务器
大家好,今天我们来聊聊 I/O 多路复用。说实话,这是网络编程里绕不开的一道坎。我当年刚接触这块时,也被 select、poll、epoll 这几个名字搞得晕头转向。但等你真正理解了它们的设计思路,就会发现——其实没那么玄乎。
多路复用的核心思想是什么?说白了就是:一个线程能同时监控多个文件描述符。你不需要为每个连接都开一个线程或进程,那样太浪费资源了。我们用一个线程,盯着成百上千个 socket,哪个有数据来了,就去处理哪个。这就是多路复用的本质。
核心概念:I/O 多路复用允许程序同时监视多个文件描述符,等待其中一个或多个变为“可读”或“可写”状态。它解决了传统阻塞 I/O 中“一个连接一个线程”的资源浪费问题。
select 函数——老前辈,但够用
select 是最早出现在 POSIX 标准里的多路复用函数。它的原型长这样:
#include <sys/select.h>
#include <sys/time.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
参数看着多,其实好理解。nfds 是你要监控的最大文件描述符加 1。后面三个 fd_set 指针分别对应可读、可写、异常事件。timeout 就是超时时间。
fd_set 是个位图结构,用 FD_SET、FD_CLR、FD_ISSET 这些宏来操作。我刚开始用的时候,老记不住这些宏的名字,后来写多了就习惯了。
小技巧:select 的 nfds 参数一定要传最大 fd + 1,否则内核不知道要遍历到哪。我曾经因为这个参数传错了,排查了半天才发现是这里的问题。
select 的缺点也很明显:
- 监控的文件描述符数量有限制,默认是 1024(FD_SETSIZE)
- 每次调用都要把整个 fd_set 从用户态拷贝到内核态,开销大
- 内核需要线性扫描所有 fd,O(n) 复杂度
- 返回后,你需要再次遍历所有 fd 来检查哪些就绪了
嗯,说白了,select 在小规模场景下完全够用。但如果你要处理几千个连接,它就不太行了。
poll 函数——改进版,但本质没变
poll 的出现解决了 select 的两个痛点:没有最大 fd 限制,而且用 pollfd 结构体替代了位图。
#include <poll.h>
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 关注的事件 */
short revents; /* 返回的事件 */
};
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
你看,poll 把 fd 和事件封装在一起,不再需要三个独立的集合了。而且 events 和 revents 分开,你不需要每次调用前重新设置 events。
但 poll 的性能问题依然存在:它还是要遍历所有 fd,还是要做用户态到内核态的数据拷贝。说白了,poll 只是改进了接口设计,没有解决根本的性能瓶颈。
注意:poll 的 timeout 参数单位是毫秒,-1 表示阻塞等待,0 表示立即返回。我曾经在项目中把 timeout 设成了 0 但期望它阻塞,结果 CPU 直接跑满了——这是个低级错误,大家别犯。
epoll——Linux 下的王者
epoll 是 Linux 特有的多路复用机制,也是目前高并发服务器的首选。它解决了 select 和 poll 的所有痛点。
epoll 有三个核心函数:
#include <sys/epoll.h>
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_create 创建一个 epoll 实例,返回一个文件描述符。epoll_ctl 用来注册、修改、删除你要监控的 fd。epoll_wait 就是等待事件发生。
epoll 的高效体现在哪?我总结三点:
- 事件驱动:内核只返回就绪的 fd,不需要遍历所有 fd
- mmap 共享内存:用户态和内核态共享一块内存,避免了数据拷贝
- 红黑树管理:epoll 用红黑树管理所有注册的 fd,增删改查都是 O(log n)
说白了,epoll 把复杂度从 O(n) 降到了 O(1)——只处理就绪的事件,不关心没动静的 fd。
LT 模式 vs ET 模式
epoll 有两种工作模式:水平触发(LT)和边缘触发(ET)。这是面试常问的点,也是实际开发中容易踩坑的地方。
| 特性 | LT(水平触发) | ET(边缘触发) |
|---|---|---|
| 通知方式 | 只要 fd 还有数据可读,就会一直通知 | 只在状态变化时通知一次 |
| 编程难度 | 低,和 select/poll 类似 | 高,需要循环读取直到 EAGAIN |
| 性能 | 一般,可能重复通知 | 高,减少系统调用次数 |
| 使用场景 | 简单应用,新手友好 | 高并发服务器,追求极致性能 |
LT 是默认模式。它的行为很直观:只要缓冲区里还有数据,epoll_wait 就会一直返回这个 fd。你读一部分,下次调用还会通知你。
ET 模式就不同了。它只在 fd 状态从“无数据”变为“有数据”时通知一次。如果你没把数据读完,它不会再通知你——除非有新数据到来。
避坑指南:使用 ET 模式时,必须把 fd 设置为非阻塞,并且要循环读取直到 read 返回 EAGAIN。我曾经在项目里用了 ET 模式但忘了设置非阻塞,结果 read 阻塞住了,整个服务器都卡死了。嗯,这个教训挺深刻的。
用 epoll 实现高并发服务器
好了,理论说完了,我们来看看实际怎么用 epoll 写一个高并发服务器。核心流程其实很简单:
- 创建 socket,绑定端口,监听
- 创建 epoll 实例
- 把监听 socket 注册到 epoll
- 循环调用 epoll_wait
- 处理就绪事件:新连接或数据读写
下面是一个简化版的代码骨架:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/epoll.h>
#define MAX_EVENTS 1024
#define PORT 8080
int main() {
int listen_fd, epoll_fd, nfds;
struct sockaddr_in addr;
struct epoll_event ev, events[MAX_EVENTS];
// 1. 创建监听 socket
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
// ... 设置 SO_REUSEADDR, bind, listen ...
// 2. 创建 epoll 实例
epoll_fd = epoll_create1(0);
// 3. 注册监听 socket
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev);
// 4. 事件循环
while (1) {
nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == listen_fd) {
// 新连接到来
int conn_fd = accept(listen_fd, NULL, NULL);
// 设置非阻塞,注册到 epoll
ev.events = EPOLLIN | EPOLLET; // ET 模式
ev.data.fd = conn_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &ev);
} else {
// 处理客户端数据
// 注意:ET 模式下要循环读取
handle_client(events[i].data.fd);
}
}
}
close(listen_fd);
return 0;
}
这个代码虽然简单,但已经能支撑几千个并发连接了。实际生产环境中,你还需要考虑:
- 使用线程池处理业务逻辑,避免阻塞事件循环
- 合理设置 epoll_wait 的超时时间
- 处理 EINTR 信号中断的情况
- 内存池管理,减少频繁分配释放
个人建议:刚开始做高并发服务器时,先用 LT 模式把功能跑通,再切换到 ET 模式优化性能。LT 模式容错性好,不容易丢数据。等你对 epoll 的细节都摸透了,再上 ET 模式。
知识体系总览
下面这张图总结了本章的核心内容,你可以对照着梳理思路:
从这张图可以看得很清楚:select 和 poll 是早期的解决方案,epoll 是 Linux 下的终极武器。而 epoll 的 LT 和 ET 模式,则给了我们灵活选择的空间。
我个人在实际项目中,大部分场景都用 ET 模式。虽然编程复杂一点,但性能提升是实打实的。不过如果你刚开始接触 epoll,从 LT 模式入手会更稳妥。
好了,这一章的内容就到这里。多路复用是网络编程的基石,理解了 select、poll、epoll 的演进过程,你就能明白为什么 epoll 能支撑高并发。下一章我们会深入 epoll 的源码实现,看看内核里到底是怎么工作的。
公众号:蓝海资料掘金营,微信 deep3321