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
相关推荐
新钛云服2 小时前
如何构建一套自动化的阿里云费用报告系统
运维·阿里云·自动化·云计算
问道飞鱼2 小时前
【大模型学习】LangGraph 深度解析:定义、功能、原理与实践
数据库·学习·大模型·工作流
DJ斯特拉2 小时前
黑马点评技术汇总(四)缓存雪崩 && 缓存击穿
数据库·缓存
allway22 小时前
Debian Regular Expressions
运维·debian·scala
文静小土豆2 小时前
Linux 进程终止指南:理解 kill 与 kill -9 的核心区别与正确用法
linux·运维·服务器
IMPYLH2 小时前
Linux 的 df 命令
linux·运维·服务器
lzhdim2 小时前
SQL 入门 7:SQL 聚合与分组:函数、GROUP BY 与 ROLLUP
java·服务器·数据库·sql·mysql
lifewange2 小时前
INSERT INTO ... SELECT ...
数据库·sql
wefg13 小时前
【Linux】会话、终端、前后台进程
linux·运维·服务器