目录
[一、什么是 epoll](#一、什么是 epoll)
[介绍 epoll](#介绍 epoll)
[二、epoll 相关的系统调用](#二、epoll 相关的系统调用)
[struct epoll_event 结构体:](#struct epoll_event 结构体:)
[三、epoll 的工作原理](#三、epoll 的工作原理)
[struct eventpoll 结构体](#struct eventpoll 结构体)
[struct epitem 结构体](#struct epitem 结构体)
[epoll 三大核心接口的底层逻辑](#epoll 三大核心接口的底层逻辑)
[补充 :](#补充 :)
本篇文章我们将开启 epoll 的学习之旅。
一、什么是 epoll
介绍 epoll
接下来我们正式开始介绍 epoll,epoll 名称里的字母 e 代表 Enhance 增强,直观来看它可以视作 poll 的升级方案,但 epoll 和 poll 二者底层架构完全独立,epoll 属于重新设计的全新接口,并非在 poll 基础上迭代优化,等同于另起一套完整的内核实现体系。epoll 诞生于 Linux 2.5.44 版本内核,在 Linux 2.6 版本之后趋于成熟稳定,它整合弥补了 select 与 poll 两类接口存在的各类缺陷,是 Linux 平台公认综合性能最优的 IO 多路就绪通知方案。不过这并不意味着开发场景里要无条件优先选用 epoll,select、poll、epoll 三者各有适配场景,实际开发需要结合业务规模灵活取舍,只是高并发海量文件描述符的场景下,epoll 的优势会格外突出。
讲解顺序
关于 epoll 我们讲解序如下:
-
第一步先做快速入门,简略介绍 epoll 配套的三个核心系统调用,先梳理每个函数的函数签名、基础参数与基础作用,先建立起对 epoll 整套 API 的初步认知;
-
第二步深入拆解 epoll 完整运行原理,这也是整个学习环节的核心内容,弄懂内核就绪队列、回调触发等核心机制,理解原理之后,再回头重新审视三个系统调用,弄懂每个参数设计背后的底层缘由;
-
第三步基于前面的 API 与理论,动手编写 epoll 服务端 Demo,复刻之前 select、poll 实现过的回显服务器 echoserver,用实操代码落地接口调用逻辑,切身感受 epoll 的编码写法;
-
第四步结合刚才编写的实战代码,再次回头复盘梳理底层原理,把代码执行流程和内核运行机制一一对应,完成知识点整合归纳,形成完整的知识闭环;
-
第五步落地工程化开发,搭建分层规范的完整版 epoll 服务,贴合实际项目的分层架构思路对接上层业务逻辑,同时妥善处理编写服务时极易出现的各类边界问题、异常隐患,打磨出稳定可用的生产级服务雏形。
二、epoll 相关的系统调用
epoll_create

**epoll_create 是 Linux 提供的系统调用,它的核心作用就是在内核中创建一个独立的 epoll 实例,分配专属的内核存储空间,用来统一管理后续需要监听的全部文件描述符,**这也是使用 epoll 整套机制的起始步骤。
epoll_create 的参数 size 我们暂时先不作展开讲解,我们重点关注返回值:**函数最终返回一个 int 类型整数,其实就是一个合法的文件描述符 fd,这个 fd 代表刚刚新建出来的 epoll 模型实例,后续所有对这个 epoll 实例的增删监听 fd、等待事件就绪等操作,全都需要依靠这个 epoll fd 作为标识来完成,**至于为何 epoll 实例会以文件描述符的形式存在、以及 size 参数的实际意义,我们放到后续内容再详细说明。
epoll_wait

epoll_wait 是 epoll 的核心等待接口,它的作用就是等待 epoll 实例中文件描述符的事件就绪,并把这些就绪事件返回给用户程序。(内核 → 用户)
第一个参数 int epfd 就是 epoll_create 的返回值,是一个创建好的的 epoll 实例的文件描述符,用来指定要操作的是哪个 epoll 实例。
第二个参数 struct epoll_event *events 是一个结构体数组,属于输出型参数。当 epoll_wait 返回时,内核会把所有已经就绪的事件 (对应的 fd 和事件类型) 填充到这个数组里带出来,我们可以遍历这个数组来处理每一个就绪事件。
第三个参数 int maxevents 是第二个参数 events 结构体数组的最大长度,告诉内核最多可以填充多少个就绪事件,防止越界访问。
最后一个参数 int timeout 和 poll 里的 timeout 含义一样,用来设置阻塞等待的超时时间:
- -1:永久阻塞,直到有事件就绪;
- 0:非阻塞,立即返回,没有事件就直接返回 0;
- 正数:等待指定毫秒数,超时后返回 0。
epoll 和 select、poll 最大的区别就是:epoll 不需要我们遍历所有 fd 来判断是否就绪,内核会直接把就绪的事件列表返回给你,我们只需要处理 events 数组里的内容即可,这也是 epoll 高性能的关键之一。至于 struct epoll_event 结构体的具体定义,我们后面再详细拆解。
epoll_ctl

epoll_ctl 是 epoll 的控制接口,它的核心作用就是用户主动告诉内核,要对哪个文件描述符、以何种方式进行事件监听,是 epoll 体系里用户态与内核态交互的关键入口。
第一个参数 epfd 还是通过 epoll_create 创建的 epoll 实例的文件描述符 fd,用来指定要操作的 epoll 对象;
第二个参数 op 代表具体的操作方式,决定了对文件描述符和事件的增删改行为;
第三个参数 fd 就是我们要操作的目标文件描述符,比如监听套接字或客户端通信套接字;
第四个参数 event 是一个指向 struct epoll_event 结构体的指针,用户会在这里定义要监听的事件类型(比如读事件 EPOLLIN)以及需要附带的自定义数据,内核会根据 op 指定的操作,将这个 fd 和对应的事件进行添加、修改或移除,后续 epoll_wait 就能根据这些配置来检测事件就绪状态。
op 的选项如下:

epoll_ctl 的 op 参数提供了三种核心操作,分别对应新增、修改、删除事件,它们是管理 epoll 监听列表的关键:
(1). EPOLL_CTL_ADD 用于向 epoll 实例中新增一个文件描述符的监听配置,也就是告诉内核开始监听该 fd 上指定的事件,这是我们注册新连接监听时最常用的操作。
(2). EPOLL_CTL_MOD 用于修改已存在的监听配置,比如后续需要为同一个 fd 新增写事件监听、调整事件触发模式时,就可以用这个操作更新对应的事件类型。
(3). EPOLL_CTL_DEL 则用于从 epoll 实例中删除某个文件描述符的监听配置,当客户端断开连接、fd 需要被关闭时,我们会先通过这个操作把它从监听列表中移除,避免内核继续对无效 fd 进行事件检测。
这三个操作覆盖了我们管理 epoll 监听列表的全部需求,让用户可以灵活地动态调整监听的 fd 和事件类型。
struct epoll_event 结构体:
epoll_event 结构体是 epoll 接口里的核心结构体,在 epoll_ctl 和 epoll_wait 中都会用到,整体只包含两个成员,分别负责标记事件类型、承载用户自定义数据。 第一个成员 uint32_t events 属于位图格式,用来标识当前文件描述符需要监听的事件,比如读事件 EPOLLIN、写事件 EPOLLOUT都存放在这里。 第二个成员 epoll_data_t data 是 union epoll_data 联合体的类型别名,这一块数据内核不会读写、不会干预 epoll 运行逻辑,是交由上层业务自行使用的,作用就是把就绪事件、对应的 fd、业务对象做绑定关联。
联合体 union epoll_data 内部一共提供四类成员,共用同一块内存空间,同一时刻只能选用其中一个字段赋值:void* ptr 可以存放自定义结构体、类对象指针,用来绑定上层业务模块;int fd 最常用,直接存储当前被监听的文件描述符;u32、u64是两个无符号整数类型,用来存放自定义数值标识。选用联合体设计也是为了节省内存开销,不必同时开辟多块独立空间。
这里有两个问题 :
第一个问题:epoll_ctl 第三个参数已经传了 fd,为什么 epoll_event.data.fd 还要再传一遍 fd?
二者的使用场景不一样,生命周期、读取方式也不同。
epoll_ctl 的第三个参数,仅用于本次增删改操作,内核依靠这个 fd 定位需要调整的监听的事件,只在这一次系统调用过程中生效。而 epoll_event 结构体里 data 下的 fd,属于用户自行定义的数据、并交给内核持久保管,内核并不会用它做检索操作,只是完整留存。
当文件描述符产生就绪事件,epoll_wait 被唤醒之后,内核会把就绪的 epoll_event 结构体(epoll_wait 的第四个输出型参数) 拷贝至用户传入的数组中,上层遍历这份数组,先依靠 events 字段判定具体触发的 IO 事件类型,再取出预先存储在 data 里的 fd,依托这个文件描述符执行 accept、recv 一类业务处理逻辑。
第二个问题:epoll_ctl 第四个参数的 event,是否和第三个 fd 互相绑定?
是的,二者严格绑定。 执行 epoll_ctl,本质就是告诉内核:请对第三个参数指定的 fd,按照 op 操作,注册 / 修改它需要监听的事件 (存放在第四个参数 epoll_event 的 events 成员)。
events 的常见事件:
epoll 各类事件都是一个比特位的宏,能够通过或运算组合监听,日常开发最核心的仅有几类。EPOLLIN 代表读事件;EPOLLOUT 代表写事件;EPOLLERR 代表异常事件。EPOLLET 用来开启边缘触发模式,区别于默认的水平触发,EPOLLONESHOT 则限定 fd 仅触发一次事件,后续需要重新注册才能继续监听。剩下 EPOLLPRI 以及各类带 NORM、BAND 后缀的标识,大多用于紧急带外数据,业务场景极少用到。
三、epoll 的工作原理
红黑树模型
我们上层调用 epoll_create 之后,内核会为本次创建的 epoll 实例构建一棵红黑树。红黑树属于平衡二叉搜索树,规避了普通二叉树失衡以及 AVL 树频繁调整树形带来的性能损耗,因此被内核选用。

这棵红黑树初始化状态为空,每一个树节点都是结构体,内部记录了 fd、需要监听的事件类型,同时节点还预留链表关联字段。 红黑树初始为空,节点的新增、修改、移除全部由用户调用 epoll_ctl 完成:执行 epoll_ctl 时,用户将需要监听的 fd 与目标事件告知内核,内核就会生成对应的红黑树节点,填入 fd 与监听事件,再插入树结构当中。
由此可以得出 :
结论一:epoll_ctl 三个 op 操作增加 / 删除 / 修改在内核底层本质就是对这棵红黑树执行新增、修改、删除节点,整棵红黑树完整保存了当前 epoll 全部需要托管监听的 fd 与事件。
结论二:epoll 会提前把所有待监控的 fd、事件一次性注册到内核,无需像 select、poll 那样,每一轮等待都重复向内核传递全部 fd,极大减少了用户态与内核态之间的数据拷贝开销,这也是 epoll 性能更优的重要原因。
结论三:红黑树作为搜索树,必须依靠唯一键值完成检索维护,epoll 红黑树的键值就是文件描述符 fd,fd 全局唯一,内核依托 fd 就能精准定位节点,这也是 epoll_ctl 执行操作时,必须传入目标 fd 的根本原因。
就绪队列模型
我们调用 epoll_create 创建 epoll 实例时,内核除了创建一颗红黑树,还会创建一个就绪队列,这个队列有明确的特性,内部仅存放已经就绪的事件和该就绪事件对应的文件描述符 fd。
举个例子,三号 fd 注册了 EPOLLIN 读事件,内核先将该 fd 封装为红黑树节点存入红黑树,当这个 fd 产生网络数据、触发可读事件时,操作系统就自动会把对应的节点挂载至就绪队列尾部。也就是说用户提前把要关心的事件和 fd 注册进内核后 (底层就是挂入红黑树),由内核全程自主监测各个 fd 的就绪状态,一旦满足事件就绪条件,就自动链入就绪队列中。

epoll_wait 系统调用只会专门监测这条就绪队列,由于队列内所有节点全部都是已经就绪的连接,epoll_wait 无需遍历整棵红黑树、无需挨个核验 fd 状态,仅仅判断这个就绪队列是否为空,就能知晓当前有无就绪事件,这一步操作的时间复杂度为 O (1),也是 epoll 性能远优于 select、poll 的关键。依托 epoll_ctl 完成 fd 注册、节点插入红黑树后,内核便持续巡检每一个节点,fd 一旦就绪就自动移入就绪队列,就绪队列存在有效节点时,epoll_wait 直接读取队列内容,再借助输出参数把就绪事件交付给用户程序。
节点从红黑树转入就绪队列的过程被称作激活节点,红黑树的结点结构体 rb_node 内嵌了就绪队列所需的 list_node 链表结点,这就让同一个节点既可以挂载在红黑树之中,也能接入就绪双向链表,无需重复新建节点、拷贝数据,仅调整链表指向即可完成入队。 同时结构体内部还预留 revents 字段,当文件描述符的 IO 事件就绪后,内核首先会在该红黑树节点的 revents 字段写入当前实际发生的就绪事件,紧接着利用节点自带的链表结点成员,将这一节点挂载到就绪队列的队尾,完成节点激活。整个过程依托同一个结构体节点完成,没有数据复制,仅仅修改字段赋值与链表指针指向,之后 epoll_wait 直接遍历就绪队列,读取已经填好 revents 的节点信息返回给用户态。整套节点迁移与 revents 就绪事件标记的逻辑,内核会封装激活节点的函数统一执行。
内核回调机制
整个 epoll 模型除了红黑树和就绪队列之外,第三大核心便是内核底层的回调触发机制。

数据抵达网卡之后,会从链路层、网络层逐层向上递交,最终 TCP 协议栈会把数据包封装为 sk_buff 结构,存入对应 socket 的接收缓冲队列 recv_queue,整个流程会触发硬件中断。内核处理中断、完成数据接收之后,便会执行提前绑定好的回调钩子函数,epoll 工作时已将节点激活函数注册到对应 socket 的回调入口,默认状态下这个函数指针为空,不会执行额外逻辑,接入 epoll 之后就会绑定激活逻辑。
一旦数据抵达触发中断,协议栈走完数据缓存工作,就会自动调用这份回调函数,函数内部会完成两步操作,一是给红黑树节点的 revents 字段标记当前触发的事件,二是将该节点挂载到就绪队列尾部,也就是我们此前所说的节点激活流程。
依靠这套内核回调体系,epoll 不再需要主动循环遍历所有 fd 去轮询检测状态,而是交由网卡中断驱动、协议栈回调自动完成节点入队,实现事件的主动上报。
因此红黑树、就绪队列、内核回调机制三者共同构成完整的 epoll 底层模型。
这里数据到达网卡后,就代表着可以读了吗?就代表读事件就绪了吗?
不能直接等同于可以读取、也就是不直接等于读事件就绪,因为二者中间隔着内核协议栈的完整处理流程,网卡收到数据仅仅是整个流程的开端。网卡接收到二进制报文后首先触发硬件中断,内核驱动把数据拷贝到内核缓冲区,之后逐层向上经过链路层、网络层、传输层做拆包、校验、重组。只有 TCP 完成报文排序、去重、重组,把完整有效载荷放进 socket 对应的接收缓冲区 recv_queue 之后,用户程序真正能够调用 recv 拿到数据,此时才判定读条件满足,内核才会执行回调,给 epoll 节点标记 EPOLLIN、推入就绪队列,epoll_wait 才会感知到读事件就绪。简单来说,网卡收到数据包只是物理层面抵达主机,读事件就绪的判定标准是 socket 接收缓冲区存在可供应用层读取的有效数据,协议栈处理完毕才算正式就绪。
struct eventpoll 结构体
至此整个 epoll 模型我们就清楚了,那你可以创建 epoll,我也可以创建,那么内核就得进行管理,**先描述再组织!**所以内核中一定有管理 epoll 模型的数据结构,这个数据结构叫 struct eventpoll,也就是说内核中,每通过 epoll_create 创建的一个 epoll 实例,都会由 struct eventpoll 结构体统一管理,这个结构体内部保存着红黑树的根节点、就绪队列的队首指针,同时维护整套回调相关配置,以此区分、管理进程下多个相互独立的 epoll 实例。

内核为了统一管理 epoll 实例,会把它**"文件化" 处理**:当调用 epoll_create 创建一个 epoll 模型时,内核会为它创建一个对应的 struct file 结构体,和普通文件、socket 套接字一样,被纳入进程的文件描述符表管理。
这个 struct file 结构体里有一个我们很熟悉 void *private_data 指针,如果是普通文件,它指向文件系统相关的结构;如果是 socket,它指向套接字的内核对象;而 epoll 的private_data,就直接指向管理 epoll 的核心结构 struct eventpoll,里面包含了红黑树根节点、就绪队列头指针、回调机制等全部 epoll 相关信息。
这样一来,epoll 实例就和普通文件、socket 一样,在进程中拥有了一个文件描述符。用户拿到这个 fd 后,就可以像操作普通文件一样,通过文件描述符表找到对应的 struct file,再通过private_data 指针找到整个 epoll 模型,进而访问红黑树、就绪队列,完成后续的增删监听、等待事件等操作。
这也解释了为什么:
- epoll_create 的返回值是一个文件描述符;
- epoll_ctl 和 epoll_wait的第一个参数,都必须传入这个 fd------ 它们需要通过 fd 找到对应的 epoll 模型,才能继续操作。
下面我们看一下内核源码中的 struct eventpoll 结构体:
上面一整个结构体就是传说中的 epoll 模型实例,每一次 epoll_create 都会生成一个独立的 eventpoll,内部各个成员各司其职:
- rwlock_t lock 读写锁,用来保护当前 eventpoll 整体结构,并发场景下保障红黑树、就绪队列修改安全,防止多线程同时操作引发数据错乱。
- struct rw_semaphore sem 读写信号量,用来做生命周期保护,epoll 遍历就绪事件时会上读锁,清理 fd、执行 ctl 删除操作时会上写锁,避免文件在 epoll 使用中途被释放销毁。
- wait_queue_head_t wq 是 epoll_wait 阻塞休眠所依托的等待队列。当进程调用 epoll_wait、当前没有就绪事件时,进程就会挂到这个等待队列上休眠;一旦有 fd 进入就绪队列,内核便会唤醒队列里阻塞的进程。
- wait_queue_head_t poll_wait 适配传统 poll 接口兼容逻辑,属于兼容层队列,日常 epoll 业务开发基本无需关注。
- struct list_head rdllist 也就是我们一直所说的就绪队列,双向链表表头,所有已经 IO 就绪的 fd 节点,都会挂载到这条链表上,epoll_wait 优先遍历该链表提取事件。
- struct rb_root rbr 红黑树的根节点,整棵用来存储全部被监听 fd 的红黑树,都以这个字段作为入口,所有通过 epoll_ctl 注册的文件描述符,都会生成红黑树节点挂载在这棵红黑树上。
rb_root 仅仅是红黑树的头部封装,内部只保存一个 rb_node 类型指针,指向整棵树的根节点,内核依靠这个根指针,完成红黑树全部的查找、插入、删除、平衡调整操作,只充当树的入口标识。
这是红黑树标准节点单元,只维护红黑树自身平衡特性,不存放 fd、events 业务字段:
- rb_parent_color:联合体字段,既存储父节点地址,又利用低位比特标记节点颜色,宏RB_RED、RB_BLACK 分别标识红、黑两色,是红黑树实现自平衡的核心标记。
- rb_left、rb_right 两个指针,分别指向当前节点的左子节点、右子节点。 我们此前聊到的 fd、监听事件、revents 就绪标记、就绪链表挂载的 list_node,都封装在包裹 rb_node 的上层自定义节点里,rb_node 只负责树形组织,业务数据依托外层结构体承载,这也是内核容器式编程的典型写法。
struct epitem 结构体
struct epitem 正是 epoll 体系里真正承载业务数据的核心节点,这个结构体既可以挂载在 eventpoll 的红黑树,又能够接入就绪双向链表中。内核采用内嵌结构体的容器编程思想,**epitem 自身并不直接是红黑树节点,而是内部封装了标准的 struct rb_node 与 struct list_head,依靠这两个内嵌成员,同一个 epitem 实例,既能依托 rb_node 接入红黑树作为树节点,又能凭借 rdllink 这条链表成员加入就绪队列,一身两用,**这也是该结构体命名 epitem 的缘由,代表 epoll 当中独立的监听条目。它和此前单纯负责树形拓扑的 rb_node 并非同级概念,二者属于包含与被包含的层级关系,这也是二者最核心的区别。
下面逐一讲解 epitem 内部关键成员各自承担的职责:
- 第一个成员 struct rb_node rbn 就是标准的红黑树节点,内核依靠该字段将整个 epitem 挂载至 eventpoll 的红黑树根 rbr 之下,完成所有监听 fd 的有序存储、检索、增删操作,红黑树的平衡调整、父子指针跳转全部围绕这个内嵌 rb_node 开展。
- 第二个成员 struct list_head rdllink,属于双向链表表头,一旦当前 fd 事件就绪,内核便会通过 rdllink,把完整 epitem 节点挂载到 eventpoll 的就绪链表 rdllist 中,实现就绪节点统一归集,epoll_wait 后续遍历就绪队列,遍历的就是依托 rdllink 串联起来的全部 epitem。
- struct epoll_filefd ffd 用来绑定当前条目归属的文件描述符,记录 fd 数值与对应内核文件结构,明确这条监听条目具体绑定哪一个套接字。
- struct eventpoll *ep 是反向指针,每一个 epitem 都会指向所属的 eventpoll 实例,在节点执行入队、清理、回调逻辑时,可以快速找到归属的 epoll 主体,避免跨实例错乱。
- struct epoll_event event 存储用户通过 epoll_ctl 注册时填写的监听事件,也就是用户想要关注的 EPOLLIN、EPOLLOUT 这类事件。
- 末尾的 unsigned int revents 是运行时就绪标记,网络报文抵达、IO 事件发生之后,内核会在这里写入实际触发的事件类型,这也是 epoll_wait 向上层返回事件的数据源。
- 其余 pwqlist、nwait、usecnt、flink、txlink 等字段,分别用于 poll 兼容等待队列、引用计数防内存释放、套接字全局条目挂载、临时中转队列管理,主要保障多并发、生命周期安全与系统兼容性,属于底层稳定性支撑字段。
最后理清 epitem 与 rb_node 的层级逻辑,二者不存在并列关系,是典型的内核内嵌容器设计。struct rb_node 只是一套通用的红黑树骨架,只提供左孩子、右孩子、父节点与颜色标记,不携带任何 fd、事件等数据,本身无法独立使用;而 struct epitem 是业务实体,把 rb_node 作为内部成员嵌入自身,内核利用 container_of 宏,通过 rb_node 指针反向拿到外层完整的 epitem 结构体。同理,就绪队列依靠 rdllink 链表头,也是依托同一套容器机制,从链表指针回溯整个 epitem。因此红黑树遍历操作操作的是 epitem 内部的 rbn,就绪队列串联的是 epitem 内部的 rdllink,外层 epitem 本体始终唯一,这就达成了同一个业务节点,同时存在于两棵不同数据结构之中的设计,这也解释了为何 epitem 可以同时作为红黑树节点、就绪队列节点,而 rb_node 仅仅只是它内部用于树形组织的基础构件。
下面我们再从文件的角度看一下:
从文件系统的角度,epoll 的实现和 Linux 内核 "一切皆文件" 的设计哲学是完全贯通的,我们顺着截图里的层级关系来梳理:
进程的 task_struct 里有一个 struct files_struct *files,这就是进程的文件描述符表管理结构。files_struct 内部有 fd_array 数组,进程打开的所有文件、套接字,包括 epoll_create 创建的 epoll 实例,都会在这里被分配一个文件描述符 fd,每个 fd 对应一个struct file结构体。
struct file 是内核中代表 "打开的文件" 的通用对象,里面有一个 void *private_data 指针,这个指针是实现多态的关键:普通文件里它指向文件系统相关对象,socket 里它指向套接字内核结构,而 epoll 实例的 struct file 里,private_data 就直接指向 epoll 的核心结构 struct eventpoll。
这样一来,用户拿到epoll_create返回的 fd 后,就能通过进程文件描述符表找到对应的 struct file,再通过 private_data 指针访问到s truct eventpoll,进而操作里面的红黑树、就绪队列等所有 epoll 相关数据。
因为 private_data 的类型是 void* 的 ,我们这里不方便查看 所以我们可以用大模型帮我们查找:简单来说,struct file里的 private_data 是内核为各类特殊文件(比如 socket、epoll 实例)预留的扩展指针,用来指向该文件类型特有的内核结构。在 epoll 的实现中,这个指针就指向管理 epoll 实例的核心结构 struct eventpoll,这样用户拿到 epoll_create 返回的 fd 后,就能通过进程文件描述符表找到对应的 struct file,再通过 private_data 指针访问到 struct eventpoll,进而操作里面的红黑树、就绪队列等所有 epoll 相关数据。这也是为什么 epoll_ctl 和 epoll_wait 都需要传入 epoll 的 fd------ 内核需要通过 fd 找到对应的 struct file,再找到它背后的 struct eventpoll。
再谈回调机制
socket 在内核由 struct sock 结构体管理,结构体内部预留了一组函数指针,也就是套接字的回调指针,其中 sk_data_ready、sk_write_space、sk_error_report 分别对应数据抵达、发送缓冲区空余、连接报错三类场景,epoll 运行的核心就是依托这几组回调函数实现事件主动上报。
网卡收到外部数据后发起硬件中断,内核驱动将数据拷贝至内核缓冲区封装为 sk_buff,随后数据沿协议栈逐层向上解析,抵达 TCP 传输层之后,报文会存入该 socket 的接收队列 recv_queue。协议栈完成数据落缓冲后,便会检测当前 sock 结构体内各个回调指针,只要指针不为空,就会执行绑定好的回调逻辑。我们执行 epoll_ctl 注册 fd 监听时,内核就会把 epoll 专属的节点激活逻辑赋值给 sk_data_ready 这类回调指针。一旦 TCP 接收缓冲区存入数据,系统便自动触发该回调函数执行函数逻辑,函数内部就会找到对应的 epitem 节点,填充 revents 就绪标识,再将节点从红黑树挂载至 eventpoll 的就绪队列 rdllist,完成节点激活。内核到此就结束事件推送工作,用户进程调用 epoll_wait 时,仅会遍历就绪队列提取事件,不再需要遍历全部监听 fd,这也是 epoll 摆脱轮询、做到高效触发的根本。
额外补充一处要点:sk_write_space 会在发送缓冲区腾出可用空间时触发,对应 EPOLLOUT 可写事件;sk_error_report在套接字发生异常时执行,主动上报错误事件,三类回调各司其职,完整覆盖 epoll 监听的读写与异常场景。
epoll 三大核心接口的底层逻辑
-
调用 epoll_create 时,内核会为你创建一个独立的 epoll 模型实例,核心是构建 struct eventpoll 结构体,其中包含红黑树的根节点、就绪队列头指针,同时创建一个对应的 struct file 对象并分配文件描述符 fd,最终返回给用户,整个模型的指针关系也同步完成初始化。
-
调用 epoll_ctl,本质就是向指定 epoll 模型的红黑树中执行增、删、改节点操作:EPOLL_CTL_ADD 会为目标 fd 创建 struct epitem 节点并插入红黑树;EPOLL_CTL_MOD 修改节点上的监听事件;EPOLL_CTL_DEL 则将节点从红黑树中移除。
-
调用 epoll_wait,进程会阻塞等待就绪队列中的节点,一旦有 fd 触发事件并被内核回调机制移入就绪队列,epoll_wait 就会直接从队列中取出节点并通过输出参数返回给用户,整个过程时间复杂度为 O (1),无需遍历全部监听 fd。
-
当不再使用 epoll 模型时,直接close关闭对应的 epoll fd,内核会自动清理 eventpoll 结构体、红黑树节点、就绪队列等全部关联资源,完成释放。
补充 :
- 还有一点就是 epoll_create 的参数 size 问题,这个参数在 Linux2.6.8 之后就被忽略了,但是必须要>0。现在也没有任何实际作用,只是一个为了兼容而保留的占位参数。
从 Linux 2.6.8 版本开始,内核改进了 epoll 的实现:
- 采用了完全动态扩展的数据结构,不再依赖 size 做预分配。
- size 参数被内核直接忽略,它的值不会影响红黑树、就绪队列的容量。
- 唯一的限制是:为了兼容旧代码,必须传入一个大于 0 的值,否则调用会失败。
所以在 Linux 2.6.8 版本之前 size 的含义就是红黑树或者就绪队列的结点个数的上限。
-
epoll_wait 的返回值代表本次调用中就绪事件的数量,因此后续遍历事件数组时,只需要从下标 0 遍历到返回值减 1 即可,这个范围内的每一个元素,都是内核返回的有效就绪事件,无需额外判断。
-
在调用 epoll_ctl 或 epoll_wait 时,涉及 fd 和 events 的传递都必须发生用户态与内核态之间的数据拷贝。epoll_ctl 会把用户传入的监听事件拷贝到内核,而 epoll_wait 会把内核中就绪事件的信息拷贝到用户态的数组中。这是因为内核不会直接暴露自身的核心数据结构给用户态,用户态只能通过拷贝的方式,完成数据的传递与获取,这也是系统调用的基本安全设计原则。
四、总结
本文深入解析了Linux的epoll机制,包括其核心原理和系统调用实现。epoll通过红黑树管理监听文件描述符,使用就绪队列高效返回事件,并借助内核回调机制实现事件主动通知。相比select/poll,epoll具有O(1)时间复杂度、无需重复注册等优势。文章详细介绍了epoll_create、epoll_ctl和epoll_wait三个系统调用的工作原理,剖析了内核中的eventpoll和epitem数据结构,以及它们如何通过文件描述符体系与用户空间交互。最后指出epoll特别适合高并发场景,但需要根据业务需求选择合适的IO多路复用技术。
谢谢大家的观看!









struct file 是内核中代表 "打开的文件" 的通用对象,里面有一个 void *private_data 指针,这个指针是实现多态的关键:普通文件里它指向文件系统相关对象,socket 里它指向套接字内核结构,而 epoll 实例的 struct file 里,private_data 就直接指向 epoll 的核心结构 struct eventpoll。
这样一来,用户拿到epoll_create返回的 fd 后,就能通过进程文件描述符表找到对应的 struct file,再通过 private_data 指针访问到s truct eventpoll,进而操作里面的红黑树、就绪队列等所有 epoll 相关数据。

所以在 Linux 2.6.8 版本之前 size 的含义就是红黑树或者就绪队列的结点个数的上限。