一.poll
1.1 poll函数介绍
cpp
// I/O 多路复用:监视多个文件描述符的事件(比 select 更高效、无 fd 数量限制)
// 使用 pollfd 结构数组 避免每次重建位图 适合大量连接
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// 参数:
// fds - 指向 pollfd 结构数组(每个元素描述一个 fd 及关注事件)
// nfds - 数组中元素个数
// timeout - 超时时间(毫秒):
// -1 表示永久阻塞
// 0 表示立即返回(非阻塞)
// >0 表示最多等待 timeout 毫秒
// 返回值:
// 成功:返回就绪的文件描述符数量(> 0)
// 超时:返回 0
// 失败:返回 -1 并设置 errno
// struct pollfd 成员:
// int fd - 要监视的文件描述符(<0 则忽略)
// short events - 关注的事件(输入,如 POLLIN、POLLOUT)
// short revents - 实际发生的事件(输出,由 poll 填写)
// 常见事件标志:
// POLLIN - 数据可读(包括对端关闭)
// POLLOUT - 可写(缓冲区有空)
// POLLERR - 发生错误(自动置位,无需在 events 中设置)
// POLLHUP - 对端挂起(如 TCP FIN)
// POLLNVAL - fd 无效
// 案例:
struct pollfd pfd;
pfd.fd = sockfd;
pfd.events = POLLIN;
int ready = poll(&pfd, 1, 5000); // 等待 5 秒
if (ready > 0 && (pfd.revents & POLLIN))
{
// sockfd 可读
}
1.2 poll的优点
接口更清晰
使用
struct pollfd数组,每个元素(结构体)明确包含:
fd:要监视的文件描述符;
events:关注的事件(输入);
revents:实际发生的事件(输出);避免了
select的"输入/输出混用同一参数"的设计,使用更方便。无硬编码 fd 数量限制
不受
FD_SETSIZE(如 1024)限制,可监视更多 fd;(但 fd 数量过大时,因仍需线性遍历,性能仍会下降。)
poll的缺点
和
select一样,poll返回后,必须遍历整个pollfd数组,才能找出哪些 fd 就绪。每次调用
poll都需将整个pollfd数组从用户态拷贝到内核态,开销随 fd 数量线性增长。当监视大量 fd 但只有少数活跃时 ,效率低下------时间复杂度为 O(n)
二.CMake
引入:
引入 CMake 可以解决手写
Makefile繁琐的问题:它通过高级配置文件(CMakeLists.txt)自动生成平台适配的构建文件 ,从而提升开发效率、增强跨平台能力。如何编译:首先需要创建一个CMakeLists.txt,然后在里面编写这三条代码即可完成
bash# 1. 指定使用的 CMake 版本 cmake_minimum_required(VERSION 3.10) # 2. 定义项目名称 project(MyFirstProject) # 3. 指定生成可执行文件,以及对应的源文件 # 格式:add_executable(生成的文件名 源文件1 源文件2 ...) add_executable(hello Main.cc) //还可以设置C++的标准 # 4. 设置 C++ 标准(例如 C++11) set(CMAKE_CXX_STANDARD 11)使用:
bashcmake .#这样就会生成一堆配置文件,然后后面就生成了Makefile文件 #然后就是Makefile文件的使用了
三.epoll
引入:
epoll的引入是为了解决poll(以及select)在高并发场景下的性能瓶颈。虽然poll已解决了select的 fd 数量限制等问题,但其线性遍历和用户/内核间重复拷贝的缺陷无法通过用户态改进。- 因此,
epoll在内核中重新设计了事件通知机制 ,不再基于"每次传全量 fd 集合",而是采用注册-回调+就绪列表 的方式,使得它与select/poll在实现模型和扩展性上本质不同
3.1 系统接口
3.1.1 epoll_create1
cpp
// 创建 epoll 实例(epoll_create 的增强版 支持标志控制)
// 推荐替代 epoll_create 以获得更安全的文件描述符行为
#include <sys/epoll.h>
int epoll_create1(int flags);
// 参数:
// flags - 控制创建行为(通常为 0 或 EPOLL_CLOEXEC)
// EPOLL_CLOEXEC: 设置 close-on-exec 标志 防止子进程继承
// 返回值:
// 成功:返回 epoll 文件描述符(非负整数)
// 失败:返回 -1 并设置 errno
// 优势:
// - 支持原子地设置 FD_CLOEXEC 避免多线程中 fd 泄露到子进程
// - 无需指定无用的 size 参数
// 案例:
int epfd = epoll_create1(EPOLL_CLOEXEC);
if (epfd == -1)
{
// 错误处理
}
3.1.2 epoll_ctl
cpp
// 控制 epoll 实例:向其中添加、修改或删除要监视的文件描述符
#include <sys/epoll.h>
//1
int epoll_ctl(
int epfd,
int op,
int fd,
struct epoll_event *event
);
// 参数:
// epfd - 由 epoll_create1() 返回的 epoll 文件描述符
// op - 操作类型:
// EPOLL_CTL_ADD 添加 fd 到 epoll 实例
// EPOLL_CTL_MOD 修改已注册 fd 的事件
// EPOLL_CTL_DEL 从 epoll 实例中删除 fd
// fd - 要操作的目标文件描述符(如 socket)
// event - 指向 epoll_event 结构(EPOLL_CTL_DEL 时可为 NULL)
// 返回值:
// 成功:返回 0
// 失败:返回 -1 并设置 errno
struct epoll_event类型介绍
cpp// 表示 epoll 监视的事件及其关联数据 // 用于 epoll_ctl 注册事件 和 epoll_wait 返回就绪事件 #include <sys/epoll.h> struct epoll_event { uint32_t events; // 监听或发生的事件标志(如 EPOLLIN)采用位图形式 epoll_data_t data; // 用户数据(union,可存 fd、指针等) }; // 成员说明: // events - 位掩码 指定关注或已触发的事件(输入/输出) // 常用值: // EPOLLIN 数据可读 // EPOLLOUT 可写 // EPOLLET 边缘触发模式(仅在 epoll_ctl 时设置) // EPOLLERR 错误(自动触发) // EPOLLHUP 对端关闭(自动触发) // // data - 用户自定义数据 内核原样返回(见 epoll_data_t) // 使用场景: // - 调用 epoll_ctl 时:events 为要监听的事件,data 为用户绑定的数据 // - 调用 epoll_wait 后:events 为实际发生的事件,data 为之前绑定的数据epoll_data_t类型介绍
cpp// epoll_data_t 是 union 类型 用于在 epoll_event 中携带用户自定义数据 // 允许将文件描述符、指针或其他整数与事件关联 便于回调时识别来源 #include <sys/epoll.h> typedef union epoll_data { void *ptr; // 通用指针(常用) int fd; // 文件描述符(最常见用法) uint32_t u32; uint64_t u64; } epoll_data_t; // 说明: // - 四个成员共享同一块内存 只能使用其中一个 // - 最常用的是 .fd(直接存 socket fd)或 .ptr(指向自定义结构体) // - 内核不解释该字段 原样返回给用户 // 典型用法: struct epoll_event ev; ev.events = EPOLLIN; // 方式1:存 fd ev.data.fd = client_sockfd;
3.1.3 epoll_wait
cpp
// 等待 epoll 实例中注册的文件描述符上的事件发生
// 阻塞(或限时阻塞)直到有事件就绪或超时
#include <sys/epoll.h>
int epoll_wait(
int epfd,
struct epoll_event *events,
int maxevents,
int timeout
);
// 参数:
// epfd - epoll 实例的文件描述符(由 epoll_create1 返回)
// events - 指向 epoll_event 数组 用于接收就绪事件
// maxevents - events 数组的最大容量(必须 > 0)
// timeout - 超时时间(毫秒):
// -1:永久阻塞
// 0:立即返回(非阻塞)
// >0:最多等待 timeout 毫秒
// 返回值:
// 成功:返回就绪事件的数量(>= 0)
// 失败:返回 -1 并设置 errno(如被信号中断)
// 说明:
// - 内核将就绪事件填充到 events 数组中
// - 每个事件包含触发的事件类型(events)和用户绑定的数据(data)
// - 在边缘触发(ET)模式下 必须一次性读完所有数据
// 案例:
struct epoll_event events[64];
int nready = epoll_wait(epfd, events, 64, -1);
if (nready > 0) {
for (int i = 0; i < nready; ++i)
{
if (events[i].events & EPOLLIN)
{
int fd = events[i].data.fd;
// 读取 fd 上的数据
}
}
}
3.2 epoll原理
硬件中断通知数据到达 网卡收到数据帧后,通过 硬件中断 通知 CPU,触发内核的网络协议栈处理。
驱动与协议栈触发回调 数据经网卡驱动和 TCP/IP 协议栈处理后,若对应 socket 有数据可读, 内核会调用该 socket 关联的
ep_poll_callback回调函数 (由epoll注册)。内核维护两个核心数据结构
红黑树 (rb-tree):存储所有通过
epoll_ctl(EPOLL_CTL_ADD)注册的 fd 及其关注的事件,用于快速查找和管理。EPOLL_CTL_ADD 添加 fd 到 epoll 实例就绪队列 (ready list):当 fd 就绪时,其对应的
epitem被加入此链表,epoll_wait直接从此队列返回就绪事件,无需遍历全部 fd 。epitem是红黑树和就绪队列共用的节点结构,其具有红黑树和就绪队列所需的属性字段。epoll和文件描述符怎么扯上关系的
每次调用
epoll_create()(或epoll_create1())都会在内核中创建一个独立的struct eventpoll对象 ;每个struct eventpoll包含自己的一棵红黑树和一个就绪队列;这个
struct eventpoll会被封装在一个struct file中,并在当前进程的文件描述符表 中分配一个 fd;因此,epoll_create1()返回的是一个int类型的文件描述符(fd),用户通过它操作对应的 epoll 实例。
3.3 epoll的优势
接口更高效
拆分为
epoll_create、epoll_ctl、epoll_wait三个函数;关注的 fd 只需注册一次 (
EPOLL_CTL_ADD),无需每次循环重设,且由内核进行管理fd输入与输出参数分离 ,避免
select/poll的覆盖问题。数据拷贝轻量
epoll_ctl仅在增删 fd 时将描述符信息拷贝到内核;
epoll_wait调用无需重复拷贝全量 fd 集合,大幅减少用户态 ↔ 内核态开销。事件驱动,无遍历
基于回调机制 (
ep_poll_callback):fd 就绪时自动加入就绪队列;
epoll_wait直接返回就绪列表,时间复杂度 O(1) 每个活跃 fd,不随总 fd 数增长。无硬性数量限制
- 仅受限于系统内存和进程 fd 上限
要点:
select和poll采用轮询驱动,因此每次调用需要内核跑一遍所监视的文件描述符
select和poll需要用户自己管理文件描述符集合epoll能完美解决这些问题,因此它是最常用的,这两个只是用于没有epoll的环境
四.epoll的工作方式
1.epoll的两种工作方式:
水平触发(LT)
边缘触发(ET)
2.两种工作用生活方式讲解:
假设你有 5 个快递放在驿站(相当于 socket 接收缓冲区中有数据)。
张三(LT 模式) : 只要还有快递没取完 (缓冲区非空),他就会一直打电话催你 (每次
epoll_wait都返回该 fd 可读),直到你把所有快递拿光。李四(ET 模式) : 他只在快递数量发生变化时打一次电话 (比如从 0 → 5,或 3 → 6)。 如果你第一次只取了 3 个(没读完),剩下的 2 个他不会再通知你 ! 除非又有新快递到来(缓冲区状态再次变化),他才会再打一次电话。
3.例子
我们已经把一个tcp socket添加到epoll描述符
这个时候socket的另一端被写入了2KB的数据
调用epoll_wait,并且它会返回. 说明它已经准备好读取操作
然后调用read, 只读取了1KB的数据
4.水平触发
当
epoll检测到 socket 上有数据可读(如缓冲区非空),就会通知;即使只处理了部分数据(如 2KB 中读了 1KB);
下次调用
epoll_wait时,会立即再次返回该 fd 的可读事件;只有当缓冲区中所有数据都被读完 ,
epoll_wait才会阻塞或等待新数据;支持阻塞和非阻塞 I/O,编程更简单、容错性高。
5.边缘触发
epoll仅在 socket 状态发生变化时通知一次(如从无数据 → 有数据);如例子中:收到 2KB 数据后,
epoll_wait返回一次;若只读取 1KB,剩余 1KB 不会再触发通知;
后续调用
epoll_wait不会返回该 fd ,除非又有新数据到达(状态再次变化);因此,必须在收到通知后一次性读完所有可用数据 (通常用循环
read直到返回EAGAIN);必须使用非阻塞 I/O ,否则最后一次
read可能阻塞;性能更高 (减少
epoll_wait返回次数),Nginx、Redis 等高性能服务默认使用 ET 模式。6.对比LT和ET
LT(水平触发)
是
epoll的默认模式(select和poll同样也是水平触发);只要 fd 处于就绪状态(如接收缓冲区非空),每次调用
epoll_wait都会返回该事件;允许分多次处理数据,编程简单、容错性强。
ET(边缘触发)
仅在 fd 状态发生变化时通知一次(如从不可读 → 可读);
强制要求程序在一次就绪通知中将所有可用数据处理完 (通常需循环读写直到
EAGAIN);必须使用非阻塞 I/O,否则可能永久阻塞;
减少了
epoll_wait的唤醒次数,在高并发、大量连接但低活跃度场景下理论性能更高。
cpp//在 Linux 的 epoll 中,边缘触发(Edge-Triggered, ET) //模式是通过在 epoll_event.events 中设置 EPOLLET 标志来启用的。 struct epoll_event ev; ev.events = EPOLLIN | EPOLLET; // 关键:加上 EPOLLET ev.data.fd = sockfd; // 注册到 epoll 实例 epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
五.从实际出发理解ET
- 使用 ET 模式的 epoll,需要将文件描述符设置为非阻塞。这个不是接口上的要求,而是 "工程实践"上的要求。
- **假设这样的场景:**服务器接收到一个 10KB 的请求,会向客户端返回一个应答数据。如果客户端收不到应答,就不会发送第二个 10KB 请求。
- 如果服务端写的代码是阻塞式的read, 并且一次只 read 1k 数据的话(read不能保证一次就把所有的数据都读出来,参考 man 手册的说明, 可能被信号打断),剩下的9k数据就会待在缓冲区中
- 此时由于 epoll 是ET模式,并不会认为文件描述符读就绪。epoll_wait 就不会再次返回. 剩下的 9k 数据会一直在缓冲区中。直到下一次客户端再给服务器写数据。epoll_wait 才能返回
问题:
服务器只读到1KB个数据, 要10KB读完才会给客户端返回响应数据。
客户端要读到服务器的响应才会发送下一个请求
客户端发送了下一个请求,epoll_wait 才会返回, 服务器才能去读缓冲区中剩余的数据.
因此,系统会陷入"客户端等服务器响应,服务器等客户端新数据"的死锁状态。为避免此问题,在 ET(边缘触发)模式下,必须将文件描述符设为非阻塞 ,并在事件触发后循环读取,直到内核缓冲区中无剩余数据。
具体做法是:不断调用
read,直到它返回-1且errno为EAGAIN或EWOULDBLOCK------这表示本次可读数据已全部读完,而非发生真正的错误。需要特别注意:这个错误码判断不是为了确认文件描述符是否就绪 (因为 epoll/select 等 I/O 多路复用机制只在 fd 就绪时才通知),而是为了区分"正常读空"和"真实网络错误 若
read回-1但errno是其他值(如ECONNRESET、EPIPE等),则说明发生了连接异常,需关闭连接。
那关于poll和epoll的知识就讲到这里了,希望大家好好学习,关于epoll的难点还是比较多的,首先需要理解它的系统接口,又要理解epoll的原理以及它的优势,最后就是它的工作方式!

