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%。
⚠ 特别注意: select 的 timeout 参数在 Linux 中会被修改,每次调用前都要重新设置。我因为这个问题 debug 了整整一个下午,血泪教训。

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 的“小位图”换成了“大数组”,复杂度没变。

💡 小技巧: 如果你用 poll,记得把不再关心的 fd 的 events 置为 0,或者把 fd 设为负数,这样内核会跳过它们。虽然不能降低 O(n),但至少能减少遍历中的无效尝试。

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 调用次数
🔑 核心优势: epoll 把拷贝和遍历从 O(n) 降到了 O(1)。我做过压测,同样的 5000 长连接,select 的 CPU 占用是 epoll 的 8 倍左右。嗯,差距就是这么直观。
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 三剑客对比

我把三者的核心差异整理成了一张表,你可以直接当速查卡用:

特性selectpollepoll
底层数据结构位图(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 这里不展开)。

下面这张图是我自己总结的三者演进关系,看到图就能想起它们的本质区别:

IO 多路复用:三兄弟的演进 select poll epoll 位图 + 轮询 链表 + 轮询 红黑树 + 回调 水平触发 (LT) 水平触发 (LT) LT / ET O(n) O(n) O(1) 记忆关键:select/poll 内核全量遍历 → epoll 事件回调推送,复杂度降为 O(1)

3.5 避坑指南与个人经验

我在这三兄弟上踩过的坑,挑几个典型的跟你分享:

🕳 坑一:ET 模式 + 非阻塞 IO = 丢数据
我曾经在 ET 模式下用阻塞型 recv,结果每次只读了一部分数据,下次 epoll_wait 不通知了(因为数据没读完但没新事件)。最后排查才发现:ET 一定要用非阻塞 IO,并且循环读取直到 EAGAIN。否则你的消息要么不全,要么直接丢了。
💡 坑二:epoll 的惊群问题(新内核已修复)
老版本 Linux(2.6 之前)如果你把监听 socket 添加到多个 epoll 实例中,或者多个线程都调 epoll_wait 同一个 epoll fd,新连接到来时所有线程都会被唤醒,但只有一个能 accept 成功。这就是“惊群”。后来内核加入了 EPOLLEXCLUSIVE 标志,才解决了这个问题。如果你还在用旧内核,记得用 EPOLLEXCLUSIVE 或自行加锁过滤。
💡 坑三:select 的 timeout 会变
前面提过了,select 的最后一个参数 struct timeval 会被内核修改为剩余时间。如果你在循环里复用同一个结构体,第二次调用时 timeout 可能只有几微秒了。切记每次重新赋值或者用 struct timespec 的 ppoll 替代。

总结一下:如果你只做 Linux 服务端开发,直接上 epoll 没商量;如果非得跨平台,那 select 和 poll 还能凑合,但千万记住它们的 O(n) 瓶颈。我个人现在的习惯是:新项目一律 epoll(带 ET + 非阻塞),旧项目如果牵扯到 select/poll,我会尽量用 libevent 或 libuv 封装,省得自己手写满屏的 select 循环。