epoll

一、核心背景

在高并发网络编程中(比如百万连接的服务器),传统的 select/poll 会遇到三个致命问题:

  • 性能瓶颈:每次调用都要遍历所有监听的文件描述符(FD),FD 越多越慢(O (n));
  • 数量限制:select 最多监听 1024 个 FD(FD_SETSIZE 限制);
  • 数据拷贝:每次调用都要把用户态的 FD 集合拷贝到内核态,开销极大。

epoll 就是为解决这些问题而生的 —— 它是 Linux 内核专为高并发 I/O 场景设计的 “事件通知管家”,核心是「只关注有变化的 FD」,让服务器能高效处理数万甚至数百万的并发连接。

通俗类比

  • select/poll:像一个老师,每天挨个检查全班 1000 个学生的作业(不管有没有写完),效率极低;
  • epoll:像一个班长,只有学生写完作业(FD 就绪)才主动告诉老师,老师只处理这些 “写完作业的学生”,效率直接拉满。

二、epoll 核心底层原理

epoll 的高效,本质是内核用了三个关键设计,我们逐一拆解:

1. 核心数据结构:红黑树 + 就绪链表

内核为每个 epoll 实例维护两个核心结构:

结构 作用
红黑树 存储所有通过 epoll_ctl 注册的 FD(待检测集合),增删改查效率 O (logn)
就绪链表 仅存储 “发生事件” 的 FD(比如有数据可读、可写),epoll_wait 只返回这个链表

内核工作流程

  1. 你通过 epoll_ctl 把要监听的 FD(比如监听套接字、客户端连接)添加到红黑树;
  2. 内核会给每个 FD 注册一个回调函数:当 FD 就绪(比如有数据到达),就自动把这个 FD 从红黑树移到就绪链表;
  3. 你调用 epoll_wait 时,内核只需要把就绪链表的 FD 拷贝到用户态,无需遍历所有 FD(时间复杂度 O (1))。

2. 内存映射(mmap):减少内核 / 用户态拷贝

epoll 用 mmap 把内核态的就绪链表和用户态的缓冲区映射到同一块内存,避免了传统 select/poll 中 “每次调用都拷贝整个 FD 集合” 的开销 —— 数据只需要拷贝一次,甚至无需拷贝。

3. 解耦设计:把 “注册 FD” 和 “等待事件” 分开

select/poll 把 “添加要检测的 FD” 和 “阻塞等待事件” 绑在一起,每次调用都要重复做这两件事;

epoll 把这两步拆成 epoll_ctl(注册 / 修改 / 删除 FD)和 epoll_wait(等待事件),大部分场景下 FD 是固定的,只需注册一次,后续只调用 epoll_wait 即可,大幅减少开销。

三、epoll 核心 API 深度解析

使用epoll需包含头文件:

1
#include<sys/epoll.h>

epoll 只有三个核心函数,我们逐个拆解:

1. epoll_create /epoll_create1(创建 epoll 实例)

1
2
3
4
// 旧版:size 参数在 2.6.8 后废弃,只需传 >0 的数即可
int epoll_create(int size);
// 新版:推荐用这个,flags 填 0 或 EPOLL_CLOEXEC(进程退出自动关闭 epoll_fd)
int epoll_create1(int flags);
  • 返回值:成功返回 epoll 实例的文件描述符(epoll_fd),失败返回 -1;
  • 关键注意:epoll_fd 本身也是一个 FD,使用完必须 close(epoll_fd),否则会内存泄漏;
  • 通俗理解:创建一个 “事件管家”,后续所有 FD 的监听都由这个管家负责。

epoll_create1()函数

1. 参数:int flags(标志位)

这是 epoll_create1() 相比旧版 epoll_create() 的核心改进 —— 通过标志位控制 epoll 实例的行为,目前支持的标志只有一个:

标志值 含义 使用场景
0 默认值:创建普通的 epoll 实例,无特殊行为 绝大多数常规场景
EPOLL_CLOEXEC 创建 epoll 实例时,为其设置 FD_CLOEXEC 标志(执行 exec 时自动关闭) 多进程 / 程序替换(exec)场景

