Linux网络编程:结合内核数据结构详谈epoll的工作原理

结合 file结构体、eventpoll结构体和 epitem结构体,深入讲解一下 epoll的工作原理。

epoll是一个非常经典的高性能 I/O 多路复用机制,理解其内部结构对掌握 Linux 高性能网络编程至关重要。

核心思想

epoll的核心思想是:将"维护大量连接"和"阻塞等待事件就绪"这两个任务分开 。它自己创建一个内核空间来管理所有需要监听的连接(文件描述符),应用程序只需要去这个内核空间询问"哪些连接有事件了?"即可,而无需像 select/poll那样每次都传递全量的描述符列表给内核。

下面我们通过三个核心数据结构来理解这个过程。


1. 三大核心数据结构

a. struct eventpoll- 总管

这个结构体是 epoll的核心,可以把它想象成 epoll实例的"大脑"或"管理中心"。当调用 epoll_create时,内核就会创建一个 eventpoll对象。

关键成员:

  • rdllist(就绪列表): ​ 这是一个双向链表,也是 epoll高效的关键。所有已经就绪(有数据可读、可写等)的连接的 epitem都会被挂到这个链表上。 ​ 这样,当 epoll_wait返回时,内核只需要检查这个链表是否为空,如果不为空,就直接将链表中的项目返回给应用程序,无需遍历所有被监视的连接。

  • rbr(红黑树的根): ​ 这是一棵红黑树,用于存储所有通过 epoll_ctl(EPOLL_CTL_ADD)添加进来的、需要监听的连接(每个连接对应一个 epitem)。红黑树的作用是高效地管理和查找这些连接。当添加、删除或修改一个监听项时,内核可以在这棵 O(log n) 时间复杂度的树中快速找到目标。

  • wq(等待队列): ​ 当应用程序调用 epoll_wait但没有任何事件就绪时,当前进程(或线程)会在这个等待队列上休眠。当某个被监视的连接有事件发生时(比如数据到达),中断处理程序或驱动除了会将对应的 epitem加入 rdllist,还会唤醒 wq上的进程,这样 epoll_wait就可以返回了。

小结:eventpoll总管着两样东西:1. 所有被监视的连接(rbr红黑树);2. 所有已经就绪的连接(rdllist就绪链表)。

b. struct epitem- 代理人

这个结构体代表一个被 epoll监视的"项"(item),通常对应一个文件描述符(比如一个 socket)。可以把它理解为每个连接在 epoll内部的"代理人"

关键成员:

  • rbn(红黑树节点): ​ 用于将本 epitem挂载到 eventpoll的红黑树 rbr上。

  • rdllink(就绪链表节点): ​ 用于将本 epitem挂载到 eventpoll的就绪链表 rdllist上。当该连接就绪时,它就会被链入;当事件被应用程序处理完后,它会被从链表中移除。

  • ffd(文件描述符信息): ​ 记录了这个 epitem所监视的文件描述符(fd)和其对应的 struct file指针。

  • event(关注的事件): ​ 记录了应用程序关心这个连接上的哪些事件(如 EPOLLIN可读,EPOLLOUT可写)。

  • pwqlist(等待队列头): ​ 这是一个列表,里面存放了针对这个特定连接的"回调挂钩"。更具体地说,它链接了 struct eppoll_entry对象。

c. struct file- 被监视的目标

这个结构体代表一个"打开的文件"。在 Linux 中,一切皆文件,socket 也是一个文件。每个打开的文件描述符(fd)都对应一个 file结构体。

epoll相关的关键成员:

  • f_op(文件操作集合): ​ 这里面包含了指向具体文件类型(如 socket、普通文件)的操作函数指针,例如 read, write, poll等。

  • private_data(私有数据):

    • 对于由 epoll_create创建的 epoll实例本身的 fd,它的 file->private_data会指向我们上面讲的 struct eventpoll对象。这样,通过 epoll的 fd 就能找到它的总管。

    • 对于一个被监视的 socket fd,它的 file->private_data指向的是 socket 相关的数据结构(如 struct socketstruct tcp_sock)。


2. epoll工作流程详解

