epoll详解

epoll函数详解

先理解 epoll 的核心结构体,epoll 的事件描述依赖 epoll_data_t(共用体)和 epoll_event(结构体)

1.union epoll_data_t

用户数据共用体

复制代码
typedef union epoll_data {
    void *ptr;    // 自定义数据指针(比如存结构体、对象地址)
    int fd;       // 要监听的文件描述符(最常用)
    uint32_t u32; // 32位无符号整数(少用)
    uint64_t u64; // 64位无符号整数(少用)
} epoll_data_t;
  • 本质是 "共用体":同一时间只能用其中一个字段(因为共用同一块内存);
    • fd:直接存要监听的文件描述符(比如 socket 的 fd),最常用;
    • ptr:存自定义数据(比如绑定一个包含 fd、业务信息的结构体指针),适合复杂业务场景;
  • 作用:将 "监听的 fd" 与 "用户业务数据" 绑定,epoll 就绪时能直接拿到关联数据。

2. struct epoll_event

事件描述结构体

复制代码
struct epoll_event {
    uint32_t events;    // 要监听的事件(输入)/已就绪的事件(输出)
    epoll_data_t data;  // 与事件绑定的用户数据(通过epoll_data_t传递)
} __EPOLL_PACKED;
  • events字段:用 "事件宏" 的组合表示要监听的事件(注册时),或实际就绪的事件(epoll_wait 返回时),常用宏包括:
    • EPOLLIN:读就绪(对应 socket 接收数据、对端关闭等);
    • EPOLLOUT:写就绪(对应 socket 发送缓冲区空闲);
    • EPOLLERR:fd 发生错误(内核自动检测,无需手动注册);
    • EPOLLHUP:fd 被挂断(如 TCP 对端关闭,内核自动检测);
    • EPOLLET:边缘触发模式(Edge Triggered,epoll 的高效模式);
    • EPOLLONESHOT:仅监听一次事件(触发后需重新注册才能再次监听);
  • data字段:关联的用户数据(即上面的epoll_data_t),epoll 就绪时会原封不动返回该数据,方便业务层关联 fd 或自定义信息。

3. epoll_create

复制代码
#include <sys/epoll.h>
int epoll_create(int size);
  • 作用:在内核中创建一个 epoll 实例(管理监听 fd 和事件的内核对象),返回该实例的句柄(文件描述符)。
  • 参数size
    • 早期内核(<2.6.8):表示 "预计监听的 fd 数量",内核据此分配资源;
    • 现代内核(≥2.6.8):该参数被忽略,传任意正整数即可(习惯传 1);
  • 返回值
    • 成功:返回 epoll 实例的句柄(正整数,如 5、6);
    • 失败:返回 - 1,errno存错误原因(如ENOMEM:内核内存不足);
  • 注意 :epoll 实例是一个文件描述符,用完必须调用close(epfd)关闭,否则会造成文件描述符泄漏。

4.epoll_ctl

复制代码
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  • 作用:向 epoll 实例(epfd)中注册 / 修改 / 删除某个 fd 的监听事件。
  • 参数详解:
    1. epfdepoll_create返回的 epoll 实例句柄;
    2. op:表示要执行的动作,用 3 个宏表示:
      • EPOLL_CTL_ADD:向 epoll 实例中注册新的 fd 及事件;
      • EPOLL_CTL_MOD:修改已注册 fd 的监听事件(如从 EPOLLIN 改为 EPOLLOUT);
      • EPOLL_CTL_DEL:从 epoll 实例中删除某个 fd 的监听(删除后不再监控该 fd);
    3. fd:要监听的文件描述符;
    4. event:指向struct epoll_event的指针,描述 "要监听的事件 + 关联的用户数据";
      • op=EPOLL_CTL_DEL时,event可以传NULL(因为删除不需要事件信息);
  • 返回值:
    • 成功:返回 0;
    • 失败:返回 - 1,errno存错误原因(如EBADF:fd 无效;EEXISTop=ADD时 fd 已注册);