关键解释:EPOLL_CLOEXEC 的作用

  • 背景:Linux 中,进程打开的 FD 会被子进程继承,若进程调用 exec(替换程序),默认情况下 FD 不会关闭;
  • 问题:如果不关闭 epoll 实例的 FD,可能导致资源泄露(比如子进程 / 新程序意外持有该 FD);
  • 解决:设置 EPOLL_CLOEXEC 后,当进程调用 exec 时,内核会自动关闭这个 epoll 实例的 FD,避免资源泄露。
2. 返回值
返回值 含义
-1 失败:会设置 errno 提示失败原因(如 ENOMEM 内存不足、EINVAL 标志无效)
>=0 成功:返回一个有效的文件描述符(epfd),代表创建的 epoll 实例

常见失败原因及解决

  • errno = ENOMEM:系统内存不足,无法创建 epoll 实例 → 检查系统内存,或减少其他进程占用;
  • errno = EINVAL:传入的 flags 无效(比如填了非 0/EPOLL_CLOEXEC 的值)→ 确认标志位合法;
  • errno = EMFILE:进程打开的 FD 数达到上限 → 执行 ulimit -n 65535 扩大 FD 上限。

2. epoll_ctl(管理监听的 FD)

这是 epoll 最核心的函数,负责给 “管家” 添加 / 修改 / 删除要监听的 FD。

1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

参数拆解

参数 含义 & 注意事项
epfd epoll_create 的返回值
op 操作类型:
✅ EPOLL_CTL_ADD:添加 FD 到红黑树
✅ EPOLL_CTL_MOD:修改 FD 的监听事件
✅ EPOLL_CTL_DEL:从红黑树删除 FD
fd 要监听的文件描述符(比如监听套接字 lfd、客户端连接 cfd)
event 核心结构体,指定 “监听什么事件”+“关联什么数据”

核心返回值含义

返回值 含义
0 操作成功(添加 / 修改 / 删除监听事件完成)
-1 操作失败,具体错误原因通过 errno 获取

核心结构体:struct epoll_event

1
2
3
4
5
6
7
8
9
10
11
12
// 联合体:多个变量共用一块内存,通常只用 fd 成员
typedef union epoll_data {
void *ptr; // 自定义指针(比如绑定 FD 对应的业务数据)
int fd; // 要监听的 FD(最常用)
uint32_t u32;
uint64_t u64;
} epoll_data_t;

struct epoll_event {
uint32_t events; // 要监听的事件(比如 EPOLLIN、EPOLLOUT)
epoll_data_t data; // 关联的数据(通常存 fd)
};

常用 events 标志(必记)

标志 含义
EPOLLIN 读事件:FD 有数据可读(客户端发数据、新连接、客户端断开)
EPOLLOUT 写事件:FD 可写(发送缓冲区有空间)
EPOLLET 边缘触发(ET 模式):默认是水平触发(LT)
EPOLLERR 异常事件:无需手动注册,内核自动触发
EPOLLHUP 挂起事件:比如客户端断开连接,无需手动注册
EPOLLONESHOT 仅触发一次事件,需重新调用 epoll_ctl 重置(多线程场景避竞争)

示例:添加监听 FD

1
2
3
4
struct epoll_event ev;
ev.events = EPOLLIN; // 监听读事件(LT 模式)
ev.data.fd = lfd; // 关联监听套接字
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev); // 把 lfd 交给 epoll 管家

3. epoll_wait(等待就绪事件)

1
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

参数拆解

参数 含义 & 注意事项
epfd epoll 实例的 fd(指定要查询的 “管家”)
events 传出参数:用户态数组,内核会把 “就绪的 FD 信息” 填充到这个数组里
maxevents events 数组的最大长度(比如 1024),不能超过系统限制
timeout 阻塞时长(毫秒):
✅ -1:永久阻塞,直到有 FD 就绪
✅ 0:非阻塞,立即返回
✅ >0:阻塞指定毫秒后返回

返回值

  • > 0:就绪的 FD 数量(events 数组中前 N 个元素有效);

  • 0:超时(没有就绪的 FD);

  • -1:失败(比如被信号中断)。

关键注意

  • events 数组是 “传出” 的,内核会把就绪 FD 的 epoll_event 结构体填进去;
  • 如果就绪的 FD 数量超过 maxevents,未拷贝的 FD 会留在内核就绪链表中,下次调用 epoll_wait 会继续返回。

四、服务端使用epoll实现IO多路复用的示例代码

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
89
90
91
92
93
94
95
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<string.h>
#include<sys/socket.h>
#include<sys/epoll.h>
#include<arpa/inet.h>

