前言
有一道几乎必问的面试题:
为什么 Nginx 能同时处理几万个并发连接?
如果你的回答是“因为 epoll”,面试官大概率会继续追问:
epoll 为什么比 select/poll 快?底层原理又是什么?
这篇文章从“一个进程如何监视多个连接”出发,把 select → poll → epoll 的演化过程讲透,再深入 epoll 的内核实现机制,帮你建立系统级 I/O 模型的完整认知。
一、问题的起点:一个进程,多个连接
服务器需要同时处理 1000 个客户端,怎么办?
方案一:一连接一线程
客户端1 → 线程1
客户端2 → 线程2
...
客户端1000 → 线程1000
问题很明显:每个线程通常占 8MB 栈空间,1000 个线程就要吃掉 8GB 内存;线程切换开销极大,并发越高反而越慢。
方案二:I/O 多路复用
用一个线程,同时监视多个 fd(文件描述符),哪个 fd 有数据到来就处理哪个。
┌──────────┐
fd1 (conn1) ──→ │ │
fd2 (conn2) ──→ │ 一个线程 │──→ 处理就绪的 fd
... ──→ │ │
fd1000 ──→ └──────────┘
"告诉我谁准备好了"
这就是 I/O 多路复用的核心思想。Linux 提供了三种实现:select、poll、epoll。
二、select:第一代,能用但很慢
用法
fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(fd1, &read_fds);
FD_SET(fd2, &read_fds);
// 阻塞等待,直到有 fd 就绪
select(max_fd + 1, &read_fds, NULL, NULL, NULL);
// 遍历找出哪个 fd 就绪了
for (int i = 0; i <= max_fd; i++) {
if (FD_ISSET(i, &read_fds)) {
// 处理 fd i
}
}
select 的三大硬伤
① fd 数量上限 1024
fd_set 本质是一个 1024 位的 bitmap,超过 1024 个连接直接不支持。
② 每次都要把 fd 集合从用户空间拷贝到内核
有 1000 个 fd,调一次 select 就要拷贝 1000 个 fd 进内核,调用 10000 次就拷贝 10000 次。
③ 内核返回后,需要遍历所有 fd 找出就绪的
就算只有 1 个 fd 就绪,你也得遍历完所有 1000 个 fd 才能确定是哪一个。
三、poll:改良版,但换汤不换药
poll 把 fd_set 换成了 pollfd 数组,解决了 1024 上限的问题,但核心痛点依然存在:
struct pollfd fds[1000];
fds[0].fd = fd1;
fds[0].events = POLLIN;
// ...
poll(fds, 1000, -1); // 每次还是要把 1000 个 fd 拷进内核
for (int i = 0; i < 1000; i++) {
if (fds[i].revents & POLLIN) {
// 还是要 O(n) 遍历
}
}
poll vs select:没有了 1024 的限制,其他问题一个没少。
说了这么多 select/poll 的短板,用一张图来直观对比,然后再看 epoll 是怎么逐个击破的:

上图右侧那套红黑树 + 就绪链表的设计,正是 epoll 高性能的根本原因,下面展开细讲。
四、epoll:第三代,真正的革命
Linux 2.6 引入 epoll,彻底解决了 select/poll 的所有历史遗留问题。
如果你对 C/C++ 底层系统编程有更深入的需求,扎实的指针和内存模型理解会是阅读后续源码分析的良好基础。
核心 API 只有三个
// 1. 创建 epoll 实例,返回一个 epfd
int epfd = epoll_create1(0);
// 2. 注册/修改/删除要监视的 fd
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
// 3. 等待就绪事件,直接返回就绪的 fd 列表
struct epoll_event events[64];
int n = epoll_wait(epfd, events, 64, -1);
for (int i = 0; i < n; i++) {
// events[i].data.fd 就是就绪的 fd,直接处理!
handle(events[i].data.fd);
}
epoll_wait 返回的 n 就是就绪 fd 的数量,数组里存的全是已就绪的 fd——不需要再遍历全部 fd!
五、epoll 为什么快?内核原理
原理一:红黑树存储,O(log n) 增删
epoll 在内核里用红黑树维护所有被监视的 fd。
epoll_ctl 每次只操作一个 fd,把它插入红黑树,时间复杂度 O(log n)。
而 select/poll 是每次调用都把全部 fd 重新传给内核——O(n) 且反复拷贝。
原理二:就绪链表,O(1) 获取结果
内核还维护一个就绪链表(ready list)。
当某个 fd 有事件发生时,网卡驱动通过回调函数直接把该 fd 加入就绪链表。
epoll_wait 只需要检查就绪链表是否为空,非空就直接返回,完全不需要轮询所有 fd。
原理三:fd 注册一次,无需反复拷贝
epoll_ctl 把 fd 注册进内核后,内核就一直持有这个 fd 的信息,之后每次 epoll_wait 不需要重新传入 fd 列表。
把这三个原理画成内核结构图,数据流动路径一目了然:

整个过程,CPU 只在应用层处理业务逻辑,数据就绪的通知完全靠内核回调驱动,无需任何轮询。这也是一种典型的 后端 & 架构 设计思想,值得在高并发场景下反复品味。
六、LT 模式 vs ET 模式
epoll 有两种工作模式,这也是面试高频考点。
水平触发(LT,Level Triggered)—— 默认模式
只要 fd 还有数据没读完,每次 epoll_wait 都会通知你。
// LT 模式(默认)
ev.events = EPOLLIN; // 不加 EPOLLET
特点:安全,不容易漏数据;但如果你不及时读,会反复收到通知(有点烦人)。
边缘触发(ET,Edge Triggered)
fd 状态发生变化时才通知你一次,之后就不再通知。
// ET 模式
ev.events = EPOLLIN | EPOLLET;
特点:性能更高(通知次数少);但必须一次性把数据读完,否则就会漏掉。
两种模式的行为差异,用图来对比最直观:

ET 模式下读数据的正确姿势,就是下面这段循环读到 EAGAIN 的写法——
ET 模式的标准写法:
// ET 模式下,必须循环读到 EAGAIN
while (1) {
int n = read(fd, buf, sizeof(buf));
if (n == -1 && errno == EAGAIN) break; // 读完了
if (n <= 0) break; // 连接关闭
process(buf, n);
}
Nginx 使用 ET 模式,配合非阻塞 I/O,性能才能最大化。这种设计中,底层的 TCP/IP 协议栈与事件驱动架构紧密结合,常常是 网络/系统 开发中需要深入理解的部分。
七、三者终极对比
| 特性 |
select |
poll |
epoll |
| fd 数量上限 |
1024 |
无限制 |
无限制 |
| fd 集合拷贝 |
每次全量拷贝 |
每次全量拷贝 |
只在注册时拷贝一次 |
| 查找就绪 fd |
O(n) 遍历 |
O(n) 遍历 |
O(1) 就绪链表 |
| 数据结构 |
bitmap |
数组 |
红黑树 + 就绪链表 |
| 连接数增加 |
线性变慢 |
线性变慢 |
几乎不变慢 |
| 适用场景 |
连接数 < 100 |
连接数 < 1000 |
高并发场景 |
八、epoll 实战:简易服务器骨架
int epfd = epoll_create1(0);
int listenfd = create_listen_socket(8080); // 创建监听 socket
// 把 listenfd 加入 epoll
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listenfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);
struct epoll_event events[1024];
while (1) {
int n = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
if (fd == listenfd) {
// 新连接到来
int connfd = accept(listenfd, NULL, NULL);
set_nonblocking(connfd);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = connfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);
} else {
// 已有连接有数据
handle_client(fd);
}
}
}
这就是 Reactor 模式的核心骨架——Redis、Nginx 的网络层本质都是这个结构。
上面的代码骨架,对应的事件流转模型如下,这也是 Nginx、Redis 网络层的核心架构:

理解了这张图,你就掌握了 Reactor 模式的本质:epoll 负责感知,分发器负责路由,Handler 负责处理,三者各司其职。像 数据库/中间件/技术栈 中像 Redis 这样的高性能组件,内部正是大量运用这一机制。
九、高频面试题精析
Q1:epoll 比 select 快在哪?
三个层面:① 不需要每次把全量 fd 拷进内核;② 内核通过回调维护就绪链表,epoll_wait 直接返回就绪 fd;③ 红黑树增删 O(log n),select 是 O(n)。
Q2:epoll 一定比 select 快吗?
不一定。当连接数很少(< 100)且几乎都活跃时,select 的简单线性扫描反而开销更低,因为 epoll 本身也有红黑树操作的开销。epoll 的优势在连接数多、活跃连接少的场景(典型的 C10K 问题)。
Q3:ET 模式为什么必须配合非阻塞 I/O?
ET 模式只通知一次,必须一次性把数据读完。如果使用阻塞 read,读到最后一次数据时会永久阻塞(没有更多数据,但 read 不返回),整个线程就卡死了。
Q4:Nginx 用的是 LT 还是 ET?
ET 模式,配合非阻塞 socket,在 Linux 上使用 epoll,在 macOS/BSD 上使用 kqueue。
结语
从 select 到 epoll,是 Linux I/O 多路复用三十年演化的缩影。
理解了 epoll 的红黑树 + 就绪链表的设计,你就真正明白了为什么它能扛住 C10K,为什么 Redis 单线程还能这么快。
这背后并没有什么神秘魔法,不过是在正确的地方,用对了数据结构而已。