5.epoll_wait

复制代码
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
  • 作用 :阻塞等待 epoll 实例中监听的 fd 就绪,将已就绪的事件收集到events数组中。
  • 参数详解
    1. epfd:epoll 实例句柄;
    2. events:用户态预先分配好的struct epoll_event数组,内核会将 "已就绪的事件" 填充到这个数组中;
    3. maxeventsevents数组的长度(即最多能接收多少个就绪事件),不能超过epoll_createsize(虽然现代内核忽略 size,但习惯上保持一致);
    4. timeout:超时时间(单位:毫秒):
      • timeout = -1:永久阻塞,直到有 fd 就绪;
      • timeout = 0:不阻塞,立即返回(仅检查当前就绪状态);
      • timeout > 0:阻塞timeout毫秒,超时后返回;
  • 返回值
    • 成功:返回已就绪的 fd 数量(正整数,≤maxevents);
    • 超时:返回 0(timeout时间内无 fd 就绪);
    • 失败:返回 - 1,errno存错误原因(如EINTR:调用被信号中断);

epoll 的工作原理

1. struct eventpoll

当调用epoll_create时,内核会创建一个eventpoll结构体,它是 epoll 的核心管理对象,包含两个关键成员:

复制代码
struct eventpoll {
    struct rb_root rbr;       // 红黑树的根节点:管理所有"被监控的fd+事件"
    struct list_head rdlist;  // 双向链表:存放"已就绪的fd+事件"
    // 其他成员(锁、等待队列等):保证线程安全和阻塞逻辑
};
  • rbr(红黑树):存储所有通过epoll_ctl注册的 "fd + 事件",作用是高效管理监控对象(去重、插入 / 删除 / 查找的时间复杂度为O(logn))。
  • rdlist(双向链表):存储 "已就绪的 fd + 事件",作用是快速返回就绪结果(epoll_wait直接读取链表,无需遍历所有监控对象)。

2. struct epitem

每个被 epoll 监控的 fd,都会对应一个epitem结构体(你提供的示意图中红黑树 / 双向链表的节点),它是红黑树和双向链表的 "共用节点":

复制代码
struct epitem {
    struct rb_node rbn;       // 红黑树节点:挂到`eventpoll->rbr`上,管理监控关系
    struct list_head rdllink; // 双向链表节点:事件就绪时,挂到`eventpoll->rdlist`上
    struct epoll_filefd ffd;  // 存储要监控的fd(比如socket的fd)
    struct eventpoll *ep;     // 指向所属的`eventpoll`实例(关联到epoll句柄)
    struct epoll_event event; // 存储用户注册的事件类型(如EPOLLIN)和用户数据
};
  • rbn:让epitem成为红黑树的节点,实现对监控 fd 的高效管理;
  • rdllink:让epitem成为双向链表的节点,事件就绪时快速加入就绪队列;
  • event:关联用户注册的事件(如 "读就绪")和自定义数据(对应epoll_data_t)。

3.epoll 工作的完整流程

epoll 的工作分为创建 epoll 实例→注册监控事件→事件就绪触发→等待并获取就绪事件四个核心阶段:

阶段 1:epoll_create------ 创建 epoll 实例

进程调用epoll_create(int size)时:

  1. 内核创建一个eventpoll结构体,初始化其成员:
    • rbr:红黑树的根节点设为空(表示暂无监控对象);
    • rdlist:双向链表的头节点初始化(表示暂无就绪事件);
  2. 内核为这个eventpoll结构体分配一个文件描述符(即epoll_create的返回值epfd);
  3. 后续epoll_ctl/epoll_wait都通过epfd关联到这个eventpoll实例。

阶段 2:epoll_ctl------ 注册 / 修改 / 删除监控事件