int main(){
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if(lfd == -1){
perror("socket failed");
exit(0);
}

struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(10000);
server_addr.sin_addr.s_addr = INADDR_ANY;

int bind_res = bind(lfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
if(bind_res == -1){
perror("bind failed");
close(lfd);
exit(0);
}

int listen_res = listen(lfd, 128);
if(listen_res == -1){
perror("listen failed");
close(lfd);
exit(0);
}

int epfd = epoll_create1(0);
if(epfd == -1){
perror("epoll_create failed");
exit(0);
}

struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = lfd;
int ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
if(ret == -1){
perror("lfd epoll_ctl failed");
exit(0);
}

struct epoll_event evs[1024];
int size = sizeof(evs)/sizeof(struct epoll_event);

while(1){
int num = epoll_wait(epfd, evs, size, -1);
for(int i = 0; i < num; ++i){
int curfd = evs[i].data.fd;
if(curfd == lfd){
int cfd = accept(lfd, NULL, NULL);
if(cfd == -1){
perror("accept failed");
exit(0);
}

ev.events = EPOLLIN;
ev.data.fd = cfd;
int ret = epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
if(ret == -1){
perror("cfd epoll_ctl failed");
exit(0);
}
}
else{
char buffer[1024];
memset(buffer, 0, sizeof(buffer));
int len = read(curfd, buffer, sizeof(buffer));
if(len > 0){
printf("客户端:%s\n", buffer);
write(curfd, buffer, len);
}
else if(len == 0){
printf("客户端断开连接...\n");
epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, &ev);
close(curfd);
}
else{
perror("read failed");
exit(0);
}
}
}
}

return 0;

}

1. 创建监听套接字

epoll

1
int lfd = socket(AF_INET, SOCK_STREAM, 0);
  • 调用 socket() 创建一个 TCP 类型的监听套接字 lfd,用于接收客户端的连接请求;
  • 失败则打印错误并退出程序。

2. 绑定套接字到指定端口和地址

