Linux 中 epoll 的用法与底层实现

在 Linux 网络编程里,epoll 是高并发场景下最常用的 I/O 多路复用机制之一。相比早期的 selectpollepoll 在文件描述符数量较大、活跃连接较多的情况下通常具有更好的性能表现。


一、什么是 epoll

epoll 是 Linux 内核提供的一组系统调用,用来让一个线程同时监视多个文件描述符(file descriptor,简称 fd)上的事件,例如:

  • socket 可读
  • socket 可写
  • 对端关闭连接
  • 出现错误

它的核心目标不是“让 I/O 变快”,而是:

让应用程序高效地知道“哪些 fd 已经就绪,可以进行非阻塞读写了”。

在典型的服务器模型中,一个进程往往要同时维护大量连接。如果对每个连接都创建一个线程,不仅线程切换成本高,内存占用也大。epoll 提供了一种单线程或少量线程管理大量连接的方式,因此它被广泛用于:

  • Redis
  • Nginx
  • 各类 Reactor 网络框架
  • 游戏服务器、网关、代理服务

二、为什么需要 epoll

epoll 出现之前,Linux 常见的 I/O 多路复用方式是 selectpoll

1. select 的问题

select 的主要缺点有:

  • fd 数量受限:通常受 FD_SETSIZE 限制
  • 每次调用都要重复传入整个 fd 集合
  • 内核返回后,用户态还要遍历所有 fd 找出就绪项

也就是说,select 更像是:

“你把全班同学名单每次都交给老师,老师告诉你有人到了,但你还得自己再一个个点名确认是谁。”

2. poll 的改进与不足

poll 去掉了 select 的 fd 数量上限问题,但它依然存在两个核心问题:

  • 每次调用都需要把关注的 fd 列表从用户态拷贝到内核态
  • 返回后应用仍需要线性遍历整个列表,时间复杂度接近 O(n)

3. epoll 的改进方向

epoll 针对上面的问题做了两件关键优化:

  1. 把“关注哪些 fd”这件事长期保存在内核中,避免每次 wait 都重复传入全部集合
  2. 把“已经就绪的 fd”单独组织起来,避免每次都扫描全部 fd

因此在“大量连接、少量活跃”的场景下,epoll 的优势会非常明显。


三、epoll 的三个核心系统调用

epoll 主要由以下三个系统调用组成:

1
2
3
int epoll_create1(int flags);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

可以把它们理解成:

  • epoll_create1:创建一个 epoll 实例
  • epoll_ctl:往 epoll 实例里增删改要监听的 fd
  • epoll_wait:阻塞等待事件发生并取回就绪结果

1. epoll_create1

1
int epfd = epoll_create1(0);

它会返回一个文件描述符 epfd,这个 fd 代表一个 epoll 对象。后续所有操作都围绕这个 epfd 展开。

如果创建失败,返回 -1

2. epoll_ctl

epoll_ctl 用于管理关注列表:

1
2
3
4
5
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listenfd;

epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);

其中 op 常见取值有:

  • EPOLL_CTL_ADD:添加 fd
  • EPOLL_CTL_MOD:修改 fd 关注的事件
  • EPOLL_CTL_DEL:删除 fd

3. epoll_wait

1
2
struct epoll_event events[1024];
int n = epoll_wait(epfd, events, 1024, -1);

参数含义:

  • events:用于接收就绪事件的数组
  • maxevents:本次最多返回多少个就绪事件
  • timeout:超时时间,毫秒
    • -1:一直阻塞
    • 0:立即返回
    • >0:等待指定毫秒数

返回值 n 表示本次就绪的 fd 个数。


四、epoll 的基本使用流程

一个典型的基于 epoll 的 TCP 服务端流程通常如下:

  1. 创建监听 socket
  2. 设置监听 socket 为非阻塞
  3. bind + listen
  4. 创建 epoll 实例
  5. 把监听 socket 加入 epoll
  6. 循环调用 epoll_wait
  7. 处理就绪事件:
    • 如果是监听 socket,就 accept
    • 如果是连接 socket,就 read / write
  8. 连接关闭时,从 epoll 中删除并关闭 fd

下面是一个简化版示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/epoll.h>