以最常用的EPOLL_CTL_ADD(注册新 fd)为例:

  1. 进程传入参数:epfd(关联eventpoll)、要监控的fdstruct epoll_event(事件类型 + 用户数据);
  2. 内核创建epitem结构体:
    • 填充epitem->ffd:存入要监控的fd
    • 填充epitem->event:存入用户注册的事件(如 EPOLLIN)和数据;
    • 填充epitem->ep:指向所属的eventpoll实例;
  3. 红黑树去重检查:
    • 内核通过fdeventpoll->rbr红黑树中查找:若已存在该 fd 的epitem,则返回错误(避免重复注册);
  4. 插入红黑树:
    • epitem通过rbn节点插入eventpoll->rbr红黑树,完成监控对象的注册;
  5. 注册驱动回调:
    • 内核向fd对应的设备驱动(比如 socket 对应网卡驱动)注册一个回调函数ep_poll_callback,告诉驱动:"当这个 fd 有事件发生时,调用这个函数"。

阶段 3:事件就绪 ------ 驱动主动触发回调,加入就绪链表

当被监控的 fd 满足事件条件(比如 socket 收到数据)时:

  1. 设备驱动(如网卡驱动)检测到 fd 就绪(比如接收缓冲区有数据);
  2. 驱动调用之前注册的ep_poll_callback回调函数;
  3. ep_poll_callback执行逻辑:
    • 通过fd找到对应的epitem结构体;
    • epitem通过rdllink节点,添加到eventpoll->rdlist双向链表中(此时该 fd 的事件被标记为 "就绪")。

阶段 4:epoll_wait------ 等待并获取就绪事件

当进程调用epoll_wait(epfd, events, maxevents, timeout)时:

  1. 内核通过epfd找到对应的eventpoll实例;
  2. 检查eventpoll->rdlist双向链表:
    • 若链表为空 :根据timeout参数处理:
      • timeout = -1:进程进入阻塞状态,挂到eventpoll的等待队列中,直到有事件加入rdlist
      • timeout = 0:直接返回 0(表示无就绪事件);
      • timeout > 0:进程阻塞timeout毫秒,超时后返回 0;
    • 若链表非空
      • 遍历rdlist中的epitem,将每个epitem->event(事件类型 + 用户数据)复制到用户态传入的events数组中;
      • 复制完成后,返回就绪事件的数量(即rdlistepitem的个数);
  3. 注意:epoll_wait不会清空rdlist,后续新的就绪事件会继续追加到链表中。

4.epoll 高效的本质

epoll 比 select/poll 高效的核心原因,是它从 "主动轮询" 变成了 "事件驱动",结合数据结构的优化:

  1. 红黑树管理监控对象 :替代了 select 的位图、poll 的数组,实现监控 fd 的高效增删改查 (时间复杂度O(logn)),同时自动去重;
  2. 双向链表管理就绪事件epoll_wait无需遍历所有监控 fd,只需直接读取就绪链表,时间复杂度 **O(1)**;
  3. 驱动回调的事件驱动 :内核无需主动轮询 fd 是否就绪,而是由设备驱动在事件发生时主动调用回调,将就绪事件加入链表 ------ 彻底避免了 select/poll 的 "全量遍历" 开销。

epoll 的 LT/ET 模式,核心差异是 "fd 就绪后,epoll 以什么样的规则向程序发送通知":

  • LT:"状态驱动"------ 只要 fd 处于 "就绪状态",就持续通知;
  • ET:"事件驱动"------ 仅当 fd 的 "就绪状态从无到有" 时,通知一次

水平触发LT

Level Triggered:epoll 默认模式

当 epoll 检测到 fd 的 "就绪状态存在" 时(比如 socket 接收缓冲区有数据),会持续触发通知(epoll_wait 反复返回该 fd),直到就绪状态消失(比如缓冲区数据被读完)。

关键特性

  • 支持阻塞 / 非阻塞读写:哪怕用阻塞 read,没读完数据也不会卡死(因为 epoll_wait 会反复提醒,下次还能读);
  • 容错性高,代码简单:新手友好,无需担心 "数据没读完导致丢失";
  • 通知次数多:fd 就绪状态持续时,epoll_wait 会频繁返回,高并发下略有性能开销。

