Redis 的网络通信事件驱动框架,底层正是基于 Linuxepoll 机制中的 epoll_create、epoll_ctl 和 epoll_wait 等函数进行了二次封装开发。为了理解为什么要用 epoll,我们需要回顾其演进过程:
select机制:受限于连接数量和遍历效率。它使用位图机制来标识文件描述符的状态,所以能监听的总数被限制在 32 × 32(通常为 1024)。而且select无法直接得到已就绪的连接,只能通过遍历所有的文件描述符来确定。poll机制:脱离了连接数量的限制,它通过结构体pollfd来表示文件描述符,但依然没有摆脱需要遍历所有连接来寻找就绪事件的缺点。epoll机制:延续了poll的优点,其内部新增了一个维护「已就绪文件描述符」的队列,保证了极其高效的遍历,只有真正发生事件的连接才会被唤醒处理。
select:开荒者的局限
select 是最早的 I/O 多路复用机制。它的核心思想是:把需要监听的 文件描述符(FD,File Descriptor)打包丢给内核,内核检查后把有事件发生的 FD 标记出来,再整体丢回给用户态。
调用原理如下(有兴趣的可以研究,但是如果不是专业做这个的就没有必要研究了):

(图片来自csdn:select、poll、epoll的原理与区别)
为什么 select 的监听数量有限制?
看它的底层数据结构:
// select 机制定义
intselect(int __nfds, fd_set *__readfds, fd_set *__writefds,
fd_set *__exceptfds, struct timeval *__timeout);
// fd_set 的定义
typedefstruct {
__fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
} fd_set;
select 使用位图(Bitmap),也就是 fd_set 来记录需要监听的文件描述符。
在 Linux 内核中,__FD_SETSIZE 宏的默认值被硬编码为 1024。这意味着 select 默认最多只能监听 1024 个连接(0~1023)。虽然可以通过修改内核宏定义重新编译来突破,但由于位图机制的固有缺陷,强行突破限制会导致性能急剧下降。
为什么 select 无法实现高效遍历?
select 的执行过程是一个典型的 O(N) 操作,它的低效体现在三个方面:
频繁的内存拷贝:每次调用
select,都需要把庞大的fd_set集合从用户态全量拷贝到内核态。两次低效的
O(N)遍历:内核态中:内核需要线性遍历传入的所有 FD,检查是否有事件发生。用户态中: select返回后,它不会告诉你究竟是哪个具体的FD就绪了,只会告诉你「有FD就绪了」。因此,用户程序必须写一个for循环,遍历全部 1024 个FD,看看哪个的标志位被改变了。不可重用的状态:每次调用后,内核会直接修改传入的
fd_set来标记就绪的FD。这就导致下次调用前,用户态必须重新初始化这个位图。
poll:突破了数量限制,但未除病根
为了解决 select 的 1024 连接数限制和不可重用问题,poll 诞生了。
为什么 poll 没有监听数量限制?
看它的数据结构:
intpoll(struct pollfd *__fds, nfds_t __nfds, int __timeout);
structpollfd {
int fd; // 记录文件描述符
short int events; // 要监听的事件类型
short int revents; // 实际发生的事件类型(由内核修改)
};
poll 摒弃了固定大小的位图,改用在用户态动态分配的 pollfd 结构体数组(或链表)。只要系统内存和操作系统的最大文件描述符限制允许,你想监听多少个连接就可以监听多少个。
同时,它将「要监听的事件(events)」和「实际发生的事件(revents)」分开存储,因此数组可以重复使用,不需要像 select 那样每次重置。
为什么 poll 依然无法高效遍历?
poll 仅仅解决了「量」的问题,没有解决「质」的问题。它的本质机制和 select 是一模一样的:依然是 O(N) 的复杂度。
每次调用 poll,依然需要把包含所有监控FD的数组从用户态全量拷贝到内核态。内核依然需要遍历整个数组来检查状态。 最致命的是, poll返回后,依然没有告诉用户态究竟是谁就绪了,用户进程依然需要写一个for循环遍历所有的pollfd,去检查它们的revents字段。
想象一下:当有 10 万个并发连接,但此刻只有 1 个连接活跃时,poll 仍然需要把 10 万个 FD 拷进内核,再在用户态空转遍历 10 万次——这显然是极大的资源浪费。
epoll:高并发的终极答案
select 和 poll 的根本问题是:每次调用都要把所有 FD 从用户态搬进内核,内核检查完再搬回来,不管有没有事件发生。
调用原理如下(有兴趣的可以研究,但是如果不是专业做这个的就没有必要研究了):

(图片来自csdn:select、poll、epoll的原理与区别)
epoll 的解法是:把「注册监听」和「等待事件」彻底分开。它提供三个函数,各司其职:
epoll_create | epoll 实例 |
epoll_ctl | FD |
epoll_wait | FD 列表 |
epoll 使用的核心数据结构如下:
typedefunion epoll_data {
int fd; // 记录文件描述符
} epoll_data_t;
structepoll_event {
uint32_t events; // 要监听的事件类型
epoll_data_t data; // 应用程序数据(通常存 fd)
};
机制一:红黑树——解决「每次全量搬运」的问题
select/poll 的 FD 表由用户态负责维护。 每次调用前,用户程序自己构建好 fd_set 或 pollfd 数组,然后通过系统调用把它传给内核。 内核拿到之后检查一遍,把结果写回来,这次调用就结束了——内核不保留任何状态。下次再调用,用户态必须重新把整张表传进去,内核才知道「这次要监听哪些 FD」。
这也是为什么 poll 解决了「数量限制」,却没解决「每次全量拷贝」—— 因为它的设计思路没变,FD 表的所有权还是在用户态,内核只是个无状态的检查器。
所以select、poll的整个流程是:用户态维护 FD 表 → 每次调用时全量拷贝进内核 → 内核检查完写回 → 内核丢弃状态
epoll 完全反过来: 用户态调用 epoll_ctl 注册 → 内核持久保存在红黑树 → 用户态只需 epoll_wait 等结果
调用 epoll_create 时,内核会创建一个 eventpoll 对象,内部维护一棵红黑树,专门用来存放所有被监听的 FD。
之后每次调用 epoll_ctl 增删 FD,只是在这棵树上做一次 O(logN) 的操作。
关键点:FD 的注册信息常驻内核,不需要每次调用时重新传入。select/poll 每次都要把整张 FD 表从用户态搬进内核,epoll 只做增量更新。
机制二:就绪队列——解决「返回后还要盲目遍历」的问题
光有红黑树还不够,epoll 还在内核中维护了一个就绪队列(rdlist),这是一个双向链表。
它的工作方式是事件驱动的:
网卡收到数据,触发硬件中断 内核网络栈处理完数据后,主动调用回调函数 ep_poll_callback该回调函数把有事件发生的 FD直接插入就绪队列
用户态调用 epoll_wait 时,内核只需检查就绪队列是否为空——不需要遍历红黑树,不需要扫描所有连接。有就绪 FD 就直接返回,返回的数组里每一个元素都是真正活跃的连接。

总结
| FD 传递方式 | ||
| 内核检查方式 | ||
| 返回结果 | ||
| 时间复杂度 |
这就是为什么在 10 万连接、只有 1 个活跃的场景下,epoll 几乎没有额外开销,而 select/poll 要空转 10 万次。
夜雨聆风