IO多路转接(epoll方案)

内容分享4天前发布
0 0 0

文章目录

初识epollepoll的定位epoll相关系统调用接口介绍1. epoll_create(用来创建一个epoll实例)2. epoll_ctl(向epoll模型中添加/删除要监控的文件描述符)3. epoll_wait(等待事件的就绪)
epoll的原理 && 重谈接口epoll模型的结构epoll模型中维护着两个非常重要的数据结构:一棵红黑树,一个就绪队列,那这俩数据结构是干啥用的呢?假如说我用户既要等待一个文件的读事件,也要等待他的写事件,请问这俩事件在红黑树中是用一个节点记录,还是两个节点记录?如果这俩同时就绪,这俩事件在就绪队列中是用一个节点记录,还是两个节点记录?epoll的回调机制是干什么用的?为什么要选红黑树作为存储被监控文件的数据结构?用数组不行吗?1. **fd 值可能远超数组的合理容量**,这会导致数组占用的空间太大了2. **删除操作的“标记法”会导致数组遍历效率退化**3. fd 具有“重用性”,用它作为索引key值是不妥的关键本质:fd 是“动态变化的非连续标识”总结
红黑树中存的是用户让内核帮忙关心的文件(所有被监控的文件),就绪队列中存的是被监控的文件中已就绪的文件,既然它们存的都是文件,那红黑树节点的类型和就绪队列中节点的类型是不是一样的呢?假如说内核在检测的时候发现某个被监控文件的被监控事件就绪了,那就需要及时将这个事件的epitem结构体插入到就绪队列中,请问这个插入是不是将红黑树中某个节点的epitem结构体复制一份,然后拷贝到就绪队列中呢?红黑树本质上就是一棵平衡过后的排序二叉树,既然要排序,那排序的依据(排序的key值)是什么呢?为什么epoll_create创建的是一个epoll模型,但是返回的却是一个int类型的文件描述符呢?为什么不返回一个eventpoll类型的结构体呢?
epoll模型这样的结构设计,相比之前select和poll的优点是什么?1. 输入无需重复初始化2.epoll支持更大规模的并发连接3.select/poll 需要每次调用时将整个文件描述符集合从用户区拷贝到内核区,而epoll就不用4.epoll就绪队列仅保存活跃的文件描述符,内核无需遍历全部描述符即可快速获取就绪事件
基于epoll模型的结构,再谈epoll的运行原理1. 创建epoll实例(epoll_create)2. 管理监控对象(epoll_ctl)3. 等待就绪事件(epoll_wait)总结
实现一个简单的EpollServerepoll的工作模式问题:ET && LT什么是ET和LT?我前面实现EpollServer的时候,也没管你ET和LT模式啥的啊,你接口不也没有相关参数吗,我不也能照常实现一个简单的EpollServer吗,你说说我前面实现的那个EpollServer,他是ET还是LT呢?epoll通过struct epoll_event 数组将某个文件的可读事件就绪通知给用户之后, 该事件对应的节点会从就绪队列中立即删除吗?LT和ET谁更高效?为什么?设计者提出了ET方案,程序员应该如何实现这个方案?**(1)我先通过系统调用获取接收缓冲区的数据量,再一次性调用 read 读取所有数据,这种实现ET模式的方案行不行?**(2)虽然我不知道你这个缓冲区中有多少数据(动态参数,获取成本高),但我可以知道你这个缓冲区的最大容量MAXSIZE(静态参数,很容易获得),我直接通过read系统调用读取MAXSIZE个数据,你要是不够就给我全读上来,这种做法实现ET行不行?(3)为什么要采用循环 read 的方案来实现ET模式呢?
为什么ET模式要求所有的fd都必须采用非阻塞式IO?为什么LT模式没有要求所有的fd都必须采用非阻塞式IO?如果我在LT模式下采用非阻塞IO+循环read的策略,这是不是就相当于ET模式呢?为什么要有ET和LT?
epoll的使用场景

初识epoll

按照man手册的说法: epoll是为处理大批量句柄而作了改进的poll.
它是在2.5.44内核中被引进的(epoll(4) is a new API introduced in Linux kernel 2.5.44),几乎具备了之前所说的一切优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法.

epoll的定位

所谓epoll的定位就是epoll是用来干嘛的
epoll的定位和select、poll是一样的,都是一种多个IO事件的等待机制,通过epoll可以用来实现多个IO事件的等待与派发(IO多路转接)

epoll相关系统调用接口介绍

1. epoll_create(用来创建一个epoll实例)

函数原型


int epoll_create(int size);

功能
创建一个epoll实例,并返回一个文件描述符,后续对这个epoll实例的操作都通过这个文件描述符进行。参数
size
在旧版本内核中有一定含义,用于提示内核该epoll实例要处理的大约句柄数,但从Linux 2.6.8 开始,该参数被忽略,不过依然需要传入一个大于0的值,通常传入1即可。

返回值
成功时返回一个非负的文件描述符,用于标识新创建的epoll实例;失败时返回 -1 ,并设置
errno
以指示错误原因,比如
ENFILE
表示达到了系统允许打开的文件描述符上限 ,
EMFILE
表示当前进程打开的文件描述符数量已达到上限。

示例代码


int epoll_fd = epoll_create(1);
if (epoll_fd == -1) {
    perror("epoll_create");
    // 处理错误
}

2. epoll_ctl(向epoll模型中添加/删除要监控的文件描述符)

函数原型


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

功能
用于对指定的epoll实例(由
epfd
标识)进行管理,可添加、修改或删除要监控的文件描述符。


epfd
:是
epoll_create
调用返回的epoll实例的文件描述符。
op
:操作类型,有三个取值:

EPOLL_CTL_ADD
:将文件描述符
fd
添加到epoll实例中进行监控。
EPOLL_CTL_MOD
:修改对文件描述符
fd
的监控事件。
EPOLL_CTL_DEL
:从epoll实例中删除文件描述符
fd
,此时
event
参数可以为
NULL

fd
:用户要内核监控的那个文件的文件描述符,可以是socket描述符、管道描述符等。
event
:指向一个
struct epoll_event
结构体(和poll中的struct pollfdle类似),用于指定要监控的事件和用户数据等信息。
struct epoll_event
结构体的定义如下:


struct epoll_event {
    uint32_t events;      /* Epoll events */
    epoll_data_t data;    /* User data variable */
};

typedef union epoll_data {
    void        *ptr;    // 可以指向用户自定义的任意数据
    int          fd;     // 关联的文件描述符
    uint32_t     u32;    // 32位无符号整数,可用于存储一些简单的整数值
    uint64_t     u64;    // 64位无符号整数,可用于存储较大的整数值或双精度浮点数转换后的整数形式
} epoll_data_t;

其中events可以是以下几个事件(宏)的集合(和poll中的events原理一模一样,不再赘述了)

EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);EPOLLOUT:表示对应的文件描述符可以写;EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);EPOLLERR:表示对应的文件描述符发生错误;EPOLLHUP:表示对应的文件描述符被挂断;EPOLLET :将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的.EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里.

返回值
操作成功时返回 0 ;失败时返回 -1 ,并设置
errno
,例如
EBADF
表示
epfd

fd
不是有效的文件描述符,
EINVAL
表示
op
参数不合法。

示例代码


struct epoll_event event;
event.events = EPOLLIN;
event.data.fd = sockfd;  // sockfd是要监控的socket描述符
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sockfd, &event) == -1) {
    perror("epoll_ctl");
    // 处理错误
}

3. epoll_wait(等待事件的就绪)

函数原型


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

功能
等待所监控的文件描述符上有事件发生。

参数解析
首先说epoll_wait的返回值和参数int timeout,因为这俩和前面我们说的poll的返回值和参数int timeout含义是一模一样的!


epfd
:epoll实例的文件描述符,由
epoll_create
返回。在调用epoll_wait之前,用户还可以通过epoll_ctr向epoll模型中添加要内核帮忙监控的文件描述符,添加完之后我们再将epoll实例的epfd传入epoll_wait的参数中,这样我们就成功地将用户要求内核监控哪些文件告知epoll_wait了
struct epoll_event * events
:当epoll执行完毕时,会将用户关注的那些事件中已经就绪的事件依次写入一个
struct epoll_event
结构体数组中,然后将这个数组的指针作为输出型参数返回
maxevents
:指定
events
数组的大小,即最多能返回的就绪事件数量。
timeout
:指定等待的超时时间(单位是毫秒):
如果
timeout
为 -1 ,表示永久阻塞,直到有事件发生才返回。如果
timeout
为 0 ,则立即返回,不阻塞。如果
timeout
大于 0 ,则最多等待
timeout
毫秒,若期间有事件发生则提前返回。

返回值
成功时返回就绪事件的数量;如果在超时时间内没有事件发生,返回 0 ;失败时返回 -1 ,并设置
errno
,比如
EINTR
表示等待过程中被信号中断。

示例代码


struct epoll_event events[10];  // 最多处理10个就绪事件
int num_events = epoll_wait(epoll_fd, events, 10, -1);
if (num_events == -1) {
    perror("epoll_wait");
    // 处理错误
} else if (num_events > 0) {
    for (int i = 0; i < num_events; i++) {
        if (events[i].events & EPOLLIN) {
            // 处理可读事件
        }
    }
}

通过组合使用这三个系统调用接口,就能实现基于epoll的高效多路I/O复用,以应对高并发的网络编程等场景。

epoll的原理 && 重谈接口

首先我们用一个问题来作为引入,大家想一想epoll_create()这个函数,他的作用是创建一个epoll模型。这个epoll模型的结构是什么?为什么epoll_create()的返回值不是一个用来描述epoll模型的结构体而是int类型的文件描述符呢?带着这些问题,我们就来看看epoll模型的结构

epoll模型的结构

如果说要我们简述epoll模型的结构,我们就可以说
epoll模型=红黑树+就绪队列+回调机制
首先我们来看一下描述epoll模型的结构体定义


/* epoll 实例的核心数据结构 */
struct eventpoll {
    /* 保护该结构体的互斥锁:用于多线程/进程操作 epoll 实例时的同步 */
    struct mutex mtx;

    /* 红黑树根节点:存储所有被该 epoll 实例监控的 epitem 结构体 */
    struct rb_root rbr;

    /* 就绪链表头:所有触发了事件的 epitem 会被添加到该链表 */
    struct list_head rdlist;

    /* 等待队列:当调用 epoll_wait() 时,若没有就绪事件,进程会阻塞在该队列 */
    wait_queue_head_t wq;

    /* 就绪等待队列:用于唤醒等待在 epoll_wait() 上的进程 */
    wait_queue_head_t poll_wait;

    /* 用于标记 epoll 实例状态的标志位(如 EPOLLWAKEUP、EPOLLEXCLUSIVE 等) */
    struct user_struct *user;
    unsigned int flags;

    /* 红黑树中 epitem 的数量 */
    int nr;

    /* 内核内部使用的回调函数:用于处理文件描述符关闭等事件 */
    struct file *file;

    /* 就绪事件的数量(部分内核版本有此成员) */
    atomic_t event_count;

    /* 用于高效处理边缘触发(ET)模式的就绪链表 */
    struct list_head ovflist;

    /* 自旋锁:保护红黑树和就绪链表的并发访问 */
    spinlock_t lock;
};

epoll模型中维护着两个非常重要的数据结构:一棵红黑树,一个就绪队列,那这俩数据结构是干啥用的呢?

我们前面说过。Io多路转接就是用户通过epoll告诉内核,你要帮我关心哪些文件的哪些事件是否就绪,然后epoll返回时告诉用户,你让我监控的那些文件的中有哪几个文件的事件已经就绪了。红黑树里面存的,就是用户让内核帮忙关心的文件,一个红黑树的节点就对应一个文件。就绪队列里面存的。就是用户让内核儿帮忙关心的那些文件中,等待事件就绪了的那些文件。
也就是说这俩数据结构,一个对接输入,一个对接输出,还记得我们在poll里面学过的参数struct pollfd fds[]数组吗?一个pollfd就对应一个文件,
pollfd::events
中记录的是用户想让内核帮忙关心这个文件的哪些事件,
pollfd::revents
中记录的是poll检测时,用户让关心的事件中有多少事件已经就绪了。感觉是不是有点像?

