I/O 多路转接之epoll

1. 引言

I/O 多路复用(I/O multiplexing)使得程序能够同时监视多个文件描述符(socket、管道、设备等),并在其中任意一个变得可读、可写或发生异常时得到通知。在 Linux 系统中,常用的多路复用机制有 selectpollepoll。随着并发连接数的增加,selectpoll 的性能会线性下降,而 epoll 则专为处理大量并发连接而设计,具有 O(1) 的事件通知复杂度,是目前高性能网络编程的基石。

2. epoll 核心概念

epoll 的出现解决了 select/poll 的几个痛点:

  • 没有文件描述符数量上限(受系统内存限制)。
  • 事件驱动:只返回就绪的文件描述符,避免了无差别遍历。
  • 高效的内部数据结构:使用红黑树管理监控的描述符,就绪链表存储就绪事件。

epoll 将监控操作(epoll_ctl)和等待事件(epoll_wait)分离,使得可以重复使用同一个 epoll 实例,避免了每次调用都重新传递描述符集合的开销。

3. epoll API

epoll 主要提供三个系统调用:

epoll_create

复制代码
#include <sys/epoll.h>

int epoll_create(int size);

参数说明:

  • size
    历史上,该参数用于告知内核期望监视的文件描述符数量,内核据此为内部数据结构预分配内存。但从 Linux 2.6.8 开始,size 参数被忽略(只需大于 0),内核会动态调整所需内存。为了兼容性,仍建议传入一个正数(如 1 或 1024),但不能为 0。

返回值:

  • 成功:返回一个非负整数,即指向新创建的 epoll 实例的文件描述符。
  • 失败:返回 -1,并设置 errno 以指示错误类型。

错误码(常见):

  • EINVALsize 不是正数。
  • EMFILE:进程已打开的文件描述符数量达到上限。
  • ENFILE:系统已打开的文件描述符总数达到上限。
  • ENOMEM:内存不足,无法创建内核数据结构。

用法与生命周期:

  1. 调用 epoll_create 获得 epfd(epoll 文件描述符)。
  2. 使用 epoll_ctl 向该实例添加、修改或删除需要监视的文件描述符。
  3. 调用 epoll_wait 等待事件发生。
  4. 不再使用时,调用 close(epfd) 释放资源。

重要注意事项:

  • epoll 实例本身也是文件描述符,占用系统资源,需及时关闭。
  • epoll 文件描述符被关闭时,所有关联的被监视文件描述符会自动从该实例中移除。
  • epoll 适用于边缘触发(ET)和水平触发(LT)两种模式,其行为受 epoll_ctl 设置的事件标志控制。

epoll_create1

epoll_create1 是 Linux 系统提供的一个用于创建 epoll 实例的系统调用,它是对早期 epoll_create 的扩展和简化。下面从多个方面详细解释它的用法、参数、返回值、注意事项以及与 epoll_create 的区别。

复制代码
#include <sys/epoll.h>

int epoll_create1(int flags);

参数 flags

  • 目前只支持一个标志:EPOLL_CLOEXEC
    如果设置该标志,新创建的 epoll 文件描述符在执行 exec 族函数时会自动关闭,防止子进程意外继承该描述符,从而提高安全性。
  • 如果传入 0,则效果与 epoll_create(1) 类似(忽略 size 参数),但 epoll_create1(0) 不会对 size 做任何检查(epoll_createsize 参数必须大于 0,但实际早已被忽略)。

返回值

  • 成功:返回一个非负整数文件描述符,代表新创建的 epoll 实例。
  • 失败:返回 -1,并设置 errno 以指示错误类型(常见如 EINVAL 表示 flags 值无效,EMFILE 表示进程已达文件描述符上限,ENFILE 表示系统已达文件描述符上限等)。

  1. epoll_create 的区别

|-------------|-----------------------------------|------------------------------------|
| 特性 | epoll_create(int size) | epoll_create1(int flags) |
| 参数 | 一个 size参数(但内核已忽略其具体数值,只要求 > 0) | 一个 flags参数(目前只有 EPOLL_CLOEXEC) |
| 历史背景 | 早期版本中 size用于建议内部数据结构大小,现已无用 | 更简洁,直接通过标志位控制行为 |
| 文件描述符标志 | 默认不设置 FD_CLOEXEC | 可通过 EPOLL_CLOEXEC标志设置 |
| 使用建议 | 为了兼容性仍需传入一个正数,如 epoll_create(1) | 推荐使用,尤其需要避免描述符泄露时 |

注意 :虽然 epoll_createsize 参数已不再被内核使用,但历史代码中仍会保留一个正数(通常传 1 或 1024)。epoll_create1 的出现正是为了消除这个无意义的参数,并引入 EPOLL_CLOEXEC

注意事项

  • 文件描述符限制 :进程可创建的 epoll 实例数量受 RLIMIT_NOFILE 限制,同时内核也有全局限制。
  • 资源释放 :epoll 实例本身占用内核资源,使用完毕后务必调用 close(epfd) 释放。如果进程退出,内核会自动回收,但良好实践是显式关闭。
  • EPOLL_CLOEXEC****的好处 :在使用了 forkexec 的多进程程序中,如果不设置此标志,子进程可能继承父进程的 epoll 文件描述符,导致:
    • 父进程关闭 epoll 后,子进程仍持有,使得内核无法完全释放资源(引用计数未归零)。
    • 子进程意外使用 epoll 可能引发混乱。
      因此,除非有明确需求要被子进程继承,否则应该总是使用 EPOLL_CLOEXEC
  • epoll_create****的兼容性 :若需要兼容旧内核(Linux 2.6.27 之前),只能用 epoll_create。但现代 Linux 系统都已支持 epoll_create1(自 2.6.27 引入)。