边缘触发ET

Edge Triggered:高性能模式(需手动设置EPOLLET

仅当 fd 的 "就绪状态发生边缘变化(从无到有 )" 时,epoll 才触发一次通知;即使就绪状态持续(比如数据没读完),也不会再次通知,直到下一次状态变化。

关键特性

  • 仅支持非阻塞读写(工程实践强制要求):这是 ET 模式最核心的易错点,下文单独拆解;
  • 通知次数极少:仅在 "状态变化" 时通知,高并发下性能远高于 LT;
  • 代码复杂度高:必须保证 "一次通知内处理完所有就绪数据",否则数据会丢失。

ET 为什么必须用非阻塞 fd

不是 epoll 接口要求,而是工程实践的硬性规则:

1.问题根源:阻塞读写会导致程序丢失

  • 场景:socket 缓冲区有 1.5KB 数据,程序调用read(fd, buf, 1024)只读了 1KB,假如之后再也没有数据到来,那么这0.5kb数据你就读不到了,数据丢失在某些场景(银行交易记录)中可是重错;
  • 即使你说自己每次都循环读取直到数据读完,但是阻塞模式下数据读完了可就直接卡在read函数了,那你整个程序就阻塞了。

2. 解决方案:非阻塞 + 循环读 / 写

  • 步骤 1:将 fd 设置为非阻塞(通过fcntl);
  • 步骤 2:收到 ET 通知后,循环调用 read/write ,直到返回EAGAIN/EWOULDBLOCK(表示缓冲区已空,无数据可读写)。

示例代码

设置event时使用 ev.events = EPOLLIN | EPOLLET;即可开启ET模式

复制代码
bool Add(const TcpSocket& sock, bool epoll_et = false) const {
int fd = sock.GetFd();
printf("[Epoll Add] fd = %d\n", fd);
epoll_event ev;
ev.data.fd = fd;
if (epoll_et) {
ev.events = EPOLLIN | EPOLLET;
} else {
ev.events = EPOLLIN;
}
int ret = epoll_ctl(epoll_fd_, EPOLL_CTL_ADD, fd, &ev);
if (ret < 0) {
perror("epoll_ctl ADD");
return false;
}
return true;
}

补充1:select/poll 的工作模式

select 和 poll本质上都是 LT 模式------ 它们没有 "边缘触发" 的机制,只要 fd 处于就绪状态,每次调用 select/poll 都会返回该 fd,直到数据处理完;这也是 epoll 的灵活性所在:既兼容 select/poll 的 LT 模式,又提供高性能的 ET 模式。

补充2:为什么ET高效

1.ET没有做重复通知

2.ET模式强制要求了程序员每次要循环读取完数据,但是LT阻塞模式不需要做这种要求,所以LT下程序员可以每次不读取完,反正后面还会接着通知他,所以LT模式下程序员写出的代码读取就可能没有ET下写的读取代码读的快;读的快你的缓冲区就会有更大空间;有更大空间双方IO就可以发送更多数据,自然效率就高了。


可读可写的区别处理

epoll 监听的就绪状态,可读和可写的触发条件、常态相反,这是两者监听策略不同的根本原因:

状态 触发条件 就绪状态的 "常态" 持续监听的后果
可读(EPOLLIN) socket 接收缓冲区有数据 / 对端关闭 偶发态(大部分时间无数据) 正常,epoll_wait 仅在有数据时返回
可写(EPOLLOUT) socket 发送缓冲区有空闲空间(可写) 常态(大部分时间有空闲) 灾难性:epoll_wait 高频返回,CPU 100%

为什么可写状态不能 "一直开启监听"?

原因:socket 发送缓冲区的 "可写状态" 是默认常态(除非你在高并发写数据,把缓冲区写满)。

简单说:持续监听EPOLLOUT,相当于让 epoll "无意义地高频返回",完全违背 epoll"事件驱动、低开销" 的设计初衷。

工程上正确的可写监听策略

实际开发中,EPOLLOUT的监听遵循 "按需注册、用完即撤" 的原则 ------ 只有需要写数据时才注册,写完立刻取消,具体分 3 步:

步骤 1:默认仅监听可读(EPOLLIN)

程序初始化时,给 socket 注册的事件只有EPOLLIN(+EPOLLET,如果用 ET 模式),专注处理 "有数据要读" 的场景:

复制代码
// 初始化:仅监听可读
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // LT模式去掉EPOLLET
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);