假如说我用户既要等待一个文件的读事件,也要等待他的写事件,请问这俩事件在红黑树中是用一个节点记录,还是两个节点记录?如果这俩同时就绪,这俩事件在就绪队列中是用一个节点记录,还是两个节点记录?

都是一个节点!在epoll内核中,无论一个文件被监控多少种事件类型(读、写、异常等),都只会有一个节点,这个节点的类型是 struct epitem(epoll item 的缩写)(后面会有介绍)

epoll的回调机制是干什么用的?

简单来说就是负责将红黑树中就绪的等待事件及时地加入到就绪队列中

具体来说,当通过
epoll_ctl
向红黑树中添加一个FD时,内核会为该FD注册一个回调函数。这个回调函数的工作流程是:

当FD对应的I/O事件就绪(例如socket接收到数据、文件可写入等)时,内核会自动触发该回调函数(信号触发)回调函数被触发后,会将该FD对应的事件结构体(包含FD编号和事件类型,如EPOLLIN读事件)插入到epoll实例的就绪队列中。若此时用户进程正阻塞在
epoll_wait
上,内核会唤醒进程,使其能立即从就绪队列中获取并处理这些就绪事件。

总结一下,就绪队列中的节点哪来的?从红黑树的节点中来。谁将节点插入到就绪队列中的?这个节点对应FD的回调函数。什么时候插入的?该节点对应文件的等待事件就绪的时候

为什么要选红黑树作为存储被监控文件的数据结构?用数组不行吗?

你有的同学就讲了,我们前面select和poll用的都是数组来存的,为啥你epoll就不用了呢?假如说我们用数组来模拟实现一个哈希表,作为存储被监控文件的数据结构。数组的下标就是索引用的fd,这样就能通过数组的随机访问特性,实现O(1)的查找效率,想要删除这个元素,就直接把对应fd下标的元素置为空指针,这样删除的效率也是O(1)。这不比红黑树强吗?

如果直接用数组下标对应 fd 值,确实能实现 O(1) 的查找和修改,但这种设计在实际场景中存在三个难以解决的问题,导致它并不适合 epoll 的需求:

1. fd 值可能远超数组的合理容量,这会导致数组占用的空间太大了

Linux 中的 fd 是“进程级的整数标识”,其值理论上可以很大(例如 32 位系统中最大可接近 2^31)。如果直接用 fd 作为数组下标:

若 fd 达到 100 万,数组就需要预先分配 100 万个元素的空间,即使实际只监控 1 万个 fd,也会浪费 99 万的内存(每个元素至少是一个指针,按 8 字节计算,100 万元素就是 8MB,1 亿元素就是 800MB,这对内核来说是不可接受的)。而实际应用中,fd 往往是“稀疏分布”的(例如同时监控 fd=1、fd=1000、fd=100000),数组中会存在大量空洞,内存利用率极低。

而红黑树则无需预先分配空间,只存储实际被监控的 fd,内存占用与监控数量严格匹配(O(n) 而非 O(max_fd))

2. 删除操作的“标记法”会导致数组遍历效率退化

你提到“删除时将对应元素置为空指针”,这种方式看似简单,但会引发新问题:

当需要遍历所有被监控的 fd 时(例如内核偶尔需要检查所有监控项的状态),必须跳过所有空指针,时间复杂度会退化为 O(max_fd)(而非 O(n))。例如,若数组容量是 100 万,即使只监控 1 万个 fd,遍历也需要检查 100 万个位置,其中 99 万是空指针,效率极低。

而红黑树的遍历天然是“只访问有效节点”的(通过左右子树指针),时间复杂度严格为 O(n),无冗余操作。

3. fd 具有“重用性”,用它作为索引key值是不妥的

fd 的“重用性”:

同一个fd在不同的时间指向的不一定是同一个文件
比如fd=0对应的是标准输入文件键盘,但是我先通过close(0)将这个标准输入文件关闭,然后将fd=3的文件log.txt重定向到fd=0,此时我fd=0对应的就是log.txt文件。因此如果你采用fd作为哈希表的索引,他有时候不一定就对。同一个fd在不同的进程中指向的大概率也不是同一个文件

即使如此,由于进程的隔离性,我依然觉得fd是满足检索时的唯一性的,只是在同一个fd新老文件的交替时我们要多做点工作

假设旧的 fd=5 被关闭并从监控中删除(数组位置 5 置空),但新的 fd=5 被打开后,若未显式添加到监控,数组位置 5 的空值会导致误判(以为未被监控);若新 fd=5 需要被监控,还需先检查位置 5 是否有残留状态(避免旧数据干扰),逻辑复杂。

红黑树通过
struct file *
(内核唯一标识文件的指针)而非 fd 作为 key,彻底避免了 fd 重用的问题——即使 fd 相同,只要对应的
struct file
不同(新旧文件),就会被视为不同的监控项。(原文链接)

IO多路转接(epoll方案)

关键本质:fd 是“动态变化的非连续标识”

数组的优势依赖于“下标是连续、紧凑、范围可控的整数”,但 fd 并不满足这些特性:

fd 是进程动态分配的,可能不连续(例如 3、5、100);fd 范围可能很大(远超合理数组容量);fd 会被重用,导致“值相同但代表的文件不同”。

因此,用数组下标映射 fd 的设计,看似能实现 O(1) 操作,实则在内存效率、遍历性能和状态管理上存在难以克服的缺陷。

总结

epoll 选择红黑树而非数组,并非因为红黑树的单次操作比数组快,而是因为:

红黑树的内存占用与实际监控数量成正比(无浪费);红黑树的增删改查复杂度稳定在 O(log n),不受 fd 范围和分布影响;红黑树通过
struct file *
作为 key,避免了 fd 重用带来的混淆。

这些特性使其比“fd 映射数组”更适合管理“动态、稀疏、大范围”的文件描述符集合,这也是 epoll 能高效支撑高并发场景的核心原因。

IO多路转接(epoll方案)

红黑树中存的是用户让内核帮忙关心的文件(所有被监控的文件),就绪队列中存的是被监控的文件中已就绪的文件,既然它们存的都是文件,那红黑树节点的类型和就绪队列中节点的类型是不是一样的呢?

