一、为什么要设计epoll
read(socket_fd, buf, sizeof(buf)); // 如果没有数据,进程阻塞
在传统场景下,一个进程/线程往往只能对应一个链接,而一旦这个链接出现阻塞就容易出现被OS挂起的尴尬情况--整个进程/线程都会挂掉,这在工程中是难以接受的。
哪怕后续的select和poll在一定程度上解决部分问题,但依旧留下需要解决的新问题:
文件描述符数量限制(select)、效率不高O(n) -- 每次调用都要把全部描述符集合从用户态拷贝到内核态,内核需要线性扫描所有描述符找出就绪的。
epoll的设计初衷就是消除描述符数量限制、消除 O(n) 扫描 → 改为 O(1) 返回就绪列表、消除用户态/内核态频繁拷贝 → 通过内核共享内存。
二、epoll系统调用
epoll_create1(int flags)
int epfd = epoll_create1(0);
作用:创建一个 epoll 实例,返回一个文件描述符指向该实例。
参数:
flags:常用0或EPOLL_CLOEXEC(执行 exec 时自动关闭)
内核行为:
-
分配
struct eventpoll对象。 -
初始化红黑树、就绪链表、等待队列。
-
分配一个未使用的文件描述符作为句柄。
注意:
-
最终必须调用
close(epfd)释放内核资源。 -
epfd本身也是一个文件描述符,可以用epoll_ctl监视另一个 epoll fd(较少用)。
epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
int epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
作用:控制 epoll 实例,添加/修改/删除要监视的文件描述符。
参数详解:
| 参数 | 说明 |
|---|---|
epfd |
epoll_create 返回的描述符 |
op |
操作类型 |
fd |
要监视的目标文件描述符 |
event |
告诉内核关心什么事件 |
op 取值:
-
EPOLL_CTL_ADD:添加 fd 到红黑树(如果已存在则报错)。 -
EPOLL_CTL_MOD:修改 fd 上监听的事件(如从读改成写)。 -
EPOLL_CTL_DEL:从红黑树删除 fd(如果 fd 已关闭,内核会自动删除)。
struct epoll_event 结构:
struct epoll_event {
uint32_t events; // 事件掩码(告诉内核关心什么)
epoll_data_t data; // 用户数据(告诉内核返回时带什么信息)
};
typedef union epoll_data {
void *ptr; // 可以指向任意用户自定义结构
int fd; // 最常用:直接存放 fd
uint32_t u32;
uint64_t u64;
} epoll_data_t;
常用事件掩码 events:
| 掩码 | 含义 |
|---|---|
EPOLLIN |
可读事件(socket 有数据、对端关闭连接) |
EPOLLOUT |
可写事件(发送缓冲区有空闲) |
EPOLLERR |
错误事件(通常不需要特意注册,会自动返回) |
EPOLLHUP |
挂断事件(对端关闭) |
EPOLLET |
边缘触发(Edge Triggered) |
EPOLLONESHOT |
一次性触发,处理完事件后自动删除,需要重新添加 |
EPOLLRDHUP |
对端关闭连接(TCP 的 FIN 包,比 EPOLLHUP 更精细) |
内核行为(ADD 操作):
-
检查 fd 是否已经在红黑树中。
-
分配一个
struct epitem(红黑树节点)。 -
将 fd 的信息、注册的事件、回调函数
ep_poll_callback封装到epitem中。 -
将
epitem插入红黑树。 -
设置 fd 的回调函数为
ep_poll_callback。
epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
int nfds = epoll_wait(epfd, ready_events, MAX_EVENTS, -1);
作用:等待事件发生,返回就绪的文件描述符列表。
参数:
-
epfd:epoll 实例描述符。 -
events:输出参数,数组用于存放就绪的事件(由用户分配)。 -
maxevents:数组大小,告诉内核最多返回多少个事件。 -
timeout:超时时间(毫秒),-1 表示无限等待,0 表示立即返回。
返回值:
-
>0:就绪的文件描述符个数。 -
0:超时,没有事件就绪。 -
-1:错误,errno 指示错误类型。
内核行为详解:
1. 检查就绪链表 rdllist 是否为空。
2. 如果非空:
- 将就绪链表中的事件复制到用户空间的 events 数组(最多 maxevents 个)。
- 根据触发模式决定是否从就绪链表中移除节点(LT 不移除,ET 移除)。
- 返回复制的事件数量。
3. 如果为空且 timeout > 0:
- 当前进程进入可中断睡眠状态,加入到 epoll 的等待队列。
- 当有事件发生或超时到达时,进程被唤醒。
- 唤醒后,重新检查就绪链表(可能多个事件同时到达)。
4. 如果 timeout == 0:
- 立即返回 0。
关键点:
-
events数组由用户分配,内核只负责填充,避免了内核态分配内存的开销。 -
maxevents建议设为略大于预期同时就绪的数量,典型值 1024。
三、epoll工作原理