epoll_ctl

epoll_ctl 是 Linux 下 epoll I/O 多路复用机制的核心函数之一,用于控制 epoll 实例(由 epoll_createepoll_create1 创建)上的文件描述符(fd)事件。通过它,你可以向 epoll 实例添加、修改或删除要监控的文件描述符。

复制代码
#include <sys/epoll.h>

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

参数说明:

|---------|---------------------------------------------|
| 参数 | 描述 |
| epfd | epoll 实例的文件描述符,由 epoll_create()返回。 |
| op | 操作类型,决定对 fd执行何种操作。 |
| fd | 要监控的目标文件描述符。 |
| event | 指向 struct epoll_event的指针,描述要监听的事件类型及用户数据。 |

操作类型 op

op 参数可以是以下三个值之一(定义在 <sys/epoll.h>):

|-----------------|---|------------------------------------------------|
| 常量 | 值 | 说明 |
| EPOLL_CTL_ADD | 1 | 将 fd添加到 epoll 实例中,开始监听 event中指定的事件。 |
| EPOLL_CTL_MOD | 2 | 修改已存在于 epoll 实例中的 fd的监听事件(用 event覆盖原有设置) |
| EPOLL_CTL_DEL | 3 | 从 epoll 实例中删除 fd,此时 event参数被忽略(可设为 NULL) |

struct epoll_event 结构体

复制代码
typedef union epoll_data {
void    *ptr;
int      fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;

struct epoll_event {
uint32_t     events;   /* 监听的事件掩码 */
epoll_data_t data;     /* 用户数据(通常存放 fd 或自定义指针) */
};

data:在调用 epoll_ctl 时设置的任意用户数据,通常在 epoll_wait 返回时通过此字段识别 fd 或相关上下文。

events 常用事件掩码

|----------------|--------------------------------------------------|
| 事件常量 | 含义 |
| EPOLLIN | 可读事件(包括对端关闭连接,此时 read返回 0)。 |
| EPOLLOUT | 可写事件。 |
| EPOLLRDHUP | 对端关闭连接或半关闭(自 Linux 2.6.17 起支持)。 |
| EPOLLPRI | 带外数据(如 TCP 紧急数据)。 |
| EPOLLERR | 发生错误。 |
| EPOLLHUP | 挂起(如管道对端关闭)。 |
| EPOLLET | 边缘触发模式(Edge Triggered),默认为水平触发(Level Triggered)。 |
| EPOLLONESHOT | 一次性事件,事件触发后自动从 epoll 实例中移除,需要重新添加才能再次控。 |

注意EPOLLERREPOLLHUP 总是会被监听,无需显式设置。

返回值:

  • 成功时返回 0
  • 失败时返回 -1,并设置 errno 以指示错误原因。

常见错误

|----------|-----------------------------------------------|
| errno | 说明 |
| EBADF | epfdfd不是有效的文件描述符。 |
| EEXIST | 对已存在的 fd执行 EPOLL_CTL_ADD |
| ENOENT | 对不存在的 fd执行 EPOLL_CTL_MODEPOLL_CTL_DEL |
| ENOMEM | 内存不足。 |
| EPERM | 目标 fd不支持 epoll(例如普通文件)。 |

注意事项:

  1. 文件描述符生命周期 :当使用 epoll_ctl 添加一个 fd 后,若该 fd 被关闭(close),它会自动从 epoll 实例中移除,无需显式调用 EPOLL_CTL_DEL
  2. 多线程安全 :epoll 实例本身是线程安全的,多个线程可以同时对同一个 epfd 调用 epoll_ctl,但需要确保对同一 fd 的操作不会产生竞态。
  3. EPOLLONESHOT****使用 :如果设置了该标志,事件触发一次后该 fd 会被自动禁用(相当于从 epoll 实例中移除),需要重新调用 epoll_ctl(EPOLL_CTL_MOD) 才能再次监控。常用于多线程环境,避免多个线程同时处理同一 fd 的事件。
  4. 性能 :对于大量连接(成千上万),epoll 性能远优于 select/poll,因为它是事件驱动,且只返回就绪的 fd,避免了遍历所有 fd 的开销。

epoll_wait

epoll_wait 是 Linux 下 I/O 多路复用机制 epoll 的核心函数之一,用于等待注册在 epoll 实例上的文件描述符(file descriptor,简称 fd)发生感兴趣的事件,并返回就绪的事件列表。

复制代码
#include <sys/epoll.h>

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

参数说明:

|-------------|---------------------------------------------------------|
| 参数 | 说明 |
| epfd | epoll_create返回的 epoll 实例文件描述符 |
| events | 指向 epoll_event结构体数组的指针,用于接收就绪的事件 |
| maxevents | events 数组的大小,即本次调用最多返回多少个事件 |
| timeout | 超时时间(毫秒): -1表示一直阻塞直到有事件 0表示立即返回(非阻塞) >0表示等待指定的毫秒数 |

返回值:

  • 成功 :返回就绪的文件描述符个数(即 events 数组中填充的事件数),0 表示超时且无事件。
  • 失败 :返回 -1,并设置 errno 表示错误原因(如 EBADFEINVALEFAULT 等)。

注意事项:

  1. 超时处理timeout0 时,即使没有就绪事件也立即返回 0,可用于非阻塞轮询;-1 时永久阻塞。
  2. 数组大小events 数组需足够大,maxevents 不宜超过数组长度,否则可能发生内存溢出。
  3. 错误检查epoll_wait 返回 -1 时需处理错误,常见如 EINTR(被信号中断),通常可以重试。
  4. 并发安全epoll 实例本身是线程安全的,多个线程可同时调用 epoll_wait 等待同一实例,但需注意事件处理时的数据竞争。
  5. 关闭 fd :当关闭一个 fd 时,内核会自动将其从 epoll 实例中删除,但最好显式调用 epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL) 以确保资源清理。