没错,红黑树和就绪队列中的节点类型完全相同,都是
struct epitem
,这个结构体专门用来记录某个被epoll监控的文件的文件描述符及其事件信息,其定义如下


/* 描述被epoll监控的文件描述符及其事件信息 */
struct epitem {
    /* 红黑树节点:用于将该epitem插入到epoll实例的红黑树中,实现高效查找 */
    struct rb_node rbn;

    /* 就绪链表节点:当文件描述符发生事件时,通过该节点加入epoll实例的就绪链表 */
    struct list_head rdllink;

    /* 引用计数:跟踪该epitem的被引用次数,用于生命周期管理 */
    atomic_t usecnt;

    /* 关联的epoll实例:指向该epitem所属的eventpoll结构体 */
    struct eventpoll *ep;

    /* 被监控的文件描述符信息:包含fd和对应的file结构体指针 */
    struct epoll_filefd ffd;

    /* 事件掩码:记录用户关心的事件(如EPOLLIN、EPOLLOUT等)及内核触发的事件 */
    struct epoll_event event;

    /* 等待队列链表:链接到被监控文件的等待队列,用于注册回调函数 */
    struct list_head pwqlist;

    /* 该epitem在poll操作中的等待数 */
    int nwait;

    /* 链接到文件的epitem链表:用于当文件关闭时快速清理相关epitem */
    struct list_head fllink;

    /* 私有数据:供内核内部使用的临时标记或状态 */
    void *priv;
};

/* 辅助结构体:存储被监控的文件描述符及其对应的file结构体 */
struct epoll_filefd {
    struct file *file;  /* 指向被监控fd对应的file结构体 */
    int fd;             /* 被监控的文件描述符数值 */
};

假如说内核在检测的时候发现某个被监控文件的被监控事件就绪了,那就需要及时将这个事件的epitem结构体插入到就绪队列中,请问这个插入是不是将红黑树中某个节点的epitem结构体复制一份,然后拷贝到就绪队列中呢?

不是的,整个过程不需要任何拷贝。说到这里有的同学可能就会有疑问。你一个epitem结构体,怎么能够同时存在两个不同的数据结构中呢?想想我们曾经在图中学过的一种结构——十字链表。看下面这个画红圈的节点,他就是既在黄线对应的单链表中,又在绿线对应的单链表中
IO多路转接(epoll方案)
epitem节点也是类似的道理,它内部有两个节点结构体,一个里面记录的是红黑树中的指针,另一个里面记录的则是就绪队列中的指针


/* 描述被epoll监控的文件描述符及其事件信息 */
struct epitem {
    /* 红黑树节点:用于将该epitem插入到epoll实例的红黑树中,实现高效查找 */
    struct rb_node rbn;

    /* 就绪链表节点:当文件描述符发生事件时,通过该节点加入epoll实例的就绪链表 */
    struct list_head rdllink;
	...
}


红黑树中的节点结构体rb_node,用于链接当前节点在红黑树中的左右子节点,


struct rb_node {
    unsigned long  __rb_parent_color;  // 低2位存储颜色,高位存储父节点指针
    struct rb_node *rb_right;          // 指向右子节点
    struct rb_node *rb_left;           // 指向左子节点
} __attribute__((aligned(sizeof(long))));

就绪队列中的节点结构体list_head,用于链接当前节点在就绪队列中的前后节点


struct list_head {
    struct list_head *next;  // 指向链表中的「下一个节点」(后继)
    struct list_head *prev;  // 指向链表中的「上一个节点」(前驱)
};

区别仅在于:
红黑树通过 epitem->rbn 管理所有被监控的 fd(无论是否就绪);
就绪队列通过 epitem->rdllink 管理已触发事件的 fd(是红黑树中部分节点的子集)。
这种设计既保证了对大量 fd 的高效管理(红黑树的 O (log n) 操作),又实现了就绪事件的快速查询(直接遍历就绪队列),是 epoll 高性能的关键原因之一。

红黑树本质上就是一棵平衡过后的排序二叉树,既然要排序,那排序的依据(排序的key值)是什么呢?

红黑树负责存储的,是用户让内核帮他关心的那些文件,既然是文件,在操作系统中一定有文件描述符,epoll模型中的红黑树,其排序的key值就是文件的文件描述符fd

为什么epoll_create创建的是一个epoll模型,但是返回的却是一个int类型的文件描述符呢?为什么不返回一个eventpoll类型的结构体呢?

如果返回一个epoll模型,那你操作系统原先又没有定义管理epoll模型的相关接口,很多接口都需要你自己重写,那就太费事了。而在linux系统中一切皆文件,如果我们将一个epoll模型封装为一个文件,那操作系统就可以通过管理文件的接口对epoll进行文件统一管理,这就非常方便。
因此我们在创建好一个epoll模型之后,会形成一个eventpoll类型的结构体,然后我们会为这个epoll模型进一步创建一个struct file结构体,通过让这个struct file中的成员变量 void *private_data指向刚刚创建的eventpoll类型的结构体,然后我们再将这个struct file的文件描述符fd返回给用户,完成epoll模型的文件封装。

epoll模型这样的结构设计,相比之前select和poll的优点是什么?

1. 输入无需重复初始化

select在每次调用前都需要重新初始化他的输入位图,而epoll只需初始化一次,后面就不用了

2.epoll支持更大规模的并发连接

select所能等待的最大文件数受fd_set位图大小的限制(通常最大为1024),epoll 没有这样的限制,能支持数万甚至数十万的并发连接

3.select/poll 需要每次调用时将整个文件描述符集合从用户区拷贝到内核区,而epoll就不用

因为epoll_create创建出来的epoll实例他就存在内核区!epoll实例中的红黑树也在内核区!我们在程序中执行epoll_ctr添加要监控的文件时,其实就是向位于内核区的红黑树中添加一个rdnode节点。当你在循环调用epoll_wait进行不断地检测时,每次都是直接从内核区的红黑树中直接得知用户要内核监控的文件,根本没有将fd集合从用户区拷贝到内核区的过程!

4.epoll就绪队列仅保存活跃的文件描述符,内核无需遍历全部描述符即可快速获取就绪事件