#define MAX_EVENTS 1024
#define PORT 8080

static int set_nonblock(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) return -1;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main() {
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));

addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(PORT);

bind(listenfd, (struct sockaddr *)&addr, sizeof(addr));
listen(listenfd, 128);
set_nonblock(listenfd);

int epfd = epoll_create1(0);

struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listenfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);

struct epoll_event events[MAX_EVENTS];

while (1) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;

if (fd == listenfd) {
while (1) {
int connfd = accept(listenfd, NULL, NULL);
if (connfd == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break;
}
break;
}

set_nonblock(connfd);
struct epoll_event client_ev;
client_ev.events = EPOLLIN | EPOLLET;
client_ev.data.fd = connfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &client_ev);
}
} else {
char buf[4096];
while (1) {
ssize_t cnt = read(fd, buf, sizeof(buf));
if (cnt > 0) {
write(fd, buf, cnt);
} else if (cnt == 0) {
close(fd);
break;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break;
}
close(fd);
break;
}
}
}
}
}

close(listenfd);
close(epfd);
return 0;
}

这个示例主要用来说明 epoll 的结构,真正上线还需要补充:

  • 错误处理
  • 写缓冲区管理
  • 半包 / 粘包处理
  • 优雅关闭连接
  • 忽略 SIGPIPE
  • 更清晰的连接状态机

五、epoll 中常见的事件类型

struct epoll_event 中的 events 字段用于指定关注哪些事件,也用于返回实际发生了哪些事件。

常见标志位如下:

  • EPOLLIN:可读
  • EPOLLOUT:可写
  • EPOLLERR:发生错误
  • EPOLLHUP:挂断
  • EPOLLRDHUP:对端关闭写端,常用于判断半关闭
  • EPOLLET:边沿触发(Edge Triggered)
  • EPOLLONESHOT:只触发一次,需手动重新 armed

一个常见写法:

1
ev.events = EPOLLIN | EPOLLRDHUP | EPOLLET;

表示:

  • 关心可读事件
  • 关心对端关闭
  • 使用边沿触发模式

六、水平触发 LT 与边沿触发 ET

这是 epoll 最容易被问到,也是最容易写错的地方。

1. LT:Level Triggered,水平触发

这是默认模式。

它的语义是:

只要 fd 仍然处于“可读/可写”状态,epoll_wait 就会反复通知你。

例如一个 socket 接收缓冲区里还有数据没读完,那么后续 epoll_wait 仍会不断返回这个 fd 的可读事件。

优点:

  • 语义直观
  • 不容易漏事件
  • 编程难度较低

缺点:

  • 重复通知较多

2. ET:Edge Triggered,边沿触发

ET 的语义是:

只有状态从“不可读变为可读”、“不可写变为可写”时,才通知一次。

比如 socket 收到新数据时通知你一次。如果你这次没有把缓冲区里的数据全部读完,那么后面可能不会再次提醒你。

因此 ET 模式通常要求:

  1. fd 必须设置为非阻塞
  2. 读操作要一直读到返回 EAGAIN
  3. 写操作要一直写到返回 EAGAIN 或数据发完

典型伪代码:

1
2
3
4
5
6
7
while ((n = read(fd, buf, sizeof(buf))) > 0) {
// 持续读取直到读空
}

if (n == -1 && errno == EAGAIN) {
// 数据已经读完
}

3. LT 与 ET 如何选择

如果是学习、功能验证或者业务逻辑较复杂的场景,通常建议优先使用 LT,因为更稳妥。

如果是成熟的高性能网络框架,且你已经能正确处理非阻塞 I/O 和状态机,那么可以使用 ET 来减少重复唤醒。

一句话概括:

  • LT 更好写
  • ET 更考验实现质量

七、为什么 epoll 通常要配合非阻塞 socket

虽然 epoll 本身不强制要求 fd 一定是非阻塞的,但在实际网络编程中,几乎总是配合 O_NONBLOCK 使用。

原因很简单:

如果某个连接上的 readwrite 阻塞住了,那么整个事件循环线程都会被卡住,其他连接也就没法处理了。

例如:

  1. epoll_wait 告诉你某个 fd 可读
  2. 你调用 read
  3. 只读了一部分后又因为某些条件阻塞住
  4. 整个 Reactor 线程停摆