步骤一:创建 epoll实例 (epoll_create)

  1. 内核创建一个 struct eventpoll对象(总管)。

  2. 初始化总管的 rdllist(就绪链表)和 rbr(红黑树)。

  3. 同时,内核会分配一个文件描述符(比如 epfd)和一个对应的 struct file对象。

  4. 将这个 file对象的 private_data指针指向刚创建的 eventpoll对象。

  5. 返回 epfd给应用程序。此后,应用程序通过操作 epfd来操作整个 epoll实例。

步骤二:添加监视连接 (epoll_ctl(EPOLL_CTL_ADD, fd, ...))

假设我们要监视一个 socket 描述符 sock_fd

  1. 应用程序调用 epoll_ctl(epfd, EPOLL_CTL_ADD, sock_fd, &event)

  2. 内核通过 epfd找到对应的 file,再通过 file->private_data找到 eventpoll总管。

  3. 内核创建一个"代理人" struct epitem对象(epi)。

  4. 初始化 epi:设置它监视的 fd (sock_fd),设置它关心的事件 (event)。

  5. 关键的一步 - 设置回调:

    • 内核会为这个 epitem创建一个 struct eppoll_entry(回调挂钩),并将其加入到 epi->pwqlist中。

    • 然后,内核会调用 sock_fd对应的 file->f_op->poll方法 (对于 socket,就是 sock_poll)。在调用时,它会将上面创建的"回调挂钩"传递给这个 poll方法。

    • socket 的驱动会把这个挂钩(它内部包含一个回调函数 ep_poll_callback)挂到 socket 自己的等待队列上。

    • 这意味着,当这个 socket 上有事件发生(比如数据到达)时,中断处理程序会唤醒 socket 等待队列上的所有条目,其中就包括我们的 ep_poll_callback函数!

  6. 将初始化好的 epitem插入到 eventpoll的红黑树 rbr中。至此,这个连接就被 epoll接管了。

步骤三:等待事件就绪 (epoll_wait)

  1. 应用程序调用 epoll_wait(epfd, events, maxevents, timeout)

  2. 内核通过 epfd找到 eventpoll总管。

  3. 内核查看总管的就绪链表 rdllist

    • 如果 rdllist不为空 :说明已经有连接就绪了。内核将链表中的 epitem取出,将其中的事件信息填充到 events数组中,然后返回就绪的数量。这个过程是 O(1) 的。

    • 如果 rdllist为空 :当前进程会休眠,被加入到 eventpoll的等待队列 wq上。直到超时,或者被某个连接的中断程序唤醒。

步骤四:事件触发与回调(核心中的核心)

这是 epoll高效的精髓所在。

  1. 当被监视的 socket 收到数据时,网卡产生中断,内核协议栈处理数据。

  2. 数据就绪后,会唤醒这个 socket 的等待队列上的所有条目。

  3. 于是,在步骤二中注册的 **ep_poll_callback**​ 回调函数被调用!

  4. ep_poll_callback函数做了以下几件事:

    a. 根据传入的参数,找到对应的 epitem(代理人)。

    b. 将 epitem添加到 eventpoll的**就绪链表 rdllist**​ 的末尾。

    c. 检查 epitem上的事件是否就是已就绪的事件(比如关心读,现在确实可读了)。

    d. 唤醒 eventpoll的等待队列 wq上休眠的进程

  5. 这样,在 epoll_wait中休眠的应用程序进程就被唤醒了。

步骤五:epoll_wait返回

  1. 被唤醒的进程从步骤三的休眠点继续执行。

  2. 它发现 rdllist不再为空,于是开始处理就绪的连接。

  3. 它将 rdllist上的 epitem逐个拷贝到用户空间的 events数组中,并从 rdllist中移除。

  4. epoll_wait调用返回,应用程序拿到就绪的 fd 列表,然后可以进行非阻塞的 I/O 操作了。

总结:为什么 epoll高效?

  1. 避免了无效的遍历select/poll需要内核在每次调用时线性扫描所有传入的 fd,而 epoll只在 epoll_ctl添加时扫描一次。事件就绪时,内核通过回调函数直接"通知" epoll,将就绪项放入链表。epoll_wait只需检查这个链表即可,时间复杂度 O(1)。

  2. 高效的数据结构:使用红黑树来管理海量连接,使得增、删、改操作的时间复杂度为 O(log n),非常适合管理成千上万的连接。

  3. 共享内核/用户空间epoll_wait返回时,通过 mmap等方式(如果可用)共享就绪事件列表,减少了内存拷贝的开销。