select/poll 返回的fd集合都是类似位图的形式,即你要遍历这个fd集合,一个个地查看这个文件中是否有事件就绪了。但epoll返回的就绪队列中,全都是就绪的文件。比如说我此次等待没有一个文件是就绪的。那么select和poll都需要遍历整个fd集合,最终发现没一个文件就绪,他才能得到这个结论(时间复杂度是O(n))。但是我epoll只需要去检查返回的就绪队列是否为空就行了(事件复杂度是O(1))

基于epoll模型的结构,再谈epoll的运行原理

epoll的运行原理基于”红黑树+就绪队列”的核心结构,整体流程可以分为三个阶段:创建实例、管理监控对象、等待就绪事件,具体如下:

1. 创建epoll实例(epoll_create)

调用
epoll_create
时,内核会在内核空间创建一个epoll实例:
初始化一棵红黑树:用于存储所有需要监控的文件描述符(FD)及其事件(如读/写/异常),红黑树的特性确保了FD的插入、删除、查找操作能在O(log n)时间内高效完成。初始化一个就绪队列(通常是双向链表):用于临时存放已触发(就绪)的事件,这些事件会被快速返回给用户态。生成一个epoll文件描述符(epfd):作为用户态操作该epoll实例的唯一标识,后续通过
epoll_ctl

epoll_wait
操作时都需要传入此fd。

2. 管理监控对象(epoll_ctl)

用户通过
epoll_ctl
对红黑树中的监控对象进行管理,主要操作包括:

添加(EPOLL_CTL_ADD):将需要监控的FD及其关注的事件(如EPOLLIN表示读事件)注册到红黑树中。此时内核会为该FD设置回调函数,当FD对应的I/O事件就绪时(如数据到达),回调函数会将该FD对应的事件结构体加入就绪队列。修改(EPOLL_CTL_MOD):更新红黑树中已有FD的监控事件(如从监控读事件改为监控写事件)。删除(EPOLL_CTL_DEL):将FD从红黑树中移除,停止对其监控,同时内核会取消该FD的回调函数。

总结来说:

EPOLL_CTL_ADD = 将节点添加到红黑树中 + 为该节点创建一个回调函数EPOLL_CTL_MOD = 修改节点epitem的属性EPOLL_CTL_DEL = 将节点从红黑树中删除

这一步的核心是:所有监控对象的管理都通过红黑树高效完成,且只需一次注册(后续无需重复传递整个FD集合),大幅减少了用户态与内核态的数据交互开销

3. 等待就绪事件(epoll_wait)

当用户调用
epoll_wait
时,内核会执行以下操作:

检查就绪队列是否有数据:
若就绪队列不为空,内核会将队列中的事件结构体(包含就绪的FD和事件类型)一次性拷贝到用户态缓冲区,并返回就绪事件的数量。若就绪队列为空,进程会进入阻塞状态(可设置超时时间),直到有事件触发或超时。 事件触发机制:当某个FD的I/O事件就绪时(如socket接收到数据),内核会通过之前注册的回调函数,将该FD对应的事件结构体插入就绪队列,并唤醒阻塞在
epoll_wait
上的进程。

这一步的关键是:用户态只需处理就绪的FD(而非遍历所有监控对象),时间复杂度接近O(1),效率远高于select/poll的轮询机制。

总结

epoll的高效本质是通过红黑树实现监控对象的高效管理,通过就绪队列实现就绪事件的快速获取,再配合回调机制一次注册多次使用的设计,彻底解决了select/poll在高并发场景下的性能瓶颈(如FD数量限制、轮询开销、频繁数据拷贝等)。

实现一个简单的EpollServer

和前面的selectServer相比,架构都没咋变,就是因为函数调用接口的不同,改了一些代码(但是epoll实现起来还是要方便一些,因为不用每次调用之前都重新初始化)


#pragma once

#include <iostream>
#include <string>
#include <memory>
#include <sys/epoll.h>
#include "Log.hpp"
#include "Socket.hpp"

using namespace SocketModule;
using namespace LogModule;

const int gdefaultfd = -1;

#define MAX 4096

