4、I/O多路复用:select、poll、epoll原理与对比
说到网络编程,I/O多路复用是个绕不开的话题。我记得刚入行那会儿,写一个简单的聊天服务器,用的是多线程——每个客户端来一个线程。结果呢?客户端一多,服务器直接卡死。后来才知道,原来有select、poll、epoll这些好东西。
说白了,I/O多路复用的核心思想就一句话:一个线程同时监控多个文件描述符。你想想看,如果每个连接都要一个线程去伺候,那系统开销得多大?
核心概念:I/O多路复用允许单个进程同时监视多个文件描述符,一旦某个描述符就绪(可读/可写/异常),就能通知程序进行相应的读写操作。
4.1 select:老当益壮
select是最早出现的I/O多路复用函数。我在项目中遇到过一些老系统,还在用select。它的原理其实挺直观的:
- 把所有要监控的fd(文件描述符)放到一个集合里
- 调用select,内核帮你检查这些fd有没有就绪
- 返回时,告诉你哪些fd可以读了、哪些可以写了
// select 的基本用法
fd_set readfds;
struct timeval tv;
FD_ZERO(&readfds);
FD_SET(sockfd, &readfds);
tv.tv_sec = 5;
tv.tv_usec = 0;
int ret = select(sockfd + 1, &readfds, NULL, NULL, &tv);
if (ret == -1) {
perror("select error");
} else if (ret == 0) {
printf("超时了,没有数据\n");
} else {
if (FD_ISSET(sockfd, &readfds)) {
// 可以读数据了
}
}
select的坑:我曾经在一个高并发项目里用select,结果发现它只能监控1024个fd。为什么?因为FD_SETSIZE默认就是1024。而且每次调用都要把整个fd集合从用户态拷贝到内核态,效率很低。
select的缺点很明显:
- fd数量有限制(默认1024)
- 每次调用都要重新设置fd集合
- 返回后需要遍历所有fd才能找到就绪的
- 用户态和内核态之间频繁拷贝数据
4.2 poll:改进版select
poll的出现,说白了就是解决select的fd数量限制问题。它用链表来存储fd,理论上没有上限。
// poll 的基本用法
struct pollfd fds[2];
fds[0].fd = sockfd;
fds[0].events = POLLIN;
fds[1].fd = listenfd;
fds[1].events = POLLIN;
int ret = poll(fds, 2, 5000); // 超时5秒
if (ret > 0) {
for (int i = 0; i < 2; i++) {
if (fds[i].revents & POLLIN) {
// fd[i] 可读
}
}
}
嗯,这里要注意:poll虽然解决了fd数量问题,但性能问题还在。每次调用还是要遍历所有fd,而且用户态和内核态的数据拷贝也没少。
我的经验:poll适合连接数不多(几百个以内)的场景。如果连接数上万,还是老老实实用epoll吧。
4.3 epoll:Linux下的王者
epoll是Linux特有的I/O多路复用机制。我第一次用epoll的时候,感觉就像打开了新世界的大门。它解决了select和poll的所有痛点:
- 没有fd数量限制(只受系统内存限制)
- 不需要每次都拷贝fd集合
- 只返回就绪的fd,不用遍历所有
- 支持边缘触发(ET)和水平触发(LT)两种模式
// epoll 的基本用法
int epfd = epoll_create(1);
struct epoll_event ev, events[1024];
ev.events = EPOLLIN;
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
while (1) {
int nfds = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < nfds; i++) {
if (events[i].events & EPOLLIN) {
// 处理可读事件
}
}
}
关键区别:select和poll是「主动问」——每次都要把所有fd传给内核,让内核帮你查。epoll是「被动等」——先把fd注册到内核,内核有事件了主动通知你。
4.4 三种机制的对比
| 特性 | select | poll | epoll |
|---|---|---|---|
| 最大fd数 | 1024(可改) | 无限制 | 无限制 |
| 数据结构 | 位图 | 链表 | 红黑树+链表 |
| 用户态拷贝 | 每次全部拷贝 | 每次全部拷贝 | 只拷贝一次(epoll_ctl时) |
| 返回结果 | 所有fd(需遍历) | 所有fd(需遍历) | 仅就绪的fd |
| 触发模式 | 水平触发 | 水平触发 | 水平+边缘触发 |
| 跨平台 | 几乎所有平台 | 几乎所有平台 | 仅Linux |
避坑指南:我曾经在边缘触发模式下吃过亏。ET模式下,如果一次没把数据读完,下次就不会再通知你了。所以用ET模式时,一定要用非阻塞IO,并且循环读取直到返回EAGAIN。
4.5 核心逻辑流程图
下面这张图展示了三种I/O多路复用机制的核心工作流程:
4.6 如何选择?
我个人习惯这样选:
- 跨平台需求:用select或poll。虽然性能差点,但Windows、macOS、Linux都能跑
- Linux高并发:无脑选epoll。Nginx、Redis、Node.js底层都是它
- 连接数少(几百以内):select或poll都行,差别不大
- 连接数上万:必须epoll,否则性能会崩
小技巧:如果你在写一个库,不确定用户会在什么平台上跑,可以考虑用libevent或libuv这类封装库。它们底层会自动选择最优的I/O多路复用机制。
好了,关于select、poll、epoll的原理和对比,就聊到这儿。记住一句话:选对工具,事半功倍。