4. 触发模式

epoll 提供两种事件触发模式:

4.1 水平触发(Level Triggered,LT)

  • 默认模式
  • 只要文件描述符处于就绪状态(例如读缓冲区有数据),epoll_wait 就会返回该事件。
  • 行为与 select/poll 类似,编程简单,不易出错。
  • 如果数据未一次性读完,下次 epoll_wait 会继续通知。

4.2 边缘触发(Edge Triggered,ET)

  • 通知时机 :只有当被监控的文件描述符状态发生变化 时(例如从"不可读"变为"可读",或从"不可写"变为"可写"),epoll_wait 才会返回该事件。
  • 一次性通知 :对于同一状态变化,ET 模式只通知一次。如果用户没有将数据全部处理完,下一次 epoll_wait 将不会再次返回该事件,即使文件描述符仍然处于就绪状态。
  • 效率更高,减少了事件触发的次数,但编程难度增加。
  • 使用 ET 模式时,文件描述符必须设置为非阻塞,以避免阻塞在 read/write 上。

5. epoll 工作原理

epoll 在内核中维护了两个关键数据结构:

  • 红黑树 :存放所有通过 epoll_ctl 添加的待监控文件描述符,支持高效的增删改查。
  • 就绪链表 :存放已经就绪的事件。当文件描述符状态变化时,通过回调机制将对应 epitem(红黑树节点)加入就绪链表。

事件通知流程

  1. 应用程序调用 epoll_create 创建一个 epoll 实例,内核分配一个 eventpoll 结构体,包含红黑树根节点和就绪链表头。
  2. 调用 epoll_ctl(ADD) 将 fd 加入红黑树,同时注册一个回调函数(ep_poll_callback)到 fd 的等待队列。
  3. 当 fd 上发生监控的事件(如数据到达),设备驱动调用回调函数,将对应的 epitem 加入就绪链表,并唤醒等待在 epoll_wait 上的进程。
  4. epoll_wait 返回时,将就绪链表中的事件拷贝到用户空间的 events 数组,并清空就绪链表(LT 模式下,如果事件未处理完,会重新加入就绪链表)。

epoll 内核实现

epoll 是 Linux 内核中专门为处理大量并发连接而设计的 I/O 多路复用机制。其高效的秘密在于内核中的精巧设计:红黑树 管理监控的文件描述符,就绪链表 存储就绪事件,回调机制 实现事件驱动。下面我们从内核数据结构、操作流程、事件通知机制等方面深入剖析。

1. 核心数据结构

在内核中,每个 epoll 实例对应一个 struct eventpoll 结构体,它是 epoll 的核心管理对象。

复制代码
// fs/eventpoll.c (简化)
struct eventpoll {
    /* 红黑树的根节点,存放所有被监控的文件描述符对应的 epitem */
    struct rb_root_cached rbr;    // 红黑树 + 最左节点缓存

    /* 就绪事件链表,存放已经就绪的 epitem */
    struct list_head rdllist;

    /* 等待队列,存放阻塞在 epoll_wait 上的进程 */
    wait_queue_head_t wq;

    /* 用于 poll 机制(非 epoll 调用)的等待队列 */
    wait_queue_head_t poll_wait;

    /* 当前 epoll 实例被引用的文件对象 */
    struct file *file;

    /* 其他字段:用户数据、锁等 */
    ...
    };

每个被监控的文件描述符(fd)在内核中对应一个 struct epitem,它挂载在红黑树上,同时也可能被链接到就绪链表中。

复制代码
struct epitem {
    /* 红黑树节点,用于挂载到 eventpoll.rbr */
    struct rb_node rbn;

    /* 就绪链表节点,用于挂载到 eventpoll.rdllist */
    struct list_head rdllink;

    /* 指向所属的 eventpoll 实例 */
    struct eventpoll *ep;

    /* 被监控的文件描述符对应的 struct file */
    struct file *file;

    /* 该 fd 监控的事件类型(EPOLLIN/EPOLLOUT 等) */
    struct epoll_event event;

    /* 其他字段:等待队列节点、回调函数指针等 */
    ...
    };

2. 创建 epoll 实例:epoll_create1

当用户调用 epoll_create1(0) 时,内核执行以下操作:

  1. 分配一个 struct eventpoll 结构体,并初始化:
    • 红黑树根节点 rbr 置空。
    • 就绪链表 rdllist 初始化为空链表。
    • 等待队列 wq 初始化为空。
  1. 创建一个匿名文件(anon_inode)并关联 eventpoll,返回该文件的文件描述符给用户空间。这个文件描述符就是 epoll 句柄(epfd)。

此后,所有对 epoll 的操作都通过这个 fd 进行。

3. 添加/修改/删除监控:epoll_ctl

epoll_ctl 是用户操作监控集的主要接口,内核中的处理逻辑如下(以 EPOLL_CTL_ADD 为例):

  1. 根据传入的 epfd 找到对应的 struct eventpoll 实例。
  2. 根据要操作的 fd 找到对应的 struct file 结构体。
  3. 检查该 fd 是否已经被监控(在红黑树中查找):
    • 若已存在且 op 为 ADD,则返回错误。
    • 若已存在且 op 为 MOD,则更新该 epitem 中的事件掩码。
    • 若不存在且 op 为 DEL,则返回错误。
  1. 对于 ADD 操作:
    • 分配一个 struct epitem 结构体,填充其字段(file, event, ep 等)。
    • 调用 ep_insert 函数,将该 epitem 插入红黑树(O(log n))。
    • 关键步骤 :调用该 fd 对应的文件操作中的 poll 函数(例如 socket 的 tcp_poll),将 epoll 的回调函数 ep_poll_callback 注册到该 fd 的等待队列中。这样当 fd 就绪时,设备驱动会触发该回调。
  1. 对于 MOD 操作:
    • 在红黑树中找到对应的 epitem,更新其 event.events
    • 重新调用 poll 检查当前就绪状态,并根据新掩码调整是否加入就绪链表。
  1. 对于 DEL 操作:
    • 从红黑树中删除 epitem,从 fd 的等待队列中注销回调函数,释放 epitem。