// 最开始的时候,tcpserver只有一个listensockfd
class EpollServer
{
    static const int revs_num = 64;

public:
    EpollServer(int port)
        : _port(port),
          _epfd(gdefaultfd),
          _listen_socket(std::make_unique<TcpSocket>()),
          _isrunning(false)
    {
    }
    void Init()
    {
        // 0. 监听套接字初始化
        _listen_socket->BuildTcpSocketMethod(_port);
        // 1. 创建epoll模型
        _epfd = epoll_create(256);
        if (_epfd < 0)
        {
            LOG(LogLevel::ERROR) << "epoll_create error";
            exit(EPOLL_CREATE_ERR);
        }
        LOG(LogLevel::DEBUG) << "epoll_create success: " << _epfd;

        // 2. 将监听套接字listensock添加到epoll模型中
        struct epoll_event ev;
        ev.events = EPOLLIN;
        ev.data.fd = _listen_socket->Fd(); // 
        int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, _listen_socket->Fd(), &ev);
        if (n < 0)
        {
            LOG(LogLevel::ERROR) << "epoll_ctl error";
            exit(EPOLL_CTL_ERR);
        }
    }
    void Loop()
    {
        int timeout = -1; // epoll设置为阻塞等待
        _isrunning = true;
        while (_isrunning)
        {
            // 我们不能让accept来阻塞检测新连接到来,而应该让epoll来负责进行就绪事件的检测
            // 用户告诉内核,你要帮我关心&rfds,读事件啊!!
            int n = epoll_wait(_epfd, _revs, revs_num, timeout);
            switch (n)
            {
            case 0:
                std::cout << "time out..." << std::endl;
                break;
            case -1:
                perror("epoll_wait");
                break;
            default:
                // 有事件就绪了
                // rfds: 内核告诉用户,你关心的rfds中的fd,有哪些已经就绪了!!
                std::cout << "有事件就绪啦..., timeout: " << std::endl;
                // Dispatcher(n); // 把已经就绪的sockfd,派发给指定的模块
                break;
            }
        }
        _isrunning = false;
    }

    // 调用accept函数,从监听套接字的全缓冲队列中获取新连接,为其创建一个新的通信套接字
    // 再将新的通信套接字添加到epoll模型中,告诉内核要帮我关注这个新文件的读事件
    void Accepter() // 回调函数呢?
    {
        InetAddr client;
        // listensockfd就绪了!获取新连接不就好了吗?
        int newfd = _listen_socket->Accepter(&client); // 会不会被阻塞呢?不会!select已经告诉我,listensockfd已经就绪了!只执行"拷贝"
        if (newfd < 0)
            return;
        else
        {
            std::cout << "获得了一个新的链接: " << newfd << " client info: " << client.Addr() << std::endl;
            // 我们需要将这个新的newfd添加给epoll,让epoll帮我们进行监管
            // 将这个新套接字文件添加到epoll模型中,告诉内核要帮我关心它的读事件
            struct epoll_event ev;
            ev.events = EPOLLIN;
            ev.data.fd = newfd;
            int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, newfd, &ev);
            if (n < 0)
            {
                LOG(LogLevel::ERROR) << "epoll_ctl error";
                close(newfd);
            }
            LOG(LogLevel::DEBUG) << "epoll_ctl success: " << newfd;
        }
    }
    
    // 调用recv函数,从通信套接字的接受缓冲区中获取数据
    // 然后将这个数据打印到屏幕上,并原封不动地发送回去
    void Recver(int fd) // 回调函数?
    {
        // 合法的,就绪的,普通的fd
        // 这里的recv,对不对啊!不完善!必须得有协议!
        char buffer[1024]; // 你怎么保证,你把一个或者多个完整的报文,全部读完了??,我们要能够把历史读到的数据进行缓存
        ssize_t n = recv(fd, buffer, sizeof(buffer) - 1, 0); // 会不会被阻塞?就绪了
        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "client# " << buffer << std::endl;
            // 把读到的信息,在回显会去
            std::string message = "echo# ";
            message += buffer;
            send(fd, message.c_str(), message.size(), 0); // bug
        }
        else if (n == 0)
        {
            LOG(LogLevel::DEBUG) << "客户端退出, sockfd: " << fd;
            // 把fd从epoll中移除, 必须保证fd是一个合法的fd.
            int m = epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);
            if (m < 0)
            {
                LOG(LogLevel::ERROR) << "epoll_ctl error";
                return;
            }
            LOG(LogLevel::DEBUG) << "epoll_ctl success: " << fd;
            close(fd);
        }
        else
        {
            LOG(LogLevel::DEBUG) << "客户端读取出错, sockfd: " << fd;
            // 把fd从epoll中移除
            int m = epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);
            if (m < 0)
            {
                LOG(LogLevel::ERROR) << "epoll_ctl error";
                return;
            }
            LOG(LogLevel::DEBUG) << "epoll_ctl success: " << fd;
            close(fd);
        }
    }
    void Dispatcher(int rnum) // rfds就可能会有更多的fd就绪了,就不仅仅 是listenfd就绪了
    {
        // rnums传的是就绪队列的大小
        // 就绪队列中全是就绪的事件,因此
        for (int i = 0; i < rnum; i++)
        {
            int events = _revs[i].events;
            int fd = _revs[i].data.fd;
            // 如果就绪事件所属fd是监听套接字listensockfd,那么就说明有新连接到来
            if (fd == _listen_socket->Fd())
            {
                if (events & EPOLLIN)
                {
                    // listen sock fd 就绪
                    Accepter();
                }
            }
            else
            {
                // 普通文件描述符就绪
                if (events & EPOLLIN)
                {
                    // 读事件就绪
                    Recver(fd);
                }
                // else if(events & EPOLLOUT)
                // {
                //     //写事件就绪
                // }
            }
        }
    }
    ~EpollServer()
    {
        _listen_socket->Close();
        if (_epfd >= 0)
            close(_epfd);
    }

private:
    uint16_t _port;
    std::unique_ptr<Socket> _listen_socket;
    bool _isrunning;
    int _epfd;
    struct epoll_event _revs[revs_num];
};

epoll的工作模式问题:ET && LT

什么是ET和LT?

ET(Edge Triggered,边缘触发)
LT(Level Triggered,水平触发)
这个就和示波器里面的触发和水平触发的概念很相似,主要区别就在于通知的时机和频率。

下面我们就结合一个生活中的例子来理解,假如说你在学校宿舍里。买了一堆快递。商家在接到商单之后将快递发送到你们学校快递站。快递站的工作人员会把你们这栋宿舍楼当天的快递运到楼下,然后一个个打电话通知同学来取。

LT模式:今天的工作人员是个老好人,小哥打电话给你,让你下来取你的6个快递。你下来之后发现6个快递我拿不完。跟小哥说我先拿上去4个,马上我再过来拿剩下的两个。结果你上去之后,突然你的女神给你发了消息。你忙着煲电话粥,把拿快递的事给忘了。等你打完了电话。才发现快递小哥儿后来又给你打了很多次电话,提醒你叫你下来把你剩下的两个快递拿走。你感觉到比较愧疚,没想到这个快递小哥还挺负责的。ET模式:第二天你又来了6个快递。但是今天排班轮到另外一个小哥给你送到楼下,这个小哥儿的风格儿就和前面的小哥儿不一样。他到你楼下之后依然是开始给每个人打电话。轮到给你打电话的时候,你这个时候又在忙,比如说在打王者。你接了电话之后就跟小哥说,小哥,我现在这里有点事情,马上我下来再拿,行不行?小哥说没问题。今天晚上6点之前。我一直在你们宿舍楼下,记得你下来拿就行。我就不再打电话提醒你了。过了6点之后,不管你有没有下来拿,我都下班了,之后你就别找我了。你说我知道了,准备下午5:55的时候下楼去拿,然后你就又去打游戏去了。打着打着突然又接到了快递小哥的电话,你非常纳闷儿:不是说就打一次吗?怎么又打来了?接了电话之后,快递小哥说。觉是这样的。你昨天还剩两个快递没有拿走。昨天的那个快递员。刚刚下班之前特地又把这两个快递送到了楼下,东西有点多,你晚上6点前记得一并取走,后面我就不提醒你了哈。