1
2
3
4
5
6
struct sockaddr_in server_addr;
// 初始化地址结构体(协议族、端口、监听所有网卡)
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(10000);
server_addr.sin_addr.s_addr = INADDR_ANY;
bind(lfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
  • 将监听套接字 lfd 与端口 10000、本机所有网卡地址绑定,使客户端能通过 IP:10000 访问服务端;
  • 失败则关闭 lfd 并退出程序。

3. 开启监听

1
listen(lfd, 128);
  • lfd 从 “主动套接字” 转为 “被动套接字”,开始监听客户端的连接请求;
  • 第二个参数 128 是半连接队列的最大长度,失败则关闭 lfd 并退出程序。

4. 创建 epoll 实例

1
int epfd = epoll_create1(0);
  • 创建 epoll 实例,内核会为该实例分配内存(存储监听的 FD 红黑树、就绪 FD 链表);
  • 返回的 epfd 是 epoll 实例的文件描述符,失败则退出程序。

5. 将监听套接字加入 epoll 实例

1
2
3
4
struct epoll_event ev;
ev.events = EPOLLIN; // 监听读事件(有新连接请求时触发)
ev.data.fd = lfd; // 关联监听套接字 lfd
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
  • 调用 epoll_ctlEPOLL_CTL_ADD 操作,将 lfd 添加到 epoll 实例的监听集合中;
  • 告知 epoll 要监听 lfd 的读事件(EPOLLIN),失败则退出程序。

6. 进入无限循环,阻塞等待就绪事件

1
2
while(1) {
int num = epoll_wait(epfd, evs, size, -1);
  • 调用 epoll_wait 阻塞程序,直到 epoll 实例中有 FD 就绪(超时时间 -1 表示永久阻塞);
  • 返回值 num 是就绪 FD 的数量,evs 数组存储所有就绪 FD 的事件信息。

7. 遍历所有就绪的 FD,逐个处理事件

1
2
for(int i = 0; i < num; ++i) {
int curfd = evs[i].data.fd; // 获取当前就绪的 FD

分支 1:处理新连接事件(就绪 FD 是监听套接字 lfd)

1
2
3
4
5
6
7
8
if(curfd == lfd) {
// 接收新客户端连接,返回通信套接字 cfd
int cfd = accept(lfd, NULL, NULL);
// 将 cfd 添加到 epoll 实例,监听其读事件(客户端发数据时触发)
ev.events = EPOLLIN;
ev.data.fd = cfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
}
  • curfd == lfd 时,说明有新客户端发起连接请求;
  • 调用 accept() 接收连接,返回用于与该客户端通信的套接字 cfd
  • cfd 添加到 epoll 实例,监听其读事件,使 epoll 能检测该客户端的数据发送行为。

分支 2:处理客户端通信事件(就绪 FD 是通信套接字 cfd)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
else {
char buffer[1024];
memset(buffer, 0, sizeof(buffer));
// 读取客户端发送的数据
int len = read(curfd, buffer, sizeof(buffer));

if(len > 0) {
// 读取到数据,回显给客户端
printf("客户端:%s\n", buffer);
write(curfd, buffer, len);
} else if(len == 0) {
// len == 0 表示客户端主动断开连接
printf("客户端断开连接...\n");
// 从 epoll 实例中删除该 FD,停止监听
epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, &ev);
// 关闭通信套接字,释放资源
close(curfd);
} else {
// read 调用失败,打印错误并退出程序
perror("read failed");
exit(0);
}
}
  • curfd != lfd 时,说明是已连接的客户端有数据发送 / 连接断开;
  • 调用 read() 读取客户端数据:
    • 读取成功(len > 0):打印数据并通过 write() 回显给客户端;
    • 读取到 0 字节(len == 0):客户端主动断开连接,从 epoll 中删除该 FD 并关闭;
    • 读取失败(len < 0):打印错误并退出程序。

五、epoll 两种工作模式(LT vs ET,核心重点)

这是 epoll 最容易踩坑的地方,我们用 “快递” 的类比讲清楚:

1. 水平触发(LT,默认)

  • 类比:你网购了一个快递,快递员送到小区门口,会一直给你打电话(直到你取走);
  • 核心规则:只要 FD 处于 “就绪状态”(比如读缓冲区有数据),每次调用 epoll_wait 都会返回这个 FD;
  • 编程要求:无需一次性读完数据,容错高(比如读了一半数据,下次还能收到通知);
  • 适用场景:新手入门、大部分业务场景(比如普通 Web 服务器)。

2. 边缘触发(ET,高速模式)

  • 类比:快递员只给你打一次电话(不管你取没取),只有新快递到了才会再打;

  • 核心规则:仅当 FD 从 “非就绪” 变为 “就绪” 时触发一次通知(比如新数据到达读缓冲区);

  • 编程要求(必须满足,否则丢数据):

    1. FD 必须设置为非阻塞(避免循环读写时阻塞);
    2. 读写操作必须循环执行,直到返回 EAGAIN/EWOULDBLOCK(表示数据已读完 / 写缓冲区满)。
  • 适用场景:高并发场景(比如百万连接的网关、Redis/Nginx 底层),追求极致性能。

LT vs ET 核心对比表

维度 水平触发(LT) 边缘触发(ET)
触发时机 只要 FD 就绪,持续触发 仅 FD 状态变化时触发一次
阻塞要求 支持阻塞 / 非阻塞 FD 必须用非阻塞 FD
编程难度 简单,容错高 复杂,需循环读写
性能 一般(通知次数多) 更高(通知次数少)
数据丢失风险 高(未读完数据会丢)

关键:ET 模式如何正确读写数据?

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
// 1. 先把 FD 设置为非阻塞
int set_nonblock(int fd) {
int flag = fcntl(fd, F_GETFL);
flag |= O_NONBLOCK;
return fcntl(fd, F_SETFL, flag);
}

// 2. 循环读取数据(直到 EAGAIN)
char buf[1024];
while (1) {
int len = recv(curfd, buf, sizeof(buf), 0);
if (len > 0) {
// 处理数据
send(curfd, buf, len, 0);
} else if (len == 0) {
// 客户端断开连接
epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL);
close(curfd);
break;
} else {
// len == -1
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 数据已读完,正常退出
break;
} else {
// 真正的错误,关闭 FD
perror("recv error");
epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL);
close(curfd);
break;
}
}
}

fcntl()函数
fcntl(file control)是 Unix/Linux 系统中用于控制文件描述符属性的核心系统调用,简单来说,它的作用是 “给已打开的文件描述符设置 / 获取各种参数”。

它的函数原型如下(C 语言):

