1. I/O多路转接之poll(了解)
pool:只负责等,一次可以等待多个fd,事件就绪,就可以对上层进行事件通知
调用的时候:fd && events 有效:用户告诉内核,你要帮我关心,fd上面的events事件
poll成功返回时:fd && revents 有效:内核告诉用户,你让我关心的fd上面的events事件,已经就绪了
@1:poll输入输出参数分离了,所以不用在poll之前进行参数重置了
@2:poll等待的fd个数,没有上限!fd == -1,在内核中,内核不关心这类fd的events
poll 函数接口
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// pollfd 结构
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
参数说明


返回结果
socket 就绪条件
poll的优点
1. 突破文件描述符数量限制
在 select
中,受限于 FD_SETSIZE
宏(通常默认值是 1024 ),能监控的文件描述符数量是有限的。而 poll
使用链表来管理文件描述符集合,理论上对所监控的文件描述符数量没有固定的上限限制,它可以根据实际需求动态地添加和删除要监控的文件描述符,从而能适应需要处理大量并发连接的场景,比如大规模的网络服务器。
2. 接口设计更灵活
每个 pollfd
结构体对应一个要监控的文件描述符,events
字段由用户设置想要监控的事件(如 POLLIN
表示监控读事件,POLLOUT
表示监控写事件等),revents
字段由内核填充,表示实际发生的事件。这种方式使得对每个文件描述符的事件监控设置更加清晰直观,相比 select
中使用三个独立的文件描述符集合(读、写、异常) ,poll
对于不同类型事件的管理和操作更加灵活。
3. 数据结构更新机制更优
select
在返回时会修改传入的文件描述符集合,只保留就绪的描述符,每次调用前都需要重新初始化集合。而 poll
不会修改用户传入的 pollfd
数组,只是在每个 pollfd
结构体的 revents
字段中填充实际发生的事件,用户可以直接通过检查这个字段来判断文件描述符的状态,不需要像 select
那样重新设置整个集合,减少了重复的初始化操作,提高了编程的便利性和效率。
4. 性能表现相对较好
poll的缺点
• 和 select 函数一样,poll 返回后,需要轮询 pollfd 来获取就绪的描述符.
• 每次调用 poll 都需要把大量的 pollfd 结构从用户态拷贝到内核中.
• 同时连接的大量客户端在一时刻可能只有很少的处于就绪状态, 因此随着监视
的描述符数量的增长, 其效率也会线性下降.
2. I/O多路转接之epoll
2.1 epoll初识
epoll
是 Linux 下高效的 I/O 多路复用机制,用于解决在处理大量并发连接时的性能问题,它通过巧妙的数据结构和事件通知机制,大幅提升了系统对 I/O 操作的管理效率。2.2 epoll工作原理
epoll
主要依赖内核中的两个关键数据结构来实现高效管理:
- 红黑树:用于管理用户注册要监控的文件描述符。在
epoll
中,每个节点对应一个要监控的文件描述符,通过红黑树可以快速地添加、删除以及查找文件描述符,这使得当有大量文件描述符需要管理时,依然能保持高效的操作性能。 - 双向链表:用于存储就绪的文件描述符。当有文件描述符所监控的事件(如可读、可写、异常等)发生时,内核会将该文件描述符对应的节点添加到这个双向链表中。在
epoll_wait
返回时,用户空间可以快速地获取到所有就绪的文件描述符,而不需要像select
和poll
那样遍历所有注册的文件描述符。 - 这里红黑树的本质:用户告诉内核,你要帮我关心哪一个fd,哪一个事件
- 就绪队列本质:内核告诉用户,哪一个fd上面的那些事件已经就绪了
2. 回调机制的作用
- 特定
fd
数据就绪 → 触发回调:
当网卡收到数据(硬件中断),驱动处理后会触发协议栈回调,把对应fd
的事件从红黑树 “激活” 到就绪链表。 - 为什么需要回调?
为了实现 “事件驱动”:不用轮询检查fd
状态,数据就绪时自动通知epoll
,大幅提升效率。
3. 工作流程
- 注册阶段:
epoll_ctl
把fd
插入红黑树,同时为该fd
注册回调(告诉内核:这个fd
有数据时,调用我的回调函数)。 - 事件触发:网卡收数据 → 驱动处理 → 协议栈触发回调 → 把
fd
从红黑树移到 就绪链表。 - 用户态获取:
epoll_wait
直接从就绪链表拿数据,返回给用户处理。
总结
epoll
用 红黑树管理监控的 fd
,用 就绪链表快速返回事件,靠 回调机制实现自动通知,让内核和用户态高效配合,处理高并发 I/O 事件。
epoll
用 生产者 - 消费者模型 管理就绪事件,epoll_wait
不怕缓冲区小(没拿完下次继续),epoll_ctl
负责 “插节点 + 注册回调”,且接口线程安全,用户处理事件时不用额外校验,拿到的都是真・就绪事件
struct eventpoll{
....
/*红黑树的根节点,这颗树中存储着所有添加到 epoll 中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过 epoll_wait 返回给用户的满足条件的事件*/
struct list_head rdlist;
....
};
struct epitem{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的 eventpoll 对象
struct epoll_event event; //期待发生的事件类型
}
• 当调用 epoll_wait 检查是否有事件发生时,只需要检查 eventpoll 对象中的
rdlist 双链表中是否有 epitem 元素即可.
• 如果 rdlist 不为空,则把发生的事件复制到用户态,同时将事件数量返回给用
户. 这个操作的时间复杂度是 O(1)
总结一下, epoll 的使用过程就是三部曲:
• 调用 epoll_create 创建一个 epoll 句柄;
• 调用 epoll_ctl, 将要监控的文件描述符进行注册
• 调用 epoll_wait, 等待文件描述符就绪;
工作模式(LT,ET)
epoll
提供了两种工作模式,不同模式下对事件的处理方式有所差异:
- 水平触发(Level Triggered,LT):这是
epoll
的默认工作模式。在这种模式下,如果文件描述符对应的读缓冲区中有数据可读,或者写缓冲区中有空间可写,epoll
就会一直通知用户程序。也就是说,只要条件满足,就会不断触发事件。白话:只要底层有报文,就要一直通知上层!--- 上层可以不读完毕,因为我知道你下次还会通知我。支持阻塞读写和非阻塞读写 - 边缘触发(Edge Triggered,ET):在边缘触发模式下,只有当文件描述符的状态发生变化时(比如从无数据可读变为有数据可读,或者从无空间可写变为有空间可写 ),
epoll
才会通知用户程序。这种模式要求应用程序在收到通知后尽可能一次性地处理完所有相关事件。白话:从网络中拿到数据,导致底层数据变化的时候,才会通知上层,即使上层把数据拿走了一部分,后来再也没有新增了,ET再也不通知上层了!(倒逼上层,必须收到通知,把本轮数据取完)只支持非阻塞的读写
哪一个效率高些?效率高不高,取决于有效通知有多少!--- ET高
理解 ET 模式和非阻塞文件描述符
为了解决上述问题(阻塞 read 不一定能一下把完整的请求读完), 于是就可以使用非阻塞轮训的方式来读缓冲区, 保证一定能把完整的请求都读出来.
而如果是 LT 没这个问题. 只要缓冲区中的数据没读完, 就能够让 epoll_wait 返回文件描述符读就绪.
使用非阻塞文件描述符(通过 O_NONBLOCK 标志设置),配合循环读取 / 写入,
确保一次性处理完所有数据:
// 设置文件描述符为非阻塞模式
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
// 在 ET 模式下读取数据(必须循环读直到返回 EAGAIN)
while (1) {
char buf[1024];
int n = read(sockfd, buf, sizeof(buf));
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 已读完所有数据,退出循环
break;
}
// 处理其他错误
perror("read error");
break;
} else if (n == 0) {
// 客户端关闭连接
close(sockfd);
break;
}
// 处理读取到的数据
process_data(buf, n);
}
ET 模式通过 “状态变化触发” 和 “非阻塞 I/O” 的组合,将数据处理的控制权完全交给应用程序,迫使开发者主动管理数据流动,从而减少内核与用户空间的交互次数,实现更高的性能。但这种高效是以增加编程复杂度为代价的,需要开发者深入理解其工作机制,才能正确应用。
2.3 epoll的相关系统调用
epoll_create
用于创建一个 epoll
实例,并在内核中分配相应的数据结构(红黑树和就绪链表等)
int epoll_create(int size);
• 自从 linux2.6.8 之后,size 参数是被忽略的.
• 用完之后, 必须调用 close()关闭
epoll_ctl
用于对 epoll
实例进行管理,包括添加、修改或删除要监控的文件描述符及其对应的事件;用户告诉内核,你要帮我关心哪一个fd,上面的event事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event
*event);
epoll 的事件注册函数.
• 它不同于 select()是在监听事件时告诉内核要监听什么类型的事件, 而是在这里
先注册要监听的事件类型.
• 第一个参数是 epoll_create()的返回值(epoll 的句柄).
• 第二个参数表示动作,用三个宏来表示.
• 第三个参数是需要监听的 fd.
• 第四个参数是告诉内核需要监听什么事
第二个参数的取值:
• EPOLL_CTL_ADD:注册新的 fd 到 epfd 中;
• EPOLL_CTL_MOD:修改已经注册的 fd 的监听事件;
• EPOLL_CTL_DEL:从 epfd 中删除一个 fd;
struct epoll_event 结构如下:
events 可以是以下几个宏的集合:
• EPOLLIN : 表示对应的文件描述符可以读 (包括对端 SOCKET 正常关闭);
• EPOLLOUT : 表示对应的文件描述符可以写;
• EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外
数据到来);
• EPOLLERR : 表示对应的文件描述符发生错误;
• EPOLLHUP : 表示对应的文件描述符被挂断;
• EPOLLET : 将 EPOLL 设为边缘触发(Edge Triggered)模式, 这是相对于水平
触发(Level Triggered)来说的.
• EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继
续监听这个 socket 的话, 需要再次把这个 socket 加入到 EPOLL 队列里.
epoll_wait
用于等待所监控的文件描述符上有事件发生;内核通知用户:你让我关心的那个fd们,上面的哪些事件已经就绪
int epoll_wait(int epfd, struct epoll_event * events, int
maxevents, int timeout);
收集在 epoll 监控的事件中已经发送的事件.
• 参数 events 是分配好的 epoll_event 结构体数组.
• epoll 将会把发生的事件赋值到 events 数组中 (events 不可以是空指针,内核
只负责把数据复制到这个 events 数组中,不会去帮助我们在用户态中分配内存).
• maxevents 告之内核这个 events 有多大,这个 maxevents 的值不能大于创建
epoll_create()时的 size. • 参数 timeout 是超时时间 (毫秒,0 会立即返回,-1 是永久阻塞).
• 如果函数调用成功,返回对应 I/O 上已准备好的文件描述符数目,如返回 0 表
示已超时, 返回小于 0 表示函数失败.
2.4 epoll的优点
相较于 select
和 poll
,epoll
的优势显著:
- 解决文件描述符数量限制:
select
受限于FD_SETSIZE
宏,poll
虽然理论上无限制,但在实际使用中随着文件描述符增多性能下降明显。而epoll
借助红黑树管理文件描述符,能够高效处理大量文件描述符,适应高并发场景。 - 降低遍历开销(回调机制):
select
和poll
每次调用都需要遍历所有注册的文件描述符来检查就绪状态,时间复杂度为 O(n) 。epoll
采用事件回调机制,使用回调函数的方式, 将就绪的文件描述符结构加入到就绪队列中, epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度 O(1). 即使文件描述符数目很多, 效率也不会受到影响 -
接口使用方便: 虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要每次循环都设置关注的文件描述符, 也做到了输入输出参数分离开
2.5 epoll的使用场景
2.6 epoll中的惊群问题(了解)
2.7 epoll示例:epoll服务器(ET模式)
基于 ET(边缘触发)+ 非阻塞 设计高性能 TCP 服务器的思路拆解,核心解决 “如何正确管理连接、处理数据”:
1. 核心目标:用 ET + 非阻塞 实现高效服务器
- ET 模式:事件只触发一次,必须配合非阻塞 I/O,循环读写直到
EAGAIN
。 - 非阻塞:避免
read/write
阻塞,保证一次处理完所有就绪数据。
2. 关键设计点
(1)“给每个 fd 封装 buffer + Connection”
- 问题:读数据时,需为每个客户端(
fd
)保存未处理完的数据,否则会丢数据。 - 方案:用
Connection
结构体封装fd
、读写缓冲区、状态等,比如: -
struct Connection { int fd; // 文件描述符 char read_buf[4096]; // 读缓冲区 int read_len; // 已读数据长度 // 其他状态... };
- 作用:解耦模块,让每个
fd
的数据独立管理,方便后续处理(如粘包、业务逻辑)。
(2)“监听 socket + 普通 socket 统一处理”
- 监听 socket(
listensock
):负责接受新连接,触发EPOLLIN
时调用accept
。 - 普通 socket(客户端连接):负责收发数据,触发
EPOLLIN
时读数据,EPOLLOUT
时写数据。 - 统一:都通过
epoll
监控EPOLLIN
事件,区别仅在于事件触发后的处理逻辑(accept
或read
)。
(3)“数据维护:用 _connections 管理所有连接”
- 用数组 / 哈希表
_connections
保存所有Connection
,键是fd
,值是Connection
对象。 - 作用:快速根据
fd
找到对应的缓冲区和状态,处理读写时直接关联。
// 1. 初始化:创建监听socket,设为非阻塞 + ET模式
int listensock = socket(...);
set_nonblocking(listensock);
epoll_ctl(epfd, EPOLL_CTL_ADD, listensock, EPOLLIN | EPOLLET);
// 2. 事件循环
while (true) {
epoll_wait(epfd, events, maxevents, -1);
for (每个事件) {
if (事件是 listensock) {
// 处理新连接:accept + 封装为 Connection + 加入 _connections
int clientfd = accept(listensock, ...);
set_nonblocking(clientfd);
_connections[clientfd] = Connection{clientfd, ...};
epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, EPOLLIN | EPOLLET);
} else {
// 处理普通socket:循环读数据到 Connection 的 buffer
Connection& conn = _connections[fd];
while (true) {
int n = read(fd, conn.read_buf + conn.read_len, sizeof(conn.read_buf) - conn.read_len);
if (n < 0) {
if (errno == EAGAIN) break; // 读完,退出循环
// 处理错误,关闭连接
close(fd);
_connections.erase(fd);
break;
} else if (n == 0) {
// 客户端关闭,清理资源
close(fd);
_connections.erase(fd);
break;
}
conn.read_len += n;
// (可选)处理完整数据包(如粘包拆分)
process_packet(conn);
}
}
}
}
总结
用 ET + 非阻塞 设计服务器,需:
- 为每个
fd
封装Connection
管理缓冲区和状态; - 统一处理监听 socket 和普通 socket 的
EPOLLIN
事件; - 循环读写直到
EAGAIN
,保证数据处理完整。
这样就能实现高效、正确的 I/O 服务器,避免数据丢失和模块耦合
Common.hpp
Connection.hpp
Epoller.hpp
InetAddr.hpp
Listener.hpp
Main.cc
makefile
Mutex.hpp
Socket.hpp
TcpServer.hpp
附录:
阻塞读是一种常见的输入读取方式,其核心特点是当程序执行读取操作时,若没有可读取的数据,会暂停当前线程的执行,直到有数据可用或超时 / 出错才继续。简单说,就是 “没数据就等,有数据才干活”。