简单来说,epoll的高效就在于它从"主动轮询"变成了"事件回调" 。不是每次去问所有连接"你好了没?",而是让每个连接在"好了"的时候主动来报告。eventpoll是总管,epitem是每个连接的代理人,而 file结构体则是连接它们与具体文件(如 socket)的桥梁。

补充:

revents

revents是 I/O 多路复用编程中一个非常关键的概念,尤其在 pollepoll的上下文中。它代表 "返回的事件"

核心定义

  • events : 应用程序关心/监视的事件。你告诉内核:"请帮我监视这个文件描述符,如果它发生了这些事件(如可读、可写、出错等),请通知我。"

  • revents : 内核返回/实际发生的事件。内核告诉你:"你监视的这个文件描述符,实际上发生了这些事件。"

cpp 复制代码
// 1. 内核数据结构(用户不可见)
struct epitem {          // 每个被监视的fd对应一个epitem
    struct rb_node rbn;      // 红黑树节点
    struct list_head rdllink; // 就绪链表节点
    struct epoll_filefd ffd;  // 包含fd和file指针
    struct eventpoll *ep;     // 所属的epoll实例
    uint32_t events;          // 用户关心的事件(EPOLLIN等)
    uint32_t revents;         // 实际发生的事件(类似revents概念)
    // ...
};

如果发生事件,内核会设置revents,然后将发生事件的文件描述符加入就绪队列.

完整的事件处理流程

"发生事件" ​ → ✅ "内核设置 revents" ​ → ✅ "加入就绪队列"

1. 事件发生阶段

  • 当被监视的文件描述符(如 socket)上发生了关心的事件(比如数据到达)

  • 网卡中断或协议栈会触发相应处理

2. 回调触发阶段

  • 内核调用在 epoll_ctl(ADD)时注册的 ep_poll_callback函数

  • 这个回调函数是连接具体文件描述符和 epoll核心的桥梁

3. 设置 revents 阶段

cpp 复制代码
// 在 ep_poll_callback 函数内部大致会发生:
epi = (struct epitem*) private_data;
// 检查实际发生的事件是否在用户关心的事件范围内
if (实际事件 & epi->event.events) {
    // 设置 revents:将实际发生的事件保存到 epitem 中
    epi->revents = 实际事件 & epi->event.events;
}

这里的关键:内核会比较"实际发生的事件"和"用户关心的事件",只有两者有交集时才会继续处理。

4. 加入就绪队列阶段

cpp 复制代码
if (epi->revents) {  // 如果有需要返回的事件
    // 将对应的 epitem 加入到 eventpoll 的 rdllist(就绪链表)
    list_add_tail(&epi->rdllink, &ep->rdllist);
}

5. 唤醒等待进程

  • 唤醒在 eventpoll的等待队列 wq上休眠的进程

  • 这样 epoll_wait就可以返回了

从用户视角看这个流程

相关推荐
了一梨1 小时前
在Ubuntu中配置适配泰山派的交叉编译环境
linux·c语言·ubuntu
network_tester1 小时前
IXIA XM2网络测试仪电源模块损坏维修方法详解
网络·网络协议·tcp/ip·http·https·信息与通信·信号处理
buyutang_1 小时前
Linux网络编程:Socket套接字编程概念及常用API接口介绍
linux·服务器·网络·tcp/ip
杨云龙UP1 小时前
从0搭建Oracle ODA NFS异地备份:从YUM源到RMAN定时任务的全流程
linux·运维·数据库·oracle
DN金猿1 小时前
恢复 Linux 上误删除的文件
linux·运维·服务器
番茄啊1 小时前
使用QNetworkProxy类简化网络应用的代理支持
网络
张3蜂1 小时前
跨站请求伪造(CSRF):原理、攻击与防御全解析
网络·安全·csrf
差点GDP2 小时前
模拟请求测试 Fake Rest API Test
前端·网络·json
远瞻。2 小时前
【环境配置】Ubuntu系统安装cuda
linux·运维·ubuntu