一、底层核心内核结构
epoll 在内核中维护两个核心结构体,对应图中红黑树、双向链表结构:
struct eventpoll(epoll 总控对象,epoll_create创建)rbr:红黑树根节点 ,存储所有被监控的文件描述符 fd ,基于epitem的rbn成员构建,增删查 fd 时时间复杂度为 \(O(logN)\);rdllist:就绪事件双向链表 ,存储已经就绪的 fd ,基于epitem的rdllink成员构建,是 epoll \(O(1)\) 获取就绪事件的核心;wq:进程等待队列 ,无就绪事件时,调用epoll_wait的进程会挂载到该队列休眠;
- **
struct epitem(单个 fd 的包装对象,epoll_ctl ADD创建)**每一个被监控的 fd,都会被内核包装为epitem,包含 fd 标识、用户注册的监听事件、红黑树节点、就绪链表节点、回调关联等信息,是红黑树和就绪链表的基础单元。
二、三大系统调用的内核执行逻辑
epoll 通过 3 个系统调用完成创建实例、添加 / 修改 / 删除监控 fd、等待就绪事件全流程,每个调用的内核行为如下:
epoll_create(size):创建 epoll 实例 内核分配struct eventpoll内核对象,初始化红黑树、就绪链表、等待队列,返回文件描述符epfd;入参size在 Linux 2.6.8 后废弃,仅需传入大于 0 的整数。epoll_ctl(epfd, op, fd, event):管理监控的 fdEPOLL_CTL_ADD(添加 fd) :创建epitem并插入红黑树;核心操作:将 epoll 专属回调函数ep_poll_callback注册到 fd 底层驱动的等待队列,实现 "fd 就绪主动通知 epoll";EPOLL_CTL_MOD(修改 fd 监听事件) :修改红黑树中epitem存储的监听事件掩码,不改变回调关联;EPOLL_CTL_DEL(删除 fd) :从红黑树删除epitem,注销 fd 上注册的回调,解除监控关系。
epoll_wait(epfd, events, maxevents, timeout):阻塞等待就绪事件- 内核检查
rdllist就绪链表; - 链表非空:将就绪事件拷贝到用户态,直接返回就绪事件数量;
- 链表为空:将当前进程挂载到
eventpoll->wq等待队列,进程休眠并让出 CPU; - 超时 / 收到信号 / 被唤醒:进程退出休眠,再次检查就绪链表。
- 内核检查
三、核心:回调 + 主动唤醒的事件流转机制
这是 epoll 区别于 select/poll 的核心,以 TCP socket 接收数据为例,流程分为 3 个阶段:
1. 注册阶段(epoll_ctl ADD)
添加 socket fd 到 epoll 时,内核将epoll 的就绪回调函数ep_poll_callback注册到网卡 / 协议栈的等待队列,建立 "fd 就绪→触发回调" 的关联。
2. 就绪触发阶段(IO 事件发生)
网卡收到 TCP 数据,内核协议栈处理后将数据写入 socket 接收缓冲区;socket 缓冲区从空变为非空时,底层驱动触发等待队列中的ep_poll_callback回调函数;回调函数完成 2 个关键操作:
- 将该 fd 对应的
epitem添加到rdllist就绪链表; - 唤醒休眠在
eventpoll->wq队列上的进程。
3. 事件获取阶段(epoll_wait返回)
进程被唤醒后,从休眠位置继续执行,读取rdllist就绪链表中的事件,仅将就绪的少量事件拷贝到用户空间,返回给应用程序。
四、epoll 高性能的本质:对比 select/poll
| 特性 | select/poll | epoll |
|---|---|---|
| 就绪检测方式 | 主动轮询:每次调用遍历所有监控 fd,O(N) | 被动回调:fd 就绪主动通知,仅处理就绪事件,O(1) |
| 数据拷贝 | 全量拷贝:每次调用复制全部 fd 集合 | 增量拷贝:仅拷贝就绪事件,数据量极少 |
| 进程调度 | 无效轮询:进程唤醒后仍需遍历所有 fd | 精准唤醒:仅就绪事件触发时唤醒,无无效检查 |
| 操作复杂度 | 每次调用 O(N) | epoll_ctl O(logN),epoll_wait O(1) |
关键细节
- 零拷贝误区 :epoll 并非完全零拷贝,
epoll_wait仍需将内核就绪事件拷贝到用户空间,但仅拷贝就绪事件,远优于 select/poll 的全量拷贝; - 触发模式:epoll 支持水平触发(LT,默认)和边缘触发(ET),LT 为只要事件未处理就持续通知,ET 为仅事件状态改变时通知;
- 休眠唤醒 :进程在
epoll_wait的休眠是可中断的,仅就绪回调唤醒、定时器超时、接收信号时会退出休眠,无主动轮询行为。
四、水平触发(LT) vs 边缘触发(ET)
- LT(水平触发,默认):只要条件满足,就一直提醒
- ET(边缘触发):只有条件刚发生变化的一瞬间 提醒 1 次
我们把 socket 内核缓冲区 = 一个水桶 ,epoll = 报警器,监控水桶里有没有水(有没有数据可读)
1. 水平触发 LT(默认模式)
只要水桶里还有水 ,报警器就一直滴滴响、不停提醒
- 例子:水桶进了 100ml 水,你舀出 50ml,桶里还剩 50ml;
- 报警器不会停,只要有水,每次看都会提醒你:"还有水!快舀!"
对应 epoll 行为 :只要 socket 缓冲区还有未读完的数据 ,每次调用epoll_wait都会返回可读事件,反复通知你。
2. 边缘触发 ET
只有水刚流进水桶的那一瞬间 ,报警器只响 1 声 之后桶里就算剩很多水,报警器彻底闭嘴,不再提醒,直到下次新水进来
- 例子:水桶进了 100ml 水,报警器响 1 声;你舀出 50ml,桶里剩 50ml;
- 此时你再看报警器,完全不响了;必须等下次新水流进来,才会再响 1 声。
对应 epoll 行为 :只有数据刚到达、缓冲区从无→有(状态发生切换)的瞬间 ,epoll_wait通知 1 次;缓冲区残留数据,永远不会再通知。
| 场景 | 水平触发 (LT) | 边缘触发 (ET) |
|---|---|---|
| 数据到达 | 每次 epoll_wait 都返回 | 只返回一次 |
| 数据没读完 | 下次 epoll_wait 继续返回 | 不再返回(除非新数据到达) |
| 编码难度 | 简单,不易出错 | 困难,需小心处理 |
| 性能 | 可能多次唤醒 | 一次唤醒处理完所有数据 |
| 适用场景 | 通用,低速设备 | 高速大量数据,文件服务器 |
| 模式 | 触发时机 | 核心特点 | 必须注意 |
|---|---|---|---|
| LT 水平触发 | 缓冲区有数据就触发 | 有就一直喊,反复提醒 | 阻塞 IO 也能用,简单安全 |
| ET 边缘触发 | 缓冲区状态变化瞬间触发(无→有) | 变了才喊一次,之后沉默 | 必须用非阻塞 IO,必须一次性读完缓冲区 |
得益于ET这种"强硬模式"倒逼快速读取缓冲区内容,ET的效率明显会比LT模式高。
五、epoll 的惊群问题
什么是惊群?
多个进程 / 线程,同时阻塞在同一个 epoll 的 epoll_wait 上 ;当一个 IO 事件就绪 时,内核会唤醒所有等待的进程 / 线程 ;但最终只有一个进程能处理这个事件 ,其余进程白醒一场,重新休眠。这种无效唤醒、浪费 CPU 资源 的现象,就是 epoll 惊群。
解决方案
方案1:EPOLLEXCLUSIVE
ev.events = EPOLLIN | EPOLLEXCLUSIVE;
给监听事件添加 EPOLLEXCLUSIVE 标志,内核只唤醒一个等待进程,不批量唤醒。适用于多个进程共享同一个监听 socket。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <errno.h>
#define PORT 8080
#define MAX_EVENTS 10
#define PROC_NUM 4 // 开启4个子进程
// 创建监听socket
int create_listen_fd() {
int lfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
addr.sin_addr.s_addr = INADDR_ANY;
bind(lfd, (struct sockaddr*)&addr, sizeof(addr));
listen(lfd, 128);
return lfd;
}
int main() {
int lfd = create_listen_fd();
// 1. 创建1个epoll实例(所有子进程共享)
int epfd = epoll_create1(0);
struct epoll_event ev, events[MAX_EVENTS];
// ============== 核心:添加 EPOLLEXCLUSIVE 解决惊群 ==============
ev.events = EPOLLIN | EPOLLEXCLUSIVE; // 独占唤醒标志
ev.data.fd = lfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
// 2. fork 4个子进程,共享epfd
for (int i = 0; i < PROC_NUM; i++) {
if (fork() == 0) {
printf("子进程 %d 启动\n", getpid());
while (1) {
// 所有子进程阻塞在同一个 epoll_wait
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int j = 0; j < n; j++) {
if (events[j].data.fd == lfd) {
// 仅一个进程会执行到这里!惊群解决
int cfd = accept(lfd, NULL, NULL);
printf("进程 %d 处理新连接\n", getpid());
close(cfd);
}
}
}
}
}
wait(NULL);
return 0;
}
**方案2:**主进程 accept,子进程独立 epoll
适合所有 Linux 版本 ,生产环境首选,从根源彻底杜绝惊群。
主进程 :只负责监听 + accept 新连接;子进程 :每个进程创建独立的 epoll 实例,互不共享;主进程把新连接分发给子进程 → 无共享 = 无惊群
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/wait.h>
#include <string.h>
#define PORT 8080
#define PROC_NUM 4
int create_listen_fd() {
int lfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr = {AF_INET, htons(PORT), INADDR_ANY};
bind(lfd, (struct sockaddr*)&addr, sizeof(addr));
listen(lfd, 128);
return lfd;
}
// 子进程工作函数:独立epoll,处理连接
void child_work() {
// ============== 核心:每个子进程创建自己的 epoll 实例 ==============
int epfd = epoll_create1(0);
struct epoll_event ev, events[10];
printf("子进程 %d 独立epoll创建完成\n", getpid());
while (1) {
int n = epoll_wait(epfd, events, 10, -1);
// 处理自己epoll上的事件(无竞争,无惊群)
}
}
int main() {
int lfd = create_listen_fd();
// 1. 创建4个子进程
for (int i = 0; i < PROC_NUM; i++) {
if (fork() == 0) child_work();
}
// 2. 主进程:只做accept,分发给子进程
printf("主进程 %d 负责监听连接\n", getpid());
while (1) {
int cfd = accept(lfd, NULL, NULL);
// 此处可将cfd发送给子进程(简化版,直接关闭演示)
close(cfd);
}
return 0;
}
六、epoll 常见陷阱
| 陷阱 | 错误做法 | 后果 | 正确做法 |
|---|---|---|---|
| ET模式忘记循环读取 | read() 只调用一次 |
剩余数据永久滞留,无法处理 | while(1) 循环读直到 EAGAIN |
| ET模式使用阻塞fd | fd 未设置 O_NONBLOCK |
最后一次 read 阻塞,进程卡死 |
必须 `fcntl(fd, F_SETFL, flags |
| EPOLLOUT滥用 | 同时注册 `EPOLLIN | EPOLLOUT` | CPU 100% 空转 |
| 忽略ERR/HUP | 只处理 EPOLLIN/OUT |
fd 泄漏,事件反复触发 | 必须处理 `EPOLLERR |
| 多线程不加保护 | 多线程同时操作同一个 epfd | 数据竞争,事件错乱 | 使用 EPOLLONESHOT 或外部锁 |
| data.ptr 忘记释放 | 用 ptr 指向堆内存,关闭时不释放 |
内存泄漏 | close(fd) 前 free(ptr) |
陷阱一:ET模式忘记循环读取
❌ 错误示例
// 注册 ET 模式
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
// 事件处理
void handle_event(int fd) {
char buf[1024];
// 只读一次,如果一次没读完,剩余数据永远无法处理
int n = read(fd, buf, sizeof(buf));
if (n > 0) {
process(buf, n);
}
}
✅ 正确示例
void handle_event(int fd) {
char buf[4096];
while (1) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) {
process(buf, n);
} else if (n == 0) {
// 对端关闭
close(fd);
break;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 数据已全部读完
break;
} else {
// 真正错误
close(fd);
break;
}
}
}
}
陷阱二:ET模式使用阻塞fd
❌ 错误示例
// 没有设置非阻塞,直接 accept 后加入 epoll
int client_fd = accept(listen_fd, ...);
// 忘记调用 set_nonblocking()
ev.events = EPOLLIN | EPOLLET;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
// 事件处理中循环读取
while (1) {
ssize_t n = read(client_fd, buf, sizeof(buf));
if (n == -1 && errno == EAGAIN) {
break; // 永远执行不到这里,因为阻塞 fd 不会返回 EAGAIN
}
}
✅ 正确示例
// 设置非阻塞函数
int set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) return -1;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
// 正确用法
int client_fd = accept(listen_fd, ...);
set_nonblocking(client_fd); // 必须设置
ev.events = EPOLLIN | EPOLLET;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
陷阱三:EPOLLOUT滥用
❌ 错误示例
// 同时注册 IN 和 OUT
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLOUT; // OUT 会一直触发
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
// 事件循环
while (1) {
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
if (events[i].events & EPOLLOUT) {
// 每次 epoll_wait 都会进来,即使没有数据要发
// CPU 瞬间 100%
}
}
}
✅ 正确示例
// 按需注册 OUT
typedef struct {
char* buffer;
size_t len;
size_t sent;
} pending_send_t;
pending_send_t pending[MAX_FD];
// 发送数据函数
void try_send(int fd, const char* data, size_t len) {
ssize_t n = send(fd, data, len, 0);
if (n == -1 && errno == EAGAIN) {
// 缓冲区满,保存待发数据
pending[fd].buffer = strdup(data);
pending[fd].len = len;
pending[fd].sent = 0;
// 添加 OUT 监听
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLOUT;
ev.data.fd = fd;
epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);
}
}
// 事件处理
if (events[i].events & EPOLLOUT) {
int fd = events[i].data.fd;
pending_send_t* p = &pending[fd];
ssize_t n = send(fd, p->buffer + p->sent, p->len - p->sent, 0);
if (n > 0) {
p->sent += n;
if (p->sent >= p->len) {
// 发送完成,移除 OUT 监听
free(p->buffer);
memset(p, 0, sizeof(*p));
struct epoll_event ev;
ev.events = EPOLLIN; // 只保留 IN
ev.data.fd = fd;
epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);
}
}
}
陷阱四:忽略ERR/HUP
❌ 错误示例
// 只处理 EPOLLIN 和 EPOLLOUT
for (int i = 0; i < nfds; i++) {
if (events[i].events & EPOLLIN) {
handle_read(events[i].data.fd);
}
if (events[i].events & EPOLLOUT) {
handle_write(events[i].data.fd);
}
// 忽略 EPOLLERR 和 EPOLLHUP -> fd 永远不会被关闭
}
✅ 正确示例
for (int i = 0; i < nfds; i++) {
int fd = events[i].data.fd;
// 先检查错误和挂断
if (events[i].events & (EPOLLERR | EPOLLHUP)) {
close(fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
continue;
}
if (events[i].events & EPOLLIN) {
handle_read(fd);
}
if (events[i].events & EPOLLOUT) {
handle_write(fd);
}
}
陷阱五:多线程不加保护
❌ 错误示例
// 线程1:不断调用 epoll_wait
void thread1() {
while (1) {
int nfds = epoll_wait(epfd, events, 1024, -1);
// 处理事件...
}
}
// 线程2:同时修改 epoll
void thread2() {
// 没有同步机制,直接修改
epoll_ctl(epfd, EPOLL_CTL_ADD, new_fd, &ev);
}
✅ 正确示例(使用 EPOLLONESHOT)
// 主线程:注册时使用 EPOLLONESHOT
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLONESHOT; // 触发一次后自动禁用
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
// 工作线程:处理完事件后重新注册
void worker_thread(int fd) {
// 处理这个 fd 上的所有数据
handle_read_until_eagain(fd);
// 重新注册,让 epoll 可以再次触发
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLONESHOT;
ev.data.fd = fd;
epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);
}
✅ 正确示例(使用互斥锁)
pthread_mutex_t epoll_mutex = PTHREAD_MUTEX_INITIALIZER;
void modify_epoll(int op, int fd, struct epoll_event* ev) {
pthread_mutex_lock(&epoll_mutex);
epoll_ctl(epfd, op, fd, ev);
pthread_mutex_unlock(&epoll_mutex);
}
陷阱六:data.ptr 忘记释放
❌ 错误示例
struct connection {
int fd;
char* buffer;
size_t buffer_len;
};
// 添加连接
struct connection* conn = malloc(sizeof(struct connection));
conn->fd = client_fd;
conn->buffer = malloc(8192);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.ptr = conn; // 使用 ptr 携带指针
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
// 关闭连接时 - 错误:只关闭 fd,忘记释放 conn
if (error_occurred) {
close(client_fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, client_fd, NULL);
// 内存泄漏!conn 和 conn->buffer 都没有释放
}
✅ 正确示例
// 关闭连接时 - 正确
if (error_occurred) {
// 先获取指针
struct connection* conn = (struct connection*)events[i].data.ptr;
// 释放内部资源
if (conn->buffer) {
free(conn->buffer);
}
// 释放连接结构体
free(conn);
// 关闭 fd
close(client_fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, client_fd, NULL);
}