步骤 2:需要写数据时,临时注册可写(EPOLLOUT)

当程序要给客户端发送数据(比如响应 HTTP 请求),先尝试非阻塞写:

  • 如果一次写完所有数据:无需注册EPOLLOUT

  • 如果没写完(返回EAGAIN,表示缓冲区满了):立刻注册EPOLLOUT,等待缓冲区空闲后继续写;

    // 尝试写数据
    ssize_t n = write(sockfd, buf, buf_len);
    if (n > 0) {
    buf += n;
    buf_len -= n;
    }

    // 没写完(缓冲区满),注册EPOLLOUT
    if (buf_len > 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
    struct epoll_event ev;
    ev.events = EPOLLIN | EPOLLOUT | EPOLLET; // 临时加EPOLLOUT
    ev.data.fd = sockfd;
    epoll_ctl(epfd, EPOLL_CTL_MOD, sockfd, &ev);
    // 保存未写完的数据,后续处理
    save_remaining_data(sockfd, buf, buf_len);
    }

步骤 3:可写事件触发后,写完数据立刻取消 EPOLLOUT

epoll_wait返回EPOLLOUT时,循环非阻塞写剩余数据,写完后立刻修改事件,去掉EPOLLOUT

复制代码
// epoll_wait返回后,处理可写事件
if (events[i].events & EPOLLOUT) {
    // 循环非阻塞写剩余数据
    while (buf_len > 0) {
        ssize_t n = write(sockfd, buf, buf_len);
        if (n > 0) {
            buf += n;
            buf_len -= n;
        } else if (errno == EAGAIN || errno == EWOULDBLOCK) {
            // 还没写完,继续等下一次可写(但此时仍注册EPOLLOUT)
            break;
        } else {
            // 出错,关闭连接
            close(sockfd);
            epoll_ctl(epfd, EPOLL_CTL_DEL, sockfd, NULL);
            return;
        }
    }

    // 数据全部写完,取消EPOLLOUT监听
    if (buf_len == 0) {
        struct epoll_event ev;
        ev.events = EPOLLIN | EPOLLET; // 恢复仅监听可读
        ev.data.fd = sockfd;
        epoll_ctl(epfd, EPOLL_CTL_MOD, sockfd, &ev);
    }
}
相关推荐
8K超高清2 小时前
风机叶片运维:隐藏于绿色能源背后的挑战
网络·人工智能·科技·5g·智能硬件
数据与后端架构提升之路3 小时前
系统架构设计师常见高频考点总结之计算机网络
网络
xdpcxq10293 小时前
风控场景下超高并发频次计算服务
java·服务器·网络
工业HMI实战笔记3 小时前
【拯救HMI】让老设备重获新生:HMI低成本升级与功能拓展指南
linux·运维·网络·信息可视化·人机交互·交互·ux
代码游侠3 小时前
复习—sqlite基础
linux·网络·数据库·学习·sqlite
一颗青果4 小时前
Reactor模型 | OneThreadOneLoop
运维·网络
步步为营DotNet5 小时前
深入理解.NET 中的IHostedService:后台任务管理的基石
java·网络·.net
梁辰兴6 小时前
计算机网络基础:虚拟互联网络
网络·计算机网络·计算机·计算机网络基础·梁辰兴·虚拟互联网络
小北方城市网6 小时前
第 3 课:前后端全栈联动核心 —— 接口规范 + AJAX + 跨域解决(打通前后端壁垒)
java·大数据·网络·python