有了上面的类比,大家应该对ET和LT的处理方式有了一个大致的认识。下面我们就一个更具体一些的例子来说说明ET和LT的区别

在一个epoll模型中,假如说用户先前调用了epoll_ctr,要求内核帮用户关心一下fd=3这个套接字文件的读事件是否就绪。然后调用epoll_wait等待事件就绪。当fd=3这个套接字文件的读事件就绪时,该文件的epitem会被添加到就绪队列中,epoll 触发一次 “可读事件”,唤醒因为epoll_wait而阻塞等待的进程,告诉他你等的事件到了(类似于快递小哥到你楼下打电话,告诉你你的快递到了)。然后你就通过epoll的输出型参数struct epoll_event * events拿到了就绪队列,打开一看果然,fd=3这个文件现在读就绪了,然后我就要调用 read()来读取这个套接字文件的接收缓冲区中的数据了。

假如说此时这个缓冲区中有1000KB的数据,但是我这时候调用read,只读了100字节的数据,然后本次循环节就结束了(类似于你一趟下来快递没拿完),请问进入下一个循环节再调用epoll_wait时,epoll_wait返回的struct epoll_event数组中,还有fd=3文件的struct epoll_event吗?(上次调用epoll_wait时,epoll_wait返回的struct epoll_event数组中肯定是有fd=3文件的struct epoll_event的,也就是说上次调用epoll_wait时已经通知过用户,fd=3的读事件已经就绪了。我们的问题就是,上次通知过了,但没读完,fd=3的接受缓冲区中依然有数据,fd=3依然是可读的,那这次还会再通知吗)

如果采用LT模式,那他这次还会再通知,也就是说下一个循环节再调用epoll_wait时,epoll_wait返回的struct epoll_event数组中还有fd=3文件的struct epoll_event。LT模式的原则是,只要文件接受缓冲区中依然有数据,那他就依然处于读就绪的状态,那下次查的时候就还会再通知(类似于第一天的快递小哥,只要你没把快递取完,他就会一直给你打电话)

如果采用ET模式,那他这次大概率不会再通知了(类似于第二个小哥,打第一个电话时就和同学说好,你记得下来拿完,后面不管你有没有拿完,我都不再通知你了), i但是有一种情况下他还会再通知,就是fd=3这个通信套接字的接受缓冲区又收到了来自对端的新数据(类似于第二个小哥儿在通知过你之后,中途又收到了属于你的两个新快递,他就会再给你打一次电话)。ET模式我的理解就是和示波器的上升沿触发道理是一样的,只要缓冲区中有新的数据进来,你下次用epoll_wait时他就会通知你一次

IO多路转接(epoll方案)

我前面实现EpollServer的时候,也没管你ET和LT模式啥的啊,你接口不也没有相关参数吗,我不也能照常实现一个简单的EpollServer吗,你说说我前面实现的那个EpollServer,他是ET还是LT呢?

LT,epoll默认就是LT

epoll通过struct epoll_event 数组将某个文件的可读事件就绪通知给用户之后, 该事件对应的节点会从就绪队列中立即删除吗?

ET模式会
在ET模式下,一旦数据就绪了,epoll_wait 取走了就绪节点,该节点就会在就绪队列rdlist 中立马移除。除非又有数据从网络传递到我的接收缓冲区,否则 就绪队列就没他了,epoll_wait () 返回的struct epoll_event数组中下次也没它了(即便数据没有被取完!)LT模式不会
只要该文件的接收缓冲区有数据,该节点就会一直在 rdlist 中,除非你把数据取完,否则 epoll_wait () 返回的struct epoll_event数组中一直都有它

LT和ET谁更高效?为什么?

ET更高效,原因主要有两点

ET模式只通知一次
举个例子就是收快递,假如说你今天到了10个快递,请问你是去快递站一次性拿10个快递回来高效,还是一次拿一个快递,一共跑10趟高效呢。这个例子反映到我们的epoll实例中,他通知用户的方式就是通过epoll_wait的输出型参数struct epoll_event数组,每通知一次,就意味着要调用一次epoll_wait。而epoll_wait 是 Linux 下用于等待文件描述符上事件发生的系统调用它需要切换到内核态运行。而我们知道用户态和内核态之间来回切换也是耗CPU的,而这部分时间对于处理任务来说是完全没用的。对于LT而言,较多的通知次数就意味着较多次的系统调用,也就意味着较高的状态切换时间开销,最终意味着较低的效率ET的策略会倒逼程序员必须在epoll_wait返回之后立即将缓冲区中的数据全部读走
由于ET模式下只通知一次的机制,它会倒逼程序员必须在epoll_wait返回之后,当前循环节结束之前将缓冲区中的数据全部读走。因为如果不一次读完,下次epoll_wait它也不提醒你,就很可能导致数据读取不完整,比如报文格式缺失,程序会报错!
这样一次性清空接收缓冲区的策略,会使得TCP的接收窗口增大,进而提升对端的滑动窗口的大小,最终使得通信带宽增大!提高了IO效率!

设计者提出了ET方案,程序员应该如何实现这个方案?

如果你是epoll机制的实现者,上司给你提了要求:ET模式下必须一次性把缓冲区清空。你该怎么做呢?

(1)我先通过系统调用获取接收缓冲区的数据量,再一次性调用 read 读取所有数据,这种实现ET模式的方案行不行?

我们常规的想法是首先要知道缓冲区中有多少数据,然后调用read函数将指定大小的数据从缓冲区中读取出来。但是这个套接字文件的接收缓冲区在内核区中呀,你一个在用户区中执行的正常程序是没有权限直接去访问一个内核区中的数据结构的,要想访问,就得进行系统调用。那我先通过系统调用获取接收缓冲区的数据量,再一次性调用 read 读取所有数据,这种实现ET模式的方案行不行呢?

答案是不行,问题主要有两个

缓冲区中的数据量可能动态变化
从 “获取数据量” 到 “执行 read” 之间存在时间差,这段时间内可能有新数据到达(导致实际数据量大于获取的值),或数据被其他线程读取(导致实际数据量小于获取的值)。因此,预先获取的值可能不准确,无法保证 “一次性读完”。增加额外的系统调用开销
ioctl 或 recvmsg 本身也是系统调用,需要从用户态切换到内核态,会产生额外的性能开销。对于高并发场景(这正是 ET 模式的优势场景),多一次系统调用就意味着多一次上下文切换,反而会降低效率。