回调注册细节

每个文件描述符(如 socket)内部都有一个等待队列(wait_queue_head_t),当发生事件(数据到达、连接建立等)时,设备驱动会遍历该等待队列,唤醒等待的进程。epoll 在添加监控时,会创建一个 epoll_entry 并挂载到 fd 的等待队列上,其回调函数就是 ep_poll_callback

4. 事件就绪与回调机制

当被监控的 fd 上发生感兴趣的事件(例如 socket 收到数据)时,内核的驱动程序会调用该 fd 等待队列上的回调函数。对于 epoll 注册的回调,就是 ep_poll_callback

ep_poll_callback 的工作流程:

  1. 根据传入的等待队列节点找到对应的 epitem
  2. 检查事件类型是否匹配 epitem 中监控的事件掩码。
  3. 如果匹配:
    • epitem 添加到 eventpoll就绪链表(rdllist) 中(如果尚未在链表中)。
    • 如果 eventpoll 中有进程阻塞在 epoll_wait 上(即等待队列 wq 不为空),则唤醒其中一个或所有进程。
  1. 对于水平触发(LT) 模式,即使就绪链表中的事件被用户读取后,如果 fd 仍然处于就绪状态,回调函数可能会被再次触发,保证事件不会丢失。对于边缘触发(ET) 模式,回调只在状态变化时被调用一次。

5. 等待事件:epoll_wait

用户调用 epoll_wait 时,内核执行以下步骤:

  1. 检查 eventpoll 的就绪链表 rdllist 是否为空:
    • 如果不为空,则直接进入步骤 3。
    • 如果为空,则根据 timeout 参数决定是否阻塞:
      • timeout = -1:将当前进程加入到 eventpoll 的等待队列 wq 中,然后调度进入睡眠,直到被唤醒。
      • timeout = 0:立即返回。
      • timeout > 0:设置定时器,在超时前若未被唤醒则返回。
  1. 进程被唤醒后(可能是事件到达或超时),再次检查就绪链表。
  2. 将就绪链表中的事件逐个拷贝到用户空间的 events 数组中,每个事件包含用户设置的 data 和实际发生的事件类型。
  3. 对于水平触发 模式,如果某事件对应的 fd 在用户读取后仍然处于就绪状态(例如读缓冲区还有数据未读完),内核会将该 epitem 重新放回就绪链表 ,以便下次 epoll_wait 继续返回该事件。对于边缘触发模式,一旦事件被取出,就不会再放回,直到下一次状态变化。

关键细节

  • 事件拷贝过程中,内核会从就绪链表中移除这些 epitem(对于 LT,如果事件未处理完会被重新加入;对于 ET,则彻底移除)。
  • epoll_wait 返回后,用户程序处理事件,通常需要循环读写直到 EAGAIN(ET 模式)或一次处理即可(LT 模式)。

6. 水平触发(LT)与边缘触发(ET)的内核实现差异

  • 水平触发(LT)
    • 是默认模式。
    • ep_poll_callback 中,当事件发生时,将 epitem 加入就绪链表后,如果该 epitem 已经因为在就绪链表中而被加过一次,则不会重复添加(通过标志位控制)。
    • epoll_wait 返回前,对于 LT 模式,内核会再次检查每个被返回的 fd 是否仍然就绪(通过调用其 poll 函数),如果仍然就绪,则将其重新放回就绪链表。这样确保下次 epoll_wait 能够再次通知用户。
  • 边缘触发(ET)
    • 用户需要通过 EPOLLET 标志显式指定。
    • ep_poll_callback 中,将 epitem 加入就绪链表时,会设置一个标志表示"已触发",并且不再因为同一状态的变化重复加入。
    • epoll_wait 返回时,内核不会将 epitem 重新放回就绪链表,即使 fd 仍然就绪。用户必须一次性处理完所有数据,否则可能丢失后续事件。

ET 模式下的注意事项

由于内核不会重复通知,用户必须循环调用 read/write 直到返回 EAGAINEWOULDBLOCK,因此 fd 必须设置为非阻塞。

为什么 ET 必须搭配非阻塞 I/O?

ET 模式要求应用程序在收到可读事件时,必须一次性将当前所有数据读完 (直到返回 EAGAINEWOULDBLOCK),否则可能丢失后续数据通知。如果使用阻塞 I/O,这个要求无法安全实现。

阻塞 I/O 在 ET 模式下的问题:

假设某个 socket 使用 ET 模式且是阻塞的。当 socket 可读时,epoll_wait 返回该 socket 的事件。然后程序调用 read(fd, buf, size) 读取数据。

  • 如果接收缓冲区中的数据量小于 sizeread 会阻塞,直到有更多数据到达或对端关闭连接。在阻塞期间,程序无法处理其他连接,严重影响并发性能。
  • 如果接收缓冲区中的数据量恰好等于 size,但还有更多数据尚未到达,read 返回后,程序可能认为已经读完,而实际上缓冲区还有数据未读取。由于 ET 模式不会再次通知(因为没有新的状态变化),剩余的数据将永远得不到处理。

因此,在 ET 模式下,无法通过一次 read 保证将所有数据读完,而阻塞的 read 又无法安全地循环调用(因为最后一次调用可能阻塞)。所以必须使用非阻塞 I/O ,并在循环中调用 read/write,直到返回 EAGAINEWOULDBLOCK

