3. IO多路复用:select/poll/epoll 归纳总结
回想我刚接触网络编程那会儿,第一个正经项目就是写一个并发聊天服务器。那时候对线程池一知半解,心想用多线程处理每个连接总没错吧?结果到了几百个客户端在线,系统负载高得离谱,上下文切换把CPU快拖垮了。后来师傅跟我说:“你试试 IO 多路复用,一个线程管上千个连接。” 于是,我认识了 select、poll 和 epoll 这三兄弟。
说白了,IO 多路复用就是让一个进程(或线程)同时监听多个文件描述符(socket、管道等),当其中某个描述符就绪(可读/可写/异常)时,再通知你处理。这样就能用少量线程接管大量 IO 事件。不过,三兄弟的实现差异很大,选错了可是要吃亏的。我自己就踩过不少坑,下面跟你一一道来。
3.1 select:老当益壮但有硬伤
select 是最早的复用模型,几乎全平台支持。它的核心思路是:把所有需要监听的 fd 塞进一个 bitmask(位图),然后调用 select() 等待内核返回就绪的 fd 集合。代码大致长这样:
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sockfd, &readfds);
int ret = select(maxfd+1, &readfds, NULL, NULL, &timeout);
if (FD_ISSET(sockfd, &readfds)) {
// 可以读了
}
嗯,这段代码我看着就想起当年调 BUG 的日子。select 最大的问题有三个:
- fd 数量受限 —— 默认最多 1024 个,想改还不一定编译通过。
- 重复拷贝 —— 每次调用都需要把 fd 集合从用户态拷贝到内核态,连接多了开销巨大。
- O(n) 轮询 —— 内核不知道哪个 fd 就绪,只能把所有 fd 遍历一遍。我维护过一个老项目,select 里监听了 800 多个 socket,每次 select 返回后还得自己再遍历判断哪些 fd 就绪,CPU 直接飙到 30%。
3.2 poll:进步但不够彻底
poll 的出现主要解决了 select 的 fd 数量限制。它用一个 struct pollfd 数组代替位图,不再受 1024 的限制。你只需要告诉内核“我监听这些 fd,感兴趣的事件是啥”,然后内核返回时标记好就绪的事件即可。
struct pollfd fds[2];
fds[0].fd = sockfd; fds[0].events = POLLIN;
int ret = poll(fds, nfds, timeout);
if (fds[0].revents & POLLIN) {
// 可读
}
我的个人习惯是,如果目标系统是老版本 Linux 或者需要跨平台(比如 AIX、Solaris),我还会用 poll。但 poll 的致命伤没有解决:每次调用仍然要全部拷贝数组,内核依然需要 O(n) 遍历所有 fd 来检查就绪状态。说白了,poll 只是把 select 的“小位图”换成了“大数组”,复杂度没变。
3.3 epoll:Linux 下的王者
epoll 是 Linux 2.6 之后引入的,真正把 IO 多路复用推向了高性能时代。它的设计理念是“事件驱动”——内核不再被动等待用户轮询,而是主动回调。
epoll 有三个关键 API:
epoll_create()—— 创建 epoll 实例,内核会分配一个红黑树 + 就绪链表的数据结构。epoll_ctl()—— 添加/修改/删除监听事件。内部通过红黑树维护所有 fd,增删改都是 O(log n)。epoll_wait()—— 等待事件发生。内核直接返回就绪链表里的 fd,用户不用再遍历所有 fd。
而且 epoll 支持两种触发模式:
| 模式 | 说明 | 个人场景 |
|---|---|---|
| LT(水平触发) | 只要 fd 还有数据可读,每次 epoll_wait 都会返回 | 我一般用 LT,编码简单,不容易丢事件 |
| ET(边缘触发) | 只有状态变化时才通知,必须一次把数据读完(或写入完) | 高并发场景用 ET,配合非阻塞 IO 可以显著减少 epoll_wait 调用次数 |
int epfd = epoll_create(1);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
struct epoll_event events[1024];
int n = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < n; i++) {
// 直接处理就绪的 fd,不需要遍历
handle_fd(events[i].data.fd);
}
3.4 三剑客对比
我把三者的核心差异整理成了一张表,你可以直接当速查卡用:
| 特性 | select | poll | epoll |
|---|---|---|---|
| 底层数据结构 | 位图(fd_set) | pollfd 数组 | 红黑树 + 就绪链表 |
| 最大 fd 数 | 1024(可改但麻烦) | 无硬限制(受内存限制) | 无硬限制(受内存限制) |
| 用户态→内核态拷贝 | 全部拷贝(每次调用) | 全部拷贝(每次调用) | 仅初始添加时拷贝 |
| 内核遍历所有 fd | 是,O(n) | 是,O(n) | 否,直接返回就绪 fd,O(1) |
| 触发模式 | 水平触发(LT) | 水平触发(LT) | LT / ET(默认 LT) |
| 跨平台性 | 几乎全平台(包括 Windows) | 几乎所有 Unix-like | 仅 Linux(2.6+) |
从表里能清晰看出,epoll 在性能上全面碾压前两者。但如果你需要在 Windows 或 macOS 上开发,select 和 poll 仍然是可选方案(macOS 的 kqueue 这里不展开)。
下面这张图是我自己总结的三者演进关系,看到图就能想起它们的本质区别:
3.5 避坑指南与个人经验
我在这三兄弟上踩过的坑,挑几个典型的跟你分享:
我曾经在 ET 模式下用阻塞型 recv,结果每次只读了一部分数据,下次 epoll_wait 不通知了(因为数据没读完但没新事件)。最后排查才发现:ET 一定要用非阻塞 IO,并且循环读取直到 EAGAIN。否则你的消息要么不全,要么直接丢了。
老版本 Linux(2.6 之前)如果你把监听 socket 添加到多个 epoll 实例中,或者多个线程都调 epoll_wait 同一个 epoll fd,新连接到来时所有线程都会被唤醒,但只有一个能 accept 成功。这就是“惊群”。后来内核加入了 EPOLLEXCLUSIVE 标志,才解决了这个问题。如果你还在用旧内核,记得用
EPOLLEXCLUSIVE 或自行加锁过滤。
前面提过了,select 的最后一个参数
struct timeval 会被内核修改为剩余时间。如果你在循环里复用同一个结构体,第二次调用时 timeout 可能只有几微秒了。切记每次重新赋值或者用 struct timespec 的 ppoll 替代。
总结一下:如果你只做 Linux 服务端开发,直接上 epoll 没商量;如果非得跨平台,那 select 和 poll 还能凑合,但千万记住它们的 O(n) 瓶颈。我个人现在的习惯是:新项目一律 epoll(带 ET + 非阻塞),旧项目如果牵扯到 select/poll,我会尽量用 libevent 或 libuv 封装,省得自己手写满屏的 select 循环。