前面我们说过系统调用要进行用户态和内核态的切换,开销比较大。人家系统调用一般都是完成一件非常关键的事情。而我这系统调用就仅仅是去读取一个内核缓冲区的大小,用这么大的开销干这么小的事情,不划算。而且假如说你前脚刚通过系统调用获取到缓冲区中有多少数据,后脚对方又给缓冲区发来新数据,那你马上执行read时就没法一次性读完了呀。这时候有同学又说了,那我把这俩操作加把锁绑定在一起,不就行了吗?对端可不管你这那的,不可能因为你要读缓冲区中的数据,他就不发了,你要是规定在缓冲区中的数据被读取上去之前,不会有新的数据输入到缓冲区,这就会造成很多报文的丢失。所以加锁也不行。

(2)虽然我不知道你这个缓冲区中有多少数据(动态参数,获取成本高),但我可以知道你这个缓冲区的最大容量MAXSIZE(静态参数,很容易获得),我直接通过read系统调用读取MAXSIZE个数据,你要是不够就给我全读上来,这种做法实现ET行不行?

其实这个做法的思路就跟我们问同学借钱时很像,你不知道他手里有多少钱?但是你又想把他手里的钱全部借完。最好的办法就是先说一个非常高的借钱金额,然后他肯定说手里的钱不够,最多借你多少。
做法很好理解,但是这个思路可不可行呢?我们说依然是不可行的,下面来说明原因

主要原因:读取过程中可能有新数据涌入,导致实际数据量超过初始预期
因为epoll常用于处理高并发场景下的IO多路转接,其同时处理的链接数量是很多的。同一个连接发起请求的次数也是非常频繁的,你只调用一次read,就很可能会出现读取过程中,本来说读MAXSIZE个数据,但是在你这边儿不断从缓冲区读数据的过程中,他那边儿又不断的传入,假如说你们两个速度相仿,那当你读完MAXSIZE个数据时,缓冲区依然没有被清空,这就不符合ET策略的设计要求。既然如此,那你说我就让read一直读,直到把缓冲区读空为止。假如说输入输出速率相同,那岂不是你要读很长时间,你读的数据量也会远超你的预期值MAXSIZE,你用来接收数据的用户缓冲区大小真不一定够啊

(3)为什么要采用循环 read 的方案来实现ET模式呢?

什么是循环read方案?
这个其实非常好理解。比如说你想把你同学今天下午手里带的钱全部借走,但是你不能问他手里有多少钱,你可以采取的做法是,先问他借100块钱,假如你这个同学顺利的把100块钱借给你了,你就再问他借100,像这样循环往复。直到有一次你问他要100块钱的时候,他只给了你32块钱,这时候就说明你已经把他手里的钱全部借完了。你再问他要100,他就说没钱了

为什么选他?
其实我们通过第二个方案就能看到,这个方案从原理设计的角度来说已经可以了,只是说没法应对高频请求的实际情况
为了解决这个问题,我们就提出了循环 read 的方案,你不是一直发吗?那我就一直读,跟你耗,一直读到你发完为止,我检测到缓冲区中是真没数据了。我才结束。

为什么ET模式要求所有的fd都必须采用非阻塞式IO?

结合上一个问题,ET模式是通过循环调用read实现的,既然如此,你肯定有一次read的时候是读不满的(那个借钱的例子。你一直借的都是100,借完之前最后一次借的就没有100,只有32。类似的。读完之前最后一次read,实际读上来的数据肯定没有参数指定的数据多)。此时read的策略就是,读不满,我就全读上来。此时返回值依然是大于0的,表示实际读上来的数据,返回值大于0,循环仍会继续。

但是调用下一次read时,这时候缓冲区中的数据真一点都没了。而如果我们套接字文件是默认的阻塞模式,当调用read读取其接收缓冲区时,如果缓冲区为空(里面没数据),read就会陷入阻塞,导致进程无法处理其他就绪事件(如其他 fd 的数据),整个程序会被 “卡死”。因此我们在创建套接字之后,必须要调用fcntl将套接字设置为非阻塞模式,这样当缓冲区为空时,read就会返回-1,同时将错误码errno置为EAGAIN ,表示非阻塞模式下,当前无数据可读

为什么LT模式没有要求所有的fd都必须采用非阻塞式IO?

因为LT模式下,只要调用完select/poll/epoll_wait之后返回值大于0,说明肯定是有相应的事件就绪了,假如说是读事件就绪了,那我去读的时候缓冲区中肯定有数据,而只要文件的接收缓冲区中有数据,即使采用阻塞式IO,read也不会被阻塞

但是在ET模式下,由于我是循环调用read,缓冲区一定会有读空的那一天,最后一次read返回值必然小于0,如果采用套接字设置为默认的阻塞模式,那就会将整个epollServer进程阻塞在read系统调用处

如果我在LT模式下采用非阻塞IO+循环read的策略,这是不是就相当于ET模式呢?

LT模式下采用非阻塞IO+循环read,与ET模式的效率确实差别不大
但是ET模式下如果程序员不把缓冲区中的数据取完,系统会出现报错!LT模式下如果程序员不把缓冲区中的数据取完,系统不会报错!

为什么要有ET和LT?

LT就是为了方便一般场景下的IO多路转接
ET则是专门为高吞吐量高并发IO的场景 设计的多路转接技术

epoll的使用场景

epoll的高性能,是有一定的特定场景的。如果场景选择的不适宜,epoll的性能可能适得其反。

对于多连接,且多连接中只有一部分连接比较活跃时,比较适合使用epoll。
例如,典型的一个需要处理上万个客户端的服务器,例如各种互联网APP的入口服务器,这样的服务器就很适合epoll。如果只是系统内部,服务器和服务器之间进行通信,只有少数的几个连接,这种情况下用epoll就并不合适。具体要根据需求和场景特点来决定使用哪种IO模型。

© 版权声明

相关文章

暂无评论

none
暂无评论...