7. epoll 的性能优势

  • 红黑树管理监控集 :增删改查操作的时间复杂度为 O(log n),n 为监控的 fd 总数。相比 select/poll 每次需要传递整个集合(O(n) 拷贝和遍历),epoll 在大量连接下优势明显。
  • 就绪链表直接返回事件epoll_wait 只需从就绪链表中取出事件,时间复杂度 O(1)(与监控总数无关)。
  • 回调机制:事件发生时,内核主动回调 epoll,避免轮询所有 fd 的开销。
  • 内存拷贝优化 :用户只需传递一次监控集,内核维护副本,epoll_wait 仅拷贝就绪事件到用户空间,数据量小。

8. 总结

epoll 的高性能源于其内核实现的三个核心设计:

  1. 数据结构分离:红黑树用于快速增删改查监控集,就绪链表用于高效返回就绪事件。
  2. 事件驱动回调:通过将回调函数注册到文件描述符的等待队列,实现"通知-唤醒"机制,避免无效轮询。
  3. 灵活的触发模式:水平触发简化编程,边缘触发进一步提升性能,适应不同场景。

正是这些设计,使得 epoll 能够轻松应对成千上万的并发连接,成为 Linux 下高性能网络编程的事实标准

6. 优缺点分析

优点:

  • 可扩展性:监控的 fd 数量几乎无限制,性能不随 fd 数量线性下降。
  • 高效的事件通知:只返回就绪的 fd,避免了每次调用都要遍历所有 fd。
  • 分离操作epoll_ctlepoll_wait 分离,允许动态修改监控集而不影响等待操作。
  • 边缘触发:提供更高的性能,减少不必要的事件触发。

缺点:

  • 只能在 Linux 平台上使用(移植性较差)。
  • 边缘触发模式下编程复杂,需要循环读写直到 EAGAIN,且必须配合非阻塞 I/O。
  • 内核内存开销稍大(红黑树节点等)。

与 select/poll 比较:

|---------|--------------|--------------------|--------------|
| 特性 | select | poll | epoll |
| 最大 fd 数 | 通常 1024(可调整) | 无限制(但随 fd 数增加效率降低) | 无限制(受系统内存限制) |
| 效率 | O(n) 遍历 | O(n) 遍历 | O(1) 就绪事件 |
| 事件触发方式 | 水平触发 | 水平触发 | 水平/边缘触发 |
| 内核实现 | 位掩码拷贝 | 链表拷贝 | 红黑树+就绪链表 |
| 适用场景 | 少量 fd | 少量 fd | 大量高并发连接 |

7. 注意事项

  1. 非阻塞 I/O :尤其在 ET 模式下,必须使用非阻塞 I/O,否则在 read/write 可能阻塞整个线程。
  2. 处理 EAGAIN :在 ET 模式下,当 read 返回 -1 且 errno 为 EAGAIN 时,表示当前数据已读完,应退出循环等待下一次事件。
  3. EPOLLONESHOT:如果需要避免多个线程同时处理同一个 fd,可以使用该标志,事件触发一次后自动从 epoll 中移除,处理完后需重新添加。
  4. 文件描述符关闭 :当 fd 被关闭时,内核会自动将其从 epoll 实例中删除,但显式调用 epoll_ctl(DEL) 是好习惯。
  5. 多线程使用 :同一个 epoll 实例可以在多个线程中共享,epoll_wait 是线程安全的,但需要注意并发修改监控集时使用锁或确保串行化。

代码

Mutex.hpp

复制代码
#pragma once
#include <iostream>
#include <mutex>
#include <pthread.h>

class Mutex
{
public:
    Mutex()
    {
        pthread_mutex_init(&_lock, nullptr);
    }
    void Lock()
    {
        pthread_mutex_lock(&_lock);
    }
    void Unlock()
    {
        pthread_mutex_unlock(&_lock);
    }
    pthread_mutex_t *Get()
    {
        return &_lock;
    }
    ~Mutex()
    {
        pthread_mutex_destroy(&_lock);
    }
private:
    pthread_mutex_t _lock;
};

class LockGuard
{
public:
    LockGuard(Mutex *_mutex):_mutexp(_mutex)
    {
        _mutexp->Lock();
    }
    ~LockGuard()
    {
        _mutexp->Unlock();
    }
private:
    Mutex *_mutexp;
};

Logger.hpp

复制代码
#pragma once

#include <iostream>
#include <string>
#include <filesystem> // C++17 文件操作
#include <fstream>
#include <ctime>
#include <unistd.h>
#include <memory>
#include <sstream>
#include "Mutex.hpp"

// 规定出场景的日志等级
enum class LogLevel
{
    DEBUG,
    INFO,
    WARNING,
    ERROR,
    FATAL
};

// 日志转换成为字符串
std::string Level2String(LogLevel level)
{
    switch (level)
    {
    case LogLevel::DEBUG:
        return "Debug";
    case LogLevel::INFO:
        return "Info";
    case LogLevel::WARNING:
        return "Warning";
    case LogLevel::ERROR:
        return "Error";
    case LogLevel::FATAL:
        return "Fatal";
    default:
        return "Unknown";
    }
}

// 根据时间戳,获取可读性较强的时间信息
// 20XX-08-04 12:27:03
std::string GetCurrentTime()
{
    // 1. 获取时间戳
    time_t currtime = time(nullptr);

    // 2. 如何把时间戳转换成为20XX-08-04 12:27:03
    struct tm currtm;
    localtime_r(&currtime, &currtm);

    // 3. 转换成为字符串
    char timebuffer[64];
    snprintf(timebuffer, sizeof(timebuffer), "%4d-%02d-%02d %02d:%02d:%02d",
             currtm.tm_year + 1900,
             currtm.tm_mon + 1,
             currtm.tm_mday,
             currtm.tm_hour,
             currtm.tm_min,
             currtm.tm_sec);

    return timebuffer;
}