这就违背了 I/O 多路复用的初衷。

因此在实践中通常遵循:

  • 监听 fd:非阻塞
  • 已连接 fd:非阻塞
  • ET 模式:更是必须非阻塞

八、epoll 的底层实现思路

这一部分是理解 epoll 的重点。

很多资料会简单说:

epoll 是 O(1) 的。

这句话并不严谨。更准确地说,epoll 的优势在于:

  • 不需要每次调用都把整个 fd 集合从用户态拷入内核
  • 不需要每次都线性扫描全部 fd 查找就绪项
  • 只返回已经准备好的事件

它的底层可以从两个核心数据结构来理解。

1. 红黑树:维护“关注列表”

当你调用:

1
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);

内核需要把这个 fd 注册到 epoll 实例中。Linux 内核内部会用一棵 红黑树 来维护这些被关注的 fd。

这样做的目的包括:

  • 快速查找某个 fd 是否已注册
  • 支持添加、删除、修改操作
  • 保持管理结构稳定

也就是说,红黑树负责“我关心谁”

2. 就绪链表:维护“已经就绪的 fd”

仅有关注列表还不够,关键在于如何快速拿到已经发生事件的 fd。

epoll 内部还维护了一个 ready list(就绪链表)。当某个 fd 对应的设备或 socket 状态发生变化时,驱动层/协议栈会通过回调机制通知 epoll

这个 fd 已经就绪,可以把它挂到 ready list 上。

这样 epoll_wait 做的事情就不是“扫描所有 fd”,而是:

直接从 ready list 中取出已经准备好的事件并返回给用户态。

这正是 epoll 在高并发下性能较好的关键原因。

3. 回调机制:事件到来时主动通知

当 socket 被加入 epoll 时,内核并不是每次等 epoll_wait 才去检查状态,而是会在 fd 对应的等待队列上注册回调。

后续当:

  • 网卡收到数据
  • TCP 协议栈把数据放入 socket 接收缓冲区
  • socket 状态变化为可读/可写

对应回调就会被触发,进而把这个 fd 放入 epoll 的就绪队列。

这是一种“事件驱动”思路,而不是“轮询整个集合”。


九、从内核视角看一次事件到达过程

可以把一次“客户端发数据给服务端,服务端被 epoll_wait 唤醒”的过程粗略理解为:

  1. 应用调用 epoll_ctl,把某个 socket 加入 epoll
  2. 内核在该 socket 的等待队列中挂接回调
  3. 客户端发送数据
  4. 网卡收到数据并触发中断
  5. 内核网络协议栈处理数据包
  6. 数据被放入目标 socket 的接收缓冲区
  7. socket 变为可读
  8. 回调函数把该 socket 对应的 epitem 挂入 ready list
  9. 若有线程阻塞在 epoll_wait,则被唤醒
  10. epoll_wait 把就绪事件拷贝到用户态数组并返回

这条链路里最重要的思想是:

不是应用反复问“谁准备好了”,而是内核在事件发生时把“准备好了的人”主动登记起来。


十、epoll 为什么适合高并发

epoll 适合高并发,主要不是因为它“没有任何成本”,而是它把成本集中到了更合理的地方。

1. 注册成本和等待成本分离

  • epoll_ctl 负责维护关注集合
  • epoll_wait 负责拿取已就绪事件

select/poll 往往是每次等待都重新提交全集。

2. 活跃连接少时收益巨大

很多服务器都有这个特点:

  • 总连接数很多
  • 但同一时刻真正活跃的连接只占一部分

例如有 10 万连接,但每次真正有数据收发的可能只有几百个。此时:

  • poll 更像遍历 10 万人名单
  • epoll 更像只处理已举手的几百人

3. 更适合事件驱动架构

epoll 很适合 Reactor 模型:

  • 主循环等待事件
  • 收到事件后分发给对应处理器
  • 尽量避免阻塞调用

这类模型天然适合:

  • 连接数大
  • 每个连接计算不算太重
  • I/O 事件频繁

十一、使用 epoll 时的常见坑

1. ET 模式下没有读到 EAGAIN

这是最经典的问题。

