2.1.2 select poll epoll reactor
1. select
的使用方法
fd_set rdset;
FD_ZERO(&rdset); // 清空 rdset
rdset = fdset; // 将 fdset 拷贝到 rdset,准备传给 select
select(maxFd + 1, &rdset, NULL, NULL, NULL);
- 参数说明:
maxFd
: 被监控的文件描述符中最大的一个。maxFd + 1
: 因为文件描述符从 0 开始,需要在最大值基础上加 1。&rdset
: 监控读事件的文件描述符集合。NULL
: 表示不监控写事件和异常事件。NULL
: 表示不设置超时时间,阻塞等待。
2. poll
的使用方法
数据结构
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 请求的事件:常用 POLLIN | POLLOUT */
short revents; /* 返回的事件 */
};
函数原型
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
- 参数说明:
fds
: 指向一个pollfd
结构体数组或链表,存放需要监控的文件描述符及其事件。nfds
:fds
中的文件描述符数量。timeout
: 超时时间(毫秒)。
注意事项
events
描述需要监控的事件。revents
返回实际发生的事件。- 如果
fds
中的文件描述符需要动态增删,建议使用链表以便管理;若为数组,删除操作会涉及数组元素移动。
3. epoll
的使用方法
数据结构
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; /* 用户自定义数据 */
};
函数原型
int epoll_create(int size); // size 为历史遗留参数,只需 >0
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
示例代码
#define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int listen_sock, conn_sock, nfds, epollfd;
/* 设置监听 socket (listen_sock),省略 socket(), bind(), listen() 等代码 */
epollfd = epoll_create(1);
if (epollfd == -1) {
perror("epoll_create1");
exit(EXIT_FAILURE);
}
ev.events = EPOLLIN;
ev.data.fd = listen_sock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
perror("epoll_ctl: listen_sock");
exit(EXIT_FAILURE);
}
for (;;) {
nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_wait");
exit(EXIT_FAILURE);
}
for (int n = 0; n < nfds; ++n) {
if (events[n].data.fd == listen_sock) {
conn_sock = accept(listen_sock, (struct sockaddr *)&addr, &addrlen);
if (conn_sock == -1) {
perror("accept");
exit(EXIT_FAILURE);
}
setnonblocking(conn_sock);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = conn_sock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock, &ev) == -1) {
perror("epoll_ctl: conn_sock");
exit(EXIT_FAILURE);
}
} else {
do_use_fd(events[n].data.fd);
}
}
}
注意
- 边缘触发 (ET):
- 提高性能,避免频繁切换状态。
- 需要非阻塞的文件描述符,并且在返回
EAGAIN
后继续读取/写入。
- 监听多个事件:
- 在
epoll_ctl
中可指定EPOLLIN | EPOLLOUT
,避免频繁切换。
- 在
4. epoll_ctl
中 data
的作用
数据结构
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
用途
- 数据关联:
- 将文件描述符与用户数据关联,在
epoll_wait
中可以直接访问。
- 将文件描述符与用户数据关联,在
- 灵活性:
- 支持存储指针(
ptr
)、文件描述符(fd
)或整数数据(u32
/u64
)。
- 支持存储指针(
- 事件处理上下文传递:
- 存储指针时可在事件处理中快速获取上下文信息。
示例
假设有一个自定义结构体 my_data
:
struct my_data {
int id;
// 其他数据
};
// 创建 epoll_event
struct epoll_event ev;
struct my_data *data = malloc(sizeof(struct my_data));
data->id = 123;
ev.events = EPOLLIN;
ev.data.ptr = data;
// 添加文件描述符
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &ev);
// 事件触发后处理
int n = epoll_wait(epoll_fd, events, maxevents, timeout);
for (int i = 0; i < n; i++) {
struct my_data *data = (struct my_data *)events[i].data.ptr;
printf("触发事件的 ID: %d\n", data->id);
}
总结
方法 | 特点 | 使用场景 |
---|---|---|
select | 简单、支持多平台,需多次拷贝 fd_set | 适合少量文件描述符监控 |
poll | 动态数组,支持更多事件类型 | 适合动态增加/删除描述符 |
epoll | 高效、支持边缘触发,用户可存储上下文信息,适合大量并发场景 | 高性能服务器、并发场景 |
5.epoll 的优势
1. 性能优势(O(1)复杂度)
- select 和 poll:
- 性能随监控的文件描述符数量增加而线性下降(O(n)),因为每次调用时,内核需要遍历整个文件描述符集,检查每一个文件描述符的状态。
- 当有大量文件描述符(如几千或几万个)时,这种线性扫描会导致性能大幅下降,尤其是当大多数文件描述符可能处于空闲状态时。
- epoll:
- epoll 的设计避免了线性扫描,采用事件驱动机制。当文件描述符状态变化时,内核会通知应用程序。
- 性能与文件描述符数量无关,复杂度为 O(1)。
- 使用基于回调机制的红黑树管理事件,只有发生事件的文件描述符才会被处理,大大减少了不必要的遍历。
2. 支持大量文件描述符
- select:
- 文件描述符集大小有限,在许多系统(如 Linux)上默认最多处理 1024 个文件描述符。虽然可以通过修改系统配置或编译选项增加限制,但仍是额外负担。
- poll:
- 无硬性限制,但性能瓶颈仍存在。监控大量文件描述符时,需要对整个文件描述符集进行线性遍历。
- epoll:
- 没有硬性限制(受限于系统允许的最大文件描述符数量),且处理大量文件描述符时性能几乎不受影响。
3. 事件驱动机制
- select 和 poll:
- 采用轮询方式,每次调用时必须检查所有文件描述符的状态,即使大部分文件描述符没有事件发生,也需要遍历整个列表。
- epoll:
- 提供事件通知机制,只有发生事件时,相关文件描述符才会被加入到就绪列表中。只有当事件发生时,epoll 才会处理相应文件描述符,避免不必要的轮询。
4. 持久模式 vs. 边缘触发模式
- select 和 poll:
- 都是水平触发(Level-triggered),每次调用都需要检查所有文件描述符的状态,即使事件已被处理完,下次调用时仍然返回。
- epoll:
- 支持两种模式:
- 水平触发(Level-triggered,默认): 与 select 和 poll 类似,当文件描述符有数据可读时,它会在每次 epoll_wait 时返回,直到数据被完全处理。
- 边缘触发(Edge-triggered): 文件描述符从空闲变为有事件时才会通知,不会重复通知。此模式效率高,但要求程序一次性处理所有数据,否则可能丢失事件。
- 支持两种模式:
5. 避免频繁的文件描述符集重建
- select 和 poll:
- 每次调用都需重新构建文件描述符集,带来额外开销。尤其在大量文件描述符的场景下,这种开销非常显著。
- epoll:
- 文件描述符集在调用 epoll_ctl 时构建并持久化,无需每次调用 epoll_wait 时重新构建。只有在增加、删除或修改感兴趣事件时才需调用 epoll_ctl 更改。
6. 内存复制开销
- select 和 poll:
- 每次调用时,文件描述符集需从用户空间复制到内核空间,结果再复制回用户空间。这种频繁的内存复制会带来较大开销。
- epoll:
- 仅在 epoll_ctl 调用时传递文件描述符信息给内核,epoll_wait 只返回就绪文件描述符,避免频繁的大量内存复制。
总结
- 性能: epoll 在大量文件描述符场景下表现优越,特别是大多数文件描述符处于空闲状态时。
- 可扩展性: epoll 可处理大量文件描述符,无硬性限制。
- 事件驱动: epoll 通过事件通知机制避免了轮询的开销。
- 模式选择: epoll 支持边缘触发模式,进一步提升效率。
6.epoll 的水平触发和边缘触发配置
1. 水平触发(Level-triggered)
- epoll 的默认模式。
- 只要文件描述符处于就绪状态,epoll_wait 每次调用都会返回该文件描述符,直到事件被处理(如数据被读取完毕)。
2. 边缘触发(Edge-triggered)
- 当文件描述符状态从未就绪变为就绪时,epoll_wait 才会通知。
- 更加高效,但要求程序能够一次性处理所有就绪数据或事件,避免数据残留在缓冲区中。
配置方法
使用 epoll_ctl 添加或修改文件描述符时,通过设置事件标志启用对应模式。
水平触发示例(默认模式):
struct epoll_event ev;
ev.events = EPOLLIN; // 监听可读事件(默认水平触发)
ev.data.fd = fd; // 文件描述符
// 将 fd 添加到 epoll 实例中
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &ev);
边缘触发示例(启用 EPOLLET):
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 启用边缘触发模式,并监听可读事件
ev.data.fd = fd; // 文件描述符
// 将 fd 添加到 epoll 实例中
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &ev);
注意事项
- 边缘触发必须一次性读取所有数据:
- 若数据未全部读取,后续 epoll_wait 不会再通知该文件描述符有未处理数据。
- 使用非阻塞 I/O:
- 避免因数据不足或缓冲区不足而阻塞程序。
- 设置文件描述符为非阻塞模式:
int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); // 设置为非阻塞模式
总结
- 水平触发: 默认模式,无需额外配置。
- 边缘触发: 在 epoll_event 的 events 字段中添加 EPOLLET 标志即可启用。
7. reactor 模式:
1 附件说明:
.c为 epoll改造实现的reactor代码例程
图片是验证效果
2 reactor设计流程
- 基于把网络IO的操作 和 上层业务代码隔离的思想,通过epoll_wait监控所有的listen_fd和client_fd,利用监控的事件类型events(IN、OUT)回调每个fd对应的回调函数。
- 基于上面所说,所有的fd都要绑定在一套结构体类型上,这个结构体中有IN事件对应的recv回调函数,有OUT事件对应的send回调函数。还需要定义接收数据和发送数据的缓存区,以及两个缓存区大小的变量。还有fd。
- 每次epoll_ctl设置EPOLLIN/EPOLLOUT只能设置一种,因为需要通过每一次的设置来触发一次接收或是发送的回调。
8.为什么 select
调用之前要把 rdset
清空重置?
在每次调用 select
之前,必须清空并重置 rdset
,这是因为 select
会修改传入的文件描述符集(fd_set
),以反映哪些文件描述符变得可读、可写或发生了异常。当 select
返回时,传入的 rdset
会只保留那些已经就绪的文件描述符,其它未就绪的文件描述符会被清除。
因此,在每次调用 select
前,需要重置 rdset
,使其包含所有需要监控的文件描述符。否则,如果直接使用上一次 select
调用后被修改的 rdset
,就会丢失未被清除的文件描述符信息。
简化流程:
fdset
是原始的文件描述符集,包含所有需要监控的描述符(如sockfd
和各个客户端clientfd
)。- 每次调用
select
前,将fdset
复制到rdset
(用于可读性检查)。 select
会修改rdset
,清除未准备好的文件描述符,只保留那些就绪的文件描述符。- 为了下一次
select
调用,必须再次用fdset
重新初始化rdset
,否则上次修改的rdset
会影响新的结果。
示例代码:
fd_set rdset;
FD_ZERO(&rdset); // 清空 rdset
rdset = fdset; // 将 fdset 拷贝到 rdset,准备传给 select
select(maxFd + 1, &rdset, NULL, NULL, NULL);
fd_set
类型
fd_set
是一个结构体类型,用于表示一组文件描述符集,通常与 select
函数配合使用,用于监控文件描述符是否就绪(可读、可写或有异常)。
在 POSIX 标准中,fd_set
通常被实现为一个位图(bit mask),每个文件描述符(整型值)对应一个位,表示该文件描述符是否在集合中。
常用宏:
FD_ZERO(fd_set *set)
:初始化一个空的文件描述符集。FD_SET(int fd, fd_set *set)
:将文件描述符fd
加入到文件描述符集中。FD_CLR(int fd, fd_set *set)
:从文件描述符集中移除文件描述符fd
。FD_ISSET(int fd, fd_set *set)
:检查文件描述符fd
是否在文件描述符集中。
内部实现通常如下:
typedef struct {
unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(unsigned long))];
} fd_set;
fds_bits
是一个位数组,每一位代表一个文件描述符是否处于就绪状态。__FD_SETSIZE
通常是 1024,表示可以同时监控的文件描述符数量。
9.select
/poll
/epoll
的超时参数是相对时间还是绝对时间?
select超时机制
select
函数的最后一个参数 timeout
是一个 struct timeval*
类型,表示超时时间。这个时间是相对时间,而不是绝对时间,因此系统时间的跳变不会影响 select
的超时行为。
具体说明:
-
相对时间:
struct timeval
定义的是相对时间,表示从调用select
开始计算的等待时间,是以经过的时间为基准,而不是某个特定的系统时刻。struct timeval
结构如下:struct timeval { long tv_sec; // 秒 long tv_usec; // 微秒 };
select
使用的是相对时间,并不会受到系统时钟跳变的影响。 -
系统时间跳变的影响: 因为
select
使用的是相对时间,基于单调计时器(monotonic clock),所以即使系统时间发生跳变(例如系统时间调整或 NTP 时间同步),也不会影响select
的超时计算。例如,如果设置超时为 5 秒,系统时钟被调整了 1 小时,select
依然会在约 5 秒后返回。 -
timeout
参数解释:- 如果
timeout
设置为NULL
,select
将无限等待,直到有文件描述符变为就绪。 - 如果
timeout
设置为一个struct timeval
结构指针,则select
最多等待给定的相对时间。 - 如果
timeout
的tv_sec
和tv_usec
都为 0,select
将立即返回,完成一个非阻塞的检查。
- 如果
示例:
struct timeval timeout;
timeout.tv_sec = 5;
timeout.tv_usec = 0;
select(maxfd + 1, &readfds, NULL, NULL, &timeout);
即使系统时间发生跳变,select
依然会在大约 5 秒后超时返回。
poll
和 epoll
的超时机制
poll
和 epoll
的超时机制与 select
类似,它们也使用相对时间,因此系统时间的跳变不会影响它们的超时行为。
-
poll
的超时机制:poll
函数的超时参数是一个以毫秒为单位的整数,表示相对时间,系统时间的跳变不会影响超时行为。poll
的原型:int poll(struct pollfd *fds, nfds_t nfds, int timeout);
timeout
:表示等待的相对时间(毫秒)。如果timeout
是 0,表示立即返回;如果是负数,表示无限期等待。
示例:
poll(fds, nfds, 5000); // 等待 5000 毫秒(5 秒)
-
epoll
的超时机制:epoll_wait
的超时参数同样是相对时间,单位是毫秒。系统时间跳变不会影响其超时行为。epoll_wait
的原型:int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
timeout
:表示相对时间(毫秒)。如果为 -1,表示无限等待;如果为 0,表示立即返回;为正数则等待指定的时间(毫秒)。
示例:
epoll_wait(epfd, events, maxevents, 5000); // 等待 5000 毫秒(5 秒)
小结:
select
、poll
和epoll
使用相对时间作为超时计算基准。- 系统时间的跳变不会影响它们的超时行为,因为它们依赖于单调计时器。
10.平时使用中该怎么选择使用select / poll/ epoll哪个接口
在选择 select、poll 或 epoll 作为 I/O 多路复用的接口时,取决于应用场景的需求和具体系统资源的使用情况。以下是关于如何选择这三者的一些建议:
1. select 适用场景:
select 是最早被引入的 I/O 多路复用机制,使用最广泛,适合以下情况:
- 少量文件描述符:select 的文件描述符集有硬性限制(通常是 1024),因此适合监控文件描述符数量较少的应用(如几十个)。
- 跨平台兼容性:如果应用需要在不支持 epoll 的平台上运行(如某些 BSD 系统、macOS 等),select 是一个通用选择。
- 简单场景:对于一些简单的、少量的 I/O 监控任务,select 足够使用,且其 API 更为广泛被了解。
优势:
- 兼容性好:几乎所有的 Unix 系统和 Windows 系统都支持。
劣势:
- 性能较差:当需要监控大量文件描述符时,select 每次都需要线性扫描整个文件描述符集,性能会迅速下降。
- 文件描述符限制:不能监控超过 1024 个文件描述符,除非手动修改系统配置。
- 每次调用时需要重建文件描述符集:select 会修改原始的文件描述符集,因此在每次调用前都需要重新设置该集。
2. poll 适用场景:
poll 是对 select 的增强,它消除了 select 的文件描述符数量限制,但其内部实现仍然是线性扫描,因此在大规模场景中仍然性能不足。
- 适中数量的文件描述符:如果你需要监控的文件描述符数量中等,poll 可以替代 select,因为它没有文件描述符数量的硬限制。
- 跨平台并支持大规模描述符:poll 在大多数平台上支持且没有 1024 文件描述符的限制,相对适合一些描述符数量介于几十到几百的场景。
- 复杂 I/O 场景:相比 select,poll 支持更多灵活的事件标志(如 POLLERR、POLLHUP 等),适合需要监听多种事件的场景。
优势:
- 无硬性文件描述符数量限制。
- 支持更多事件:相较于 select,poll 能处理更多种类的事件,能够更灵活地监控 I/O 状态。
劣势:
- 性能问题:当文件描述符数量增加时,poll 和 select 一样,每次调用都需要遍历整个文件描述符列表,性能会随着文件描述符数量线性下降。
- 每次调用仍需要重建文件描述符集:和 select 类似,每次调用 poll 都需要重新设置感兴趣的文件描述符集。
3. epoll 适用场景:
epoll 是 Linux 提供的高性能 I/O 多路复用机制,特别适用于需要处理大量并发连接的大规模应用场景。
- 大量文件描述符:如果你的应用需要监控几千甚至几万个文件描述符,epoll 是最佳选择。它通过事件驱动的机制,避免了每次都要遍历整个文件描述符集。
- 长连接场景:如服务器端的网络应用,尤其是需要处理大量空闲连接(如长连接或 WebSocket),epoll 能在这种场景下表现出色,因为它只会在有事件发生时通知,而不是不断轮询空闲的连接。
- 高性能和低延迟需求:对于一些需要低延迟、高吞吐量的系统,epoll 的边缘触发模式能进一步提高效率,避免无意义的系统调用。
优势:
- 高效处理大规模并发连接:即使有大量文件描述符,epoll 也能高效处理,避免了 select 和 poll 的线性性能瓶颈。
- 事件驱动模型:epoll 只在有事件发生时返回,性能更加优越。
- 无描述符数量限制:epoll 没有文件描述符数量的硬性限制,取决于系统的最大打开文件数(ulimit -n)。
劣势:
- 仅在 Linux 平台支持:epoll 是 Linux 专有的 API,如果需要跨平台支持,它就不适合。
- 复杂度稍高:与 select 和 poll 相比,epoll 的 API 需要管理文件描述符的注册、修改、删除,有些应用场景下实现起来可能稍显复杂。
4.选择总结:
- 选择 select:
- 文件描述符数量少于 1024 个,并且需要跨平台支持。
- 场景简单,性能要求不高。
- 几十个描述符的场景
- 选择 poll:
- 文件描述符数量中等,跨平台需求。
- 需要处理多种 I/O 事件且不希望受限于 select 的 1024 限制。
- 几十几百文件描述符的场景
- 选择 epoll:
- 处理大量文件描述符(成千上万个,如果硬件支持可达百万),并且系统为 Linux。
- 需要处理高并发的网络服务,且希望性能高效,延迟低。
- 希望使用事件驱动模型而不是轮询。
应用场景举例:
- 高性能 Web 服务器或代理服务器(如 Nginx):使用 epoll 处理大规模并发连接。
- 简单的命令行工具或小型服务器:可以使用 select 或 poll。
- 跨平台的网络应用:使用 poll 或 select。
未来扩展:
在现代 Linux 系统中,epoll 是处理大规模并发和高性能网络应用的首选。如果你的应用主要在 Linux 环境运行,建议优先考虑 epoll。如果需要跨平台兼容性,poll 会比 select 更适合。
https://github.com/0voice