// 策略模式,策略接口
// 1. 刷新的问题 -- 假设我们已经有了一条完整的日志,string->设备(显示器,文件)
// 基类方法
class LogStrategy
{
public:
    // 不同模式核心是刷新方式的不同
    virtual ~LogStrategy() = default;
    virtual void SyncLog(const std::string &logmessage) = 0;
};

// 控制台日志策略,就是日志只向显示器打印,方便我们debug
// 显示器刷新
class ConsoleLogStrategy : public LogStrategy
{
public:
    ~ConsoleLogStrategy()
    {
    }
    void SyncLog(const std::string &logmessage) override
    {
        {
            LockGuard lockguard(&_lock);
            std::cout << logmessage << std::endl;
        }
    }

private:
    // 显示器也是临界资源,保证输出线程安全
    Mutex _lock;
};

// 默认路径和日志名称
const std::string logdefaultdir = "log";
const static std::string logfilename = "test.log";

// 文件日志策略
// 文件刷新
class FileLogStrategy : public LogStrategy
{
public:
    // 构造函数,建立出来指定的目录结构和文件结构
    FileLogStrategy(const std::string &dir = logdefaultdir,
                    const std::string filename = logfilename)
        : _dir_path_name(dir), _filename(filename)
    {
        LockGuard lockguard(&_lock);
        if (std::filesystem::exists(_dir_path_name))
        {
            return;
        }
        try
        {
            std::filesystem::create_directories(_dir_path_name);
        }
        catch (const std::filesystem::filesystem_error &e)
        {
            std::cerr << e.what() << "\r\n";
        }
    }
    // 将一条日志信息写入到文件中
    void SyncLog(const std::string &logmessage) override
    {
        {
            LockGuard lockguard(&_lock);
            std::string target = _dir_path_name;
            target += "/";
            target += _filename;
            // 追加方式
            std::ofstream out(target.c_str(), std::ios::app); // append
            if (!out.is_open())
            {
                return;
            }
            out << logmessage << "\n"; // out.write
            out.close();
        }
    }

    ~FileLogStrategy()
    {
    }

private:
    std::string _dir_path_name; // log
    std::string _filename;      // hello.log => log/hello.log
    Mutex _lock;
};

// 具体的日志类
// 1. 定制刷新策略
// 2. 构建完整的日志
class Logger
{
public:
    Logger()
    {
    }
    void EnableConsoleLogStrategy()
    {
        _strategy = std::make_unique<ConsoleLogStrategy>();
    }
    void EnableFileLogStrategy()
    {
        _strategy = std::make_unique<FileLogStrategy>();
    }

    // 内部类,实现RAII风格的日志格式化和刷新
    // 这个LogMessage,表示一条完整的日志对象
    class LogMessage
    {
    public:
        // RAII风格,构造的时候构建好日志头部信息
        LogMessage(LogLevel level, std::string &filename, int line, Logger &logger)
            : _curr_time(GetCurrentTime()),
              _level(level),
              _pid(getpid()),
              _filename(filename),
              _line(line),
              _logger(logger)
        {
            // stringstream不允许拷贝,所以这里就当做格式化功能使用
            std::stringstream ss;
            ss << "[" << _curr_time << "] "
               << "[" << Level2String(_level) << "] "
               << "[" << _pid << "] "
               << "[" << _filename << "] "
               << "[" << _line << "]"
               << " - ";
            _loginfo = ss.str();
        }
        // 重载 << 支持C++风格的日志输入,使用模版,表示支持任意类型
        template <typename T>
        LogMessage &operator<<(const T &info)
        {
            std::stringstream ss;
            ss << info;
            _loginfo += ss.str();
            return *this;
        }
        // RAII风格,析构的时候进行日志持久化,采用指定的策略
        ~LogMessage()
        {
            if (_logger._strategy)
            {
                _logger._strategy->SyncLog(_loginfo);
            }
        }

    private:
        std::string _curr_time; // 日志时间
        LogLevel _level;        // 日志等级
        pid_t _pid;             // 进程pid
        std::string _filename;
        int _line;

        std::string _loginfo; // 一条合并完成的,完整的日志信息
        Logger &_logger;      // 引用外部logger类, 方便使用策略进行刷新
    };
    // 故意拷贝,形成LogMessage临时对象,后续在被<<时,会被持续引用,
    // 直到完成输入,才会自动析构临时LogMessage,至此也完成了日志的显示或者刷新
    // 同时,形成的临时对象内包含独立日志数据
    // 未来采用宏替换,进行文件名和代码行数的获取
    LogMessage operator()(LogLevel level, std::string filename, int line)
    {
        return LogMessage(level, filename, line, *this);
    }
    ~Logger()
    {
    }

private:
    // 写入日志的策略
    std::unique_ptr<LogStrategy> _strategy;
};

// 定义全局的logger对象
Logger logger;

// 使用宏,可以进行代码插入,方便随时获取文件名和行号
#define LOG(level) logger(level, __FILE__, __LINE__)

// 提供选择使用何种日志策略的方法
#define EnableConsoleLogStrategy() logger.EnableConsoleLogStrategy()
#define EnableFileLogStrategy() logger.EnableFileLogStrategy()

InetAddr.hpp

复制代码
#pragma once
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring>
#include <string>
#include "Logger.hpp"

using namespace std;

#define Conv(addr) ((struct sockaddr *)&addr)

class InetAddr
{
private:
    void Net2Host()
    {
        _port = ntohs(_addr.sin_port);
        // _ip = inet_ntoa(_addr.sin_addr);
        char ipbuffer[64];
        inet_ntop(AF_INET, &(_addr.sin_addr.s_addr), ipbuffer, sizeof(ipbuffer));
        _ip = ipbuffer;
    }
    void Host2Net()
    {
        memset(&_addr, 0, sizeof(_addr));
        _addr.sin_family = AF_INET;
        _addr.sin_port = htons(_port);
        // _addr.sin_addr.s_addr = inet_addr(_ip.c_str());
        inet_pton(AF_INET, _ip.c_str(), &(_addr.sin_addr.s_addr));
    }

public:
    InetAddr(){}
    // 默认ip为INADDR_ANY(0.0.0.0)
    InetAddr(uint16_t port, const string ip = "0.0.0.0")
        : _ip(ip),
          _port(port)
    {
        Host2Net();
    }

    InetAddr(struct sockaddr_in &addr)
    {
        _addr = addr;
        Net2Host();
    }

    void Init(const struct sockaddr_in peer)
    {
        _addr = peer;
        Net2Host();
    }

    struct sockaddr *Addr()
    {
        return Conv(_addr);
    }

    string IP()
    {
        return _ip;
    }

    uint16_t Port()
    {
        return _port;
    }

    bool operator==(const InetAddr &addr)
    {
        return _ip == addr._ip && _port == addr._port;
    }

    socklen_t Length()
    {
        return sizeof(_addr);
    }

    string ToString()
    {
        return _ip + "-" + to_string(_port);
    }

    ~InetAddr()
    {
    }

private:
    // 网络风格地址
    struct sockaddr_in _addr;

    // 主机风格地址
    string _ip;
    uint16_t _port;
};

Socket.hpp

复制代码
#pragma once
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <memory>
#include <functional>
#include "Logger.hpp"
#include "InetAddr.hpp"
using namespace std;

static int gbacklog = 16; // 设置默认的backlog
static const int gsockfd = -1;

// 设置错误码
enum
{
    OK,
    CREATE_ERR,
    BIND_ERR,
    LISTEN_ERR,
    CONNECT_ERR
};

// 定义一个抽象类,父类中定义算法的骨架,将某些步骤的具体实现延迟到子类中
class Socket
{
public:
    // 编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor,
    // 所以基类的析构函数加了vialtual修饰,派生类的析构函数就构成重写
    // 基类必须有虚析构函数
    virtual ~Socket() {}
    // 纯虚函数强制派生类重写虚函数
    virtual void CreatSocket() = 0;
    virtual void BindSocket(int port) = 0;
    virtual void ListenSocket() = 0;
    //virtual shared_ptr<Socket> Accept(InetAddr *clientaddr) = 0;
     virtual int Accept(InetAddr *clientaddr) = 0;
    virtual bool Connect(InetAddr &peer) = 0;
    virtual int SockFd() = 0;
    virtual void Close() = 0;
    virtual ssize_t Recv(std::string *out) = 0;
    virtual ssize_t Send(const std::string &in) = 0;

public:
    void BuildListenSocketMethod(int port)
    {
        CreatSocket();
        BindSocket(port);
        ListenSocket();
    }

    void BuildClientSocketMethod()
    {
        CreatSocket();
    }
};

class TcpSocket : public Socket
{
public:
    TcpSocket() : _sockfd(gsockfd)
    {
    }
    TcpSocket(int sockfd) : _sockfd(sockfd)
    {
    }
    // override帮助用户检测是否重写
    void CreatSocket() override
    {
        _sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if (_sockfd < 0)
        {
            LOG(LogLevel::FATAL) << "creat socket error";
            exit(CREATE_ERR);
        }
        LOG(LogLevel::DEBUG) << "creat socket success";
    }
    void BindSocket(int port) override
    {
        InetAddr local(port);
        if (bind(_sockfd, local.Addr(), local.Length()) != 0)
        {
            LOG(LogLevel::FATAL) << "bind socket error";
            exit(BIND_ERR);
        }
        LOG(LogLevel::DEBUG) << "bind socket success";
    }
    void ListenSocket() override
    {
        if (listen(_sockfd, gbacklog) != 0)
        {
            LOG(LogLevel::FATAL) << "listen socket error";
            exit(LISTEN_ERR);
        }
        LOG(LogLevel::DEBUG) << "listen socket success";
    }
    int Accept(InetAddr *clientaddr) override
    {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);
        int clientfd = accept(_sockfd, (sockaddr *)&peer, &len);
        if (clientfd < 0)
        {
            LOG(LogLevel::FATAL) << "accept error";
            return -1;
        }
        clientaddr->Init(peer);
        return clientfd;
    }
    bool Connect(InetAddr &peer) override
    {
        if (connect(_sockfd, peer.Addr(), peer.Length()) != 0)
        {
            LOG(LogLevel::FATAL) << "connect error";
            return false;
        }
        LOG(LogLevel::DEBUG) << "connect " << peer.ToString() << " success";
        return true;
    }
    int SockFd() override
    {
        return _sockfd;
    }
    void Close() override
    {
        if (_sockfd >= 0)
            close(_sockfd);
    }
    // 今天重点放在读,通过读,理解序列和反序列和自定义协议的过程
    ssize_t Recv(std::string *out) override
    {
        // 只读一次
        char buffer[1024];
        ssize_t n = recv(_sockfd, buffer, sizeof(buffer) - 1, 0);
        if (n > 0)
        {
            buffer[n] = 0;
            *out += buffer; // 故意+=
        }
        return n;
    }
    ssize_t Send(const std::string &in)
    {
        // send 用于 已建立连接 的套接字(典型如 TCP),数据将自动发送给连接的对端。
        // sendto 主要用于 无连接 的套接字(典型如 UDP),每次发送都需要明确指定目标地址。
        return send(_sockfd, in.c_str(), in.size(), 0);
    }

    ~TcpSocket() {}