如果 ET 模式下只读一次就返回,而没有把缓冲区读空,那么剩余数据可能一直躺在内核缓冲区里,但应用再也收不到提醒。

2. 忘记设置非阻塞

尤其是在 ET 模式下,如果 fd 不是非阻塞的,循环 read / accept 很容易把线程卡死。

3. 关闭 fd 前未正确处理 epoll 状态

虽然关闭 fd 时内核通常会把它从 epoll 中清理掉,但工程上仍建议:

  1. epoll_ctl(..., EPOLL_CTL_DEL, ...)
  2. close(fd)

这样逻辑更清晰,也更利于排查问题。

4. 误以为 EPOLLOUT 应该一直监听

对 socket 来说,“可写”通常是很常见的状态。如果你长期监听 EPOLLOUT,事件循环可能会被大量可写通知淹没。

更常见的策略是:

  • 默认只监听 EPOLLIN
  • 当发送缓冲区有未发完数据时,再临时打开 EPOLLOUT
  • 数据发完后,再取消 EPOLLOUT

5. 没有处理对端关闭

应关注:

  • read 返回 0
  • EPOLLRDHUP
  • EPOLLHUP
  • EPOLLERR

否则很容易出现连接已经断开但业务侧还以为在线的情况。


十二、epoll 与 select、poll 的对比

对比项 select poll epoll
fd 数量限制 有,通常受 FD_SETSIZE 限制 无明显固定上限 无明显固定上限
每次调用是否重复传全集
就绪事件获取方式 线性扫描 线性扫描 从就绪队列取
典型复杂度特征 O(n) O(n) 更接近按活跃 fd 处理
适合场景 小规模 fd 中等规模 大规模高并发
Linux 特性 通用 POSIX 通用 POSIX Linux 特有

如果项目要求跨平台,往往还需要:

  • Linux 用 epoll
  • BSD / macOS 用 kqueue
  • Windows 用 IOCP

很多跨平台网络库会把这些机制再封装一层。


十三、epoll 在工程中的常见使用模式

1. 单 Reactor 单线程

适合:

  • 逻辑简单
  • 连接很多但每次处理很轻
  • 学习或轻量服务

模式如下:

  • 一个线程 epoll_wait
  • 同一个线程完成 accept、read、write、业务处理

优点是简单,缺点是业务计算重时容易拖慢整个事件循环。

2. 单 Reactor 多工作线程

常见模式:

  • 一个线程负责 I/O 事件分发
  • 多个工作线程负责业务逻辑

这样可以把:

  • I/O 就绪通知
  • 业务 CPU 计算

分离开来。

3. 多 Reactor 多线程

更高性能的网络服务器中常见:

  • 一个主 Reactor 负责监听和 accept
  • 多个子 Reactor 各自维护自己的 epoll 实例
  • 新连接按策略分发给某个子 Reactor

这是许多成熟网络框架会采用的思路。


十四、一个简化的理解模型

如果用“教室点名”来类比:

  • select/poll:每次上课都拿全班名单,一个一个问“到了没?”
  • epoll_ctl:先把全班名单交给班长长期保存
  • 就绪链表:已经到教室的人主动去签到
  • epoll_wait:老师只看签到表,不看全班总名单

这个类比虽然不完全严谨,但很适合理解 epoll 的核心优势:

把“全量扫描”变成“增量通知 + 就绪集合提取”。


十五、总结

epoll 的价值可以浓缩成三点:

  1. 使用上:通过 epoll_create1epoll_ctlepoll_wait 三步完成事件驱动式 I/O 管理
  2. 编程上:通常配合非阻塞 socket,尤其在 ET 模式下必须读/写到 EAGAIN
  3. 实现上:内核通过红黑树维护关注集合,通过就绪链表维护已发生事件,并借助回调机制在事件到达时主动通知

所以,epoll 真正高效的地方,不是简单一句“时间复杂度 O(1)”就能说清,而是:

它避免了每次等待都重复提交和扫描整个 fd 集合,把精力集中在真正活跃的连接上。

如果只是记忆结论,那么你会觉得 epoll 只是一个 API; 如果理解了它的内核组织方式,就会明白它为什么能成为 Linux 高并发网络编程的基础设施。