1. 引言
I/O 多路复用(I/O multiplexing)使得程序能够同时监视多个文件描述符(socket、管道、设备等),并在其中任意一个变得可读、可写或发生异常时得到通知。在 Linux 系统中,常用的多路复用机制有 select、poll 和 epoll。随着并发连接数的增加,select 和 poll 的性能会线性下降,而 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以指示错误类型。
错误码(常见):
EINVAL:size不是正数。EMFILE:进程已打开的文件描述符数量达到上限。ENFILE:系统已打开的文件描述符总数达到上限。ENOMEM:内存不足,无法创建内核数据结构。
用法与生命周期:
- 调用
epoll_create获得epfd(epoll 文件描述符)。 - 使用
epoll_ctl向该实例添加、修改或删除需要监视的文件描述符。 - 调用
epoll_wait等待事件发生。 - 不再使用时,调用
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_create的size参数必须大于 0,但实际早已被忽略)。
返回值
- 成功:返回一个非负整数文件描述符,代表新创建的 epoll 实例。
- 失败:返回
-1,并设置errno以指示错误类型(常见如EINVAL表示flags值无效,EMFILE表示进程已达文件描述符上限,ENFILE表示系统已达文件描述符上限等)。
- 与
epoll_create的区别
|-------------|-----------------------------------|------------------------------------|
| 特性 | epoll_create(int size) | epoll_create1(int flags) |
| 参数 | 一个 size参数(但内核已忽略其具体数值,只要求 > 0) | 一个 flags参数(目前只有 EPOLL_CLOEXEC) |
| 历史背景 | 早期版本中 size用于建议内部数据结构大小,现已无用 | 更简洁,直接通过标志位控制行为 |
| 文件描述符标志 | 默认不设置 FD_CLOEXEC | 可通过 EPOLL_CLOEXEC标志设置 |
| 使用建议 | 为了兼容性仍需传入一个正数,如 epoll_create(1) | 推荐使用,尤其需要避免描述符泄露时 |
注意 :虽然 epoll_create 的 size 参数已不再被内核使用,但历史代码中仍会保留一个正数(通常传 1 或 1024)。epoll_create1 的出现正是为了消除这个无意义的参数,并引入 EPOLL_CLOEXEC。
注意事项
- 文件描述符限制 :进程可创建的 epoll 实例数量受
RLIMIT_NOFILE限制,同时内核也有全局限制。 - 资源释放 :epoll 实例本身占用内核资源,使用完毕后务必调用
close(epfd)释放。如果进程退出,内核会自动回收,但良好实践是显式关闭。 - EPOLL_CLOEXEC****的好处 :在使用了
fork或exec的多进程程序中,如果不设置此标志,子进程可能继承父进程的 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_create 或 epoll_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 实例中移除,需要重新添加才能再次控。 |
注意 :EPOLLERR 和 EPOLLHUP 总是会被监听,无需显式设置。
返回值:
- 成功时返回
0。 - 失败时返回
-1,并设置errno以指示错误原因。
常见错误:
|----------|-----------------------------------------------|
| errno | 说明 |
| EBADF | epfd 或 fd不是有效的文件描述符。 |
| EEXIST | 对已存在的 fd执行 EPOLL_CTL_ADD |
| ENOENT | 对不存在的 fd执行 EPOLL_CTL_MOD或 EPOLL_CTL_DEL |
| ENOMEM | 内存不足。 |
| EPERM | 目标 fd不支持 epoll(例如普通文件)。 |
注意事项:
- 文件描述符生命周期 :当使用
epoll_ctl添加一个 fd 后,若该 fd 被关闭(close),它会自动从 epoll 实例中移除,无需显式调用EPOLL_CTL_DEL。 - 多线程安全 :epoll 实例本身是线程安全的,多个线程可以同时对同一个 epfd 调用
epoll_ctl,但需要确保对同一 fd 的操作不会产生竞态。 - EPOLLONESHOT****使用 :如果设置了该标志,事件触发一次后该 fd 会被自动禁用(相当于从 epoll 实例中移除),需要重新调用
epoll_ctl(EPOLL_CTL_MOD)才能再次监控。常用于多线程环境,避免多个线程同时处理同一 fd 的事件。 - 性能 :对于大量连接(成千上万),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表示错误原因(如EBADF、EINVAL、EFAULT等)。
注意事项:
- 超时处理 :
timeout为0时,即使没有就绪事件也立即返回 0,可用于非阻塞轮询;-1时永久阻塞。 - 数组大小 :
events数组需足够大,maxevents不宜超过数组长度,否则可能发生内存溢出。 - 错误检查 :
epoll_wait返回-1时需处理错误,常见如EINTR(被信号中断),通常可以重试。 - 并发安全 :
epoll实例本身是线程安全的,多个线程可同时调用epoll_wait等待同一实例,但需注意事件处理时的数据竞争。 - 关闭 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(红黑树节点)加入就绪链表。
事件通知流程:
- 应用程序调用
epoll_create创建一个 epoll 实例,内核分配一个eventpoll结构体,包含红黑树根节点和就绪链表头。 - 调用
epoll_ctl(ADD)将 fd 加入红黑树,同时注册一个回调函数(ep_poll_callback)到 fd 的等待队列。 - 当 fd 上发生监控的事件(如数据到达),设备驱动调用回调函数,将对应的
epitem加入就绪链表,并唤醒等待在epoll_wait上的进程。 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) 时,内核执行以下操作:
- 分配一个
struct eventpoll结构体,并初始化:
-
- 红黑树根节点
rbr置空。 - 就绪链表
rdllist初始化为空链表。 - 等待队列
wq初始化为空。
- 红黑树根节点
- 创建一个匿名文件(anon_inode)并关联
eventpoll,返回该文件的文件描述符给用户空间。这个文件描述符就是 epoll 句柄(epfd)。
此后,所有对 epoll 的操作都通过这个 fd 进行。
3. 添加/修改/删除监控:epoll_ctl
epoll_ctl 是用户操作监控集的主要接口,内核中的处理逻辑如下(以 EPOLL_CTL_ADD 为例):
- 根据传入的 epfd 找到对应的
struct eventpoll实例。 - 根据要操作的 fd 找到对应的
struct file结构体。 - 检查该 fd 是否已经被监控(在红黑树中查找):
-
- 若已存在且 op 为 ADD,则返回错误。
- 若已存在且 op 为 MOD,则更新该 epitem 中的事件掩码。
- 若不存在且 op 为 DEL,则返回错误。
- 对于 ADD 操作:
-
- 分配一个
struct epitem结构体,填充其字段(file, event, ep 等)。 - 调用
ep_insert函数,将该 epitem 插入红黑树(O(log n))。 - 关键步骤 :调用该 fd 对应的文件操作中的
poll函数(例如 socket 的tcp_poll),将 epoll 的回调函数ep_poll_callback注册到该 fd 的等待队列中。这样当 fd 就绪时,设备驱动会触发该回调。
- 分配一个
- 对于 MOD 操作:
-
- 在红黑树中找到对应的 epitem,更新其
event.events。 - 重新调用 poll 检查当前就绪状态,并根据新掩码调整是否加入就绪链表。
- 在红黑树中找到对应的 epitem,更新其
- 对于 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 的工作流程:
- 根据传入的等待队列节点找到对应的
epitem。 - 检查事件类型是否匹配 epitem 中监控的事件掩码。
- 如果匹配:
-
- 将
epitem添加到eventpoll的 就绪链表(rdllist) 中(如果尚未在链表中)。 - 如果
eventpoll中有进程阻塞在epoll_wait上(即等待队列wq不为空),则唤醒其中一个或所有进程。
- 将
- 对于水平触发(LT) 模式,即使就绪链表中的事件被用户读取后,如果 fd 仍然处于就绪状态,回调函数可能会被再次触发,保证事件不会丢失。对于边缘触发(ET) 模式,回调只在状态变化时被调用一次。
5. 等待事件:epoll_wait
用户调用 epoll_wait 时,内核执行以下步骤:
- 检查
eventpoll的就绪链表rdllist是否为空:
-
- 如果不为空,则直接进入步骤 3。
- 如果为空,则根据
timeout参数决定是否阻塞:
-
-
timeout = -1:将当前进程加入到eventpoll的等待队列wq中,然后调度进入睡眠,直到被唤醒。timeout = 0:立即返回。timeout > 0:设置定时器,在超时前若未被唤醒则返回。
-
- 进程被唤醒后(可能是事件到达或超时),再次检查就绪链表。
- 将就绪链表中的事件逐个拷贝到用户空间的
events数组中,每个事件包含用户设置的data和实际发生的事件类型。 - 对于水平触发 模式,如果某事件对应的 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 直到返回 EAGAIN 或 EWOULDBLOCK,因此 fd 必须设置为非阻塞。
为什么 ET 必须搭配非阻塞 I/O?
ET 模式要求应用程序在收到可读事件时,必须一次性将当前所有数据读完 (直到返回 EAGAIN 或 EWOULDBLOCK),否则可能丢失后续数据通知。如果使用阻塞 I/O,这个要求无法安全实现。
阻塞 I/O 在 ET 模式下的问题:
假设某个 socket 使用 ET 模式且是阻塞的。当 socket 可读时,epoll_wait 返回该 socket 的事件。然后程序调用 read(fd, buf, size) 读取数据。
- 如果接收缓冲区中的数据量小于
size,read会阻塞,直到有更多数据到达或对端关闭连接。在阻塞期间,程序无法处理其他连接,严重影响并发性能。 - 如果接收缓冲区中的数据量恰好等于
size,但还有更多数据尚未到达,read返回后,程序可能认为已经读完,而实际上缓冲区还有数据未读取。由于 ET 模式不会再次通知(因为没有新的状态变化),剩余的数据将永远得不到处理。
因此,在 ET 模式下,无法通过一次 read 保证将所有数据读完,而阻塞的 read 又无法安全地循环调用(因为最后一次调用可能阻塞)。所以必须使用非阻塞 I/O ,并在循环中调用 read/write,直到返回 EAGAIN 或 EWOULDBLOCK。
7. epoll 的性能优势
- 红黑树管理监控集 :增删改查操作的时间复杂度为 O(log n),n 为监控的 fd 总数。相比
select/poll每次需要传递整个集合(O(n) 拷贝和遍历),epoll 在大量连接下优势明显。 - 就绪链表直接返回事件 :
epoll_wait只需从就绪链表中取出事件,时间复杂度 O(1)(与监控总数无关)。 - 回调机制:事件发生时,内核主动回调 epoll,避免轮询所有 fd 的开销。
- 内存拷贝优化 :用户只需传递一次监控集,内核维护副本,
epoll_wait仅拷贝就绪事件到用户空间,数据量小。
8. 总结
epoll 的高性能源于其内核实现的三个核心设计:
- 数据结构分离:红黑树用于快速增删改查监控集,就绪链表用于高效返回就绪事件。
- 事件驱动回调:通过将回调函数注册到文件描述符的等待队列,实现"通知-唤醒"机制,避免无效轮询。
- 灵活的触发模式:水平触发简化编程,边缘触发进一步提升性能,适应不同场景。
正是这些设计,使得 epoll 能够轻松应对成千上万的并发连接,成为 Linux 下高性能网络编程的事实标准
6. 优缺点分析
优点:
- 可扩展性:监控的 fd 数量几乎无限制,性能不随 fd 数量线性下降。
- 高效的事件通知:只返回就绪的 fd,避免了每次调用都要遍历所有 fd。
- 分离操作 :
epoll_ctl和epoll_wait分离,允许动态修改监控集而不影响等待操作。 - 边缘触发:提供更高的性能,减少不必要的事件触发。
缺点:
- 只能在 Linux 平台上使用(移植性较差)。
- 边缘触发模式下编程复杂,需要循环读写直到 EAGAIN,且必须配合非阻塞 I/O。
- 内核内存开销稍大(红黑树节点等)。
与 select/poll 比较:
|---------|--------------|--------------------|--------------|
| 特性 | select | poll | epoll |
| 最大 fd 数 | 通常 1024(可调整) | 无限制(但随 fd 数增加效率降低) | 无限制(受系统内存限制) |
| 效率 | O(n) 遍历 | O(n) 遍历 | O(1) 就绪事件 |
| 事件触发方式 | 水平触发 | 水平触发 | 水平/边缘触发 |
| 内核实现 | 位掩码拷贝 | 链表拷贝 | 红黑树+就绪链表 |
| 适用场景 | 少量 fd | 少量 fd | 大量高并发连接 |
7. 注意事项
- 非阻塞 I/O :尤其在 ET 模式下,必须使用非阻塞 I/O,否则在
read/write可能阻塞整个线程。 - 处理 EAGAIN :在 ET 模式下,当
read返回 -1 且 errno 为 EAGAIN 时,表示当前数据已读完,应退出循环等待下一次事件。 - EPOLLONESHOT:如果需要避免多个线程同时处理同一个 fd,可以使用该标志,事件触发一次后自动从 epoll 中移除,处理完后需重新添加。
- 文件描述符关闭 :当 fd 被关闭时,内核会自动将其从 epoll 实例中删除,但显式调用
epoll_ctl(DEL)是好习惯。 - 多线程使用 :同一个 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