private:
    int _sockfd;
};

EpollEchoServer.hpp

复制代码
#include "Socket.hpp"
#include <sys/epoll.h>

const static int gsize = 64;

class EpollServer
{
public:
    EpollServer(uint16_t port) : _listensocket(make_unique<TcpSocket>()), _epfd(-1)
    {
        _listensocket->BuildListenSocketMethod(port);
        _epfd = epoll_create(1);
        if (_epfd < 0)
        {
            LOG(LogLevel::ERROR) << "epoll_create error";
            return;
        }
        LOG(LogLevel::DEBUG) << "_listensocket: " << _listensocket->SockFd() << " epfd: " << _epfd;
        // 首先要做的是把唯一的一个listensock添加到epoll模型中!!
        // 用户告诉内核,你要帮我关心哪一个fd上面的哪些事件
        struct epoll_event ev;
        ev.data.fd = _listensocket->SockFd();
        ev.events = EPOLLIN;
        epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensocket->SockFd(), &ev);
    }
    void Recver(int sockfd)
    {
        char buffer[1024]; // bug!
        ssize_t n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "Client Say@ " << buffer;
            std::string echo_string = "echo server# ";
            echo_string += buffer;

            // 写?
            send(sockfd, echo_string.c_str(), echo_string.size(), 0);
        }
        else if (n == 0)
        {
            LOG(LogLevel::INFO) << "client quit, me too, fd is : " << sockfd;
            // 坑: epoll_ctl EPOLL_CTL_DEL : 不能对非法fd进行操作!
            // 所以要先操作,再关闭
            epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
            close(sockfd);
        }
        else
        {
            LOG(LogLevel::INFO) << "recv error, fd is : " << sockfd;
            // 坑: epoll_ctl EPOLL_CTL_DEL : 不能对非法fd进行操作!
            epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
            close(sockfd);
        }
    }
    void Accepter()
    {
        InetAddr clientaddr;
        int sockfd = _listensocket->Accept(&clientaddr);
        if (sockfd > 0)
        {
            LOG(LogLevel::INFO) << "获取一个新连接, fd : " << sockfd
                                << " 客户端地址是: " << clientaddr.ToString();
            // sockfd, 可以对这个新的连接进行读取吗?不能!
            // 应该做什么?新的sockfd添加到epoll模型中,红黑树中
            struct epoll_event ev;
            ev.events = EPOLLIN;
            ev.data.fd = sockfd;
            epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &ev);
            LOG(LogLevel::INFO) << "添加新连接到epoll中: " << sockfd;
        }
    }
    void DispatchEvent(int num)
    {
        LOG(LogLevel::DEBUG) << "开始将就绪事件派发到各处理模块";
        for (int i = 0; i < num; i++)
        {
            int fd = _revents[i].data.fd;
            uint32_t events = _revents[i].events;

            if (events & EPOLLIN)
            {
                // 1. listensockfd
                if (fd == _listensocket->SockFd())
                {
                    Accepter();
                }
                else
                {
                    // 2. normal
                    Recver(fd);
                }
            }
            else
            {
                //......
            }
        }
    }
    void Run()
    {
        int timeout = -1;
        while (true)
        {
            int n = epoll_wait(_epfd, _revents, gsize, timeout);
            if (n > 0)
            {
                LOG(LogLevel::DEBUG) << n << "个事件已就绪";
                DispatchEvent(n);
            }
            else if (n == 0)
            {
                LOG(LogLevel::DEBUG) << "timeout";
                // break;
            }
            else
            {
                LOG(LogLevel::ERROR) << "epoll_wait error";
                break;
            }
        }
    }
    ~EpollServer() {}

private:
    unique_ptr<Socket> _listensocket;
    int _epfd;
    struct epoll_event _revents[gsize];
};

Main.cc

复制代码
#include "EpollEchoServer.hpp"
#include <string>
#include <iostream>
void Usage(std::string proc)
{
    std::cerr << "Usage: " << proc << " localport" << std::endl;
}

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        Usage(argv[0]);
        exit(0);
    }

    // 网络服务
    uint16_t serverport = std::stoi(argv[1]);

    EnableConsoleLogStrategy();
    std::unique_ptr<EpollServer> Epollsvr = std::make_unique<EpollServer>(serverport);
    Epollsvr->Run();

    return 0;
}

Makefile

复制代码
EpollEchoServer:Main.cc
    g++ -o $@ $^ -std=c++17
.PHONY:clean
clean:
    rm -f EpollEchoServer
相关推荐
用户03284722207015 小时前
如何搭建本地yum源(上)
运维
倔强的石头_2 天前
《Kingbase护城河》——数据库存储空间全景探测与精细化瘦身实战
数据库
冬奇Lab2 天前
每日一个开源项目(第134篇):Zvec - 阿里开源的嵌入式向量数据库,向量搜索界的 SQLite
数据库·人工智能·llm
ClouGence3 天前
Oracle CDC 架构优化:从主库直连到 DataGuard 备库同步
数据库·后端·oracle
无响应de神3 天前
三、用户与权限管理
数据库·mysql
大树884 天前
金刚石散热越强,管路越先见顶
大数据·运维·服务器·人工智能·ai
摇滚侠4 天前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
霸道流氓气质4 天前
领域驱动设计(DDD)在 Spring Boot 微服务中的实践指南
运维·spring boot·微服务
小宇宙Zz4 天前
Maven依赖冲突
java·服务器·maven
Inhand陈工4 天前
基于台达PLC与映翰通IG502的智慧水产养殖精准投喂与远程运维解决方案
运维·人工智能·物联网·阿里云·信息与通信