1
2
3
4
#include <unistd.h>
#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* arg */ );
  • 参数:
    • fd:要操作的文件描述符(比如打开文件返回的 0/1/2 标准输入 / 输出 / 错误,或 open() 返回的自定义 fd)。
    • cmd:操作指令(核心参数,决定要做什么)。
    • ...:可选参数,根据 cmd 的不同,传入不同类型的值(如 intstruct flock)。
  • 返回值: 成功返回非负整数(具体值看 cmd),失败返回 -1 并设置 errno

示例:用 fcntl 设置非阻塞模式

文件描述符(fd)有一组状态标志,其中 O_NONBLOCK 就是非阻塞标志。我们通过 fcntl 读取当前标志,添加 O_NONBLOCK 后再写回,就可以让 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
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>

// 将文件描述符设置为非阻塞模式
int set_nonblock(int fd) {
// 1. 获取当前的状态标志
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) {
perror("fcntl F_GETFL failed");
return -1;
}

// 2. 添加非阻塞标志(用 | 保留原有标志)
flags |= O_NONBLOCK;

// 3. 设置新的状态标志
if (fcntl(fd, F_SETFL, flags) == -1) {
perror("fcntl F_SETFL failed");
return -1;
}

return 0;
}

取消非阻塞:

1
2
flags &= ~O_NONBLOCK; 
fcntl(fd, F_SETFL, flags);

O_NONBLOCK原理简述(了解)

O_NONBLOCK 是一个宏定义(通常在 fcntl.h 中),它的本质是一个整数常量,且这个整数的二进制形式只有某一位是 1,其余位都是 0

比如在大多数系统中:

1
2
#define O_NONBLOCK 04000  // 八进制,对应二进制是 100 0000 0000 0000
// 换算成十进制是 2048,二进制是第 11 位(从 0 开始数)为 1

假设当前 fd 的状态标志 flags 是:

1
2
二进制:1001 0100 1000 0000  (包含 O_NONBLOCK,第 11 位是 1)
十进制:37952

~按位取反运算符,会把二进制的每一位都翻转(1→0,0→1)。

O_NONBLOCK = 04000(二进制 100 0000 0000 0000)为例:

1
2
O_NONBLOCK 的二进制:0000 0000 1000 0000 0000 0000 (简化为 32 位)
~O_NONBLOCK 的二进制:1111 1111 0111 1111 1111 1111

这个取反后的结果,就是清除 O_NONBLOCK 标志的掩码

  • 对应 O_NONBLOCK 的位是 0,其余位都是 1。

六、epoll 常见坑点(避坑指南)

坑 1:ET 模式未设置非阻塞 → 进程阻塞

  • 现象:服务端卡死,无法处理其他连接;
  • 原因:ET 模式下循环读数据,缓冲区空时 recv 阻塞;
  • 解决:必须用 fcntl 把 FD 设置为非阻塞。

坑 2:ET 模式未循环读数据 → 数据丢失

  • 现象:客户端发大量数据,服务端只收到一部分;
  • 原因:ET 模式仅触发一次通知,未读完的数据不会再通知;
  • 解决:循环读直到 errno == EAGAIN

坑 3:监听 EPOLLOUT → CPU 100%

  • 现象:服务端 CPU 占用率极高;
  • 原因:写缓冲区大部分时间可写,epoll_wait 频繁返回,循环空转;
  • 解决:仅当需要写数据时,才添加 EPOLLOUT 监听,写完后立即删除。

坑 4:关闭 FD 未从 epoll 删除 → 无效事件

  • 现象:epoll_wait 频繁返回已关闭的 FD,报错 EBADF
  • 原因:FD 关闭后,内核不会自动从 epoll 红黑树删除;
  • 解决:关闭 FD 前,先调用 epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL)

七、总结(核心要点回顾)

  1. epoll 核心优势:基于红黑树 + 就绪链表管理 FD,回调机制触发事件,mmap 减少拷贝,效率 O (1),无 FD 数量限制;
  2. 两种模式:LT 模式易编程、容错高(默认),ET 模式效率高但需非阻塞 + 循环读写;
  3. 核心步骤:创建监听 FD → 创建 epoll 实例 → 添加监听 FD → epoll_wait 检测事件 → 处理新连接 / 通信数据 → 循环检测;
  4. 避坑关键:ET 模式必须非阻塞 + 循环读写,关闭 FD 前要从 epoll 删除,避免监听不必要的 EPOLLOUT。