目录
1. poll
2. epoll
2.1 epoll_ctl
2.2 epoll_wait
2.3 epoll原理
2.4 epoll的工作模式
2.5 epoll的惊群效应
使用建议
总结
1. poll
poll也是实现 I/O 多路复用的系统调用,可以解决select等待fd上限的问题,将输入输出参数分离,不需要每次对参数重置;
原型:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
参数:
- fds是一个poll函数监听的结构列表, 每一个元素中,包含了三部分内容:文件描述符,监听的事件集合, 返回的事件集合;
- nfds表示fds数组的长度.(由此可以看出,fds其实就是一个数组)
- timeout:指定 poll 函数的超时时间,单位为毫秒。其取值有以下几种情况:
timeout > 0:poll 函数会阻塞,直到指定的文件描述符上有事件发生或者超时时间到达。
timeout == 0:poll 函数会立即返回,不会阻塞,用于非阻塞检查文件描述符的状态。
timeout == -1:poll 函数会一直阻塞,直到指定的文件描述符上有事件发生。
调用时:调用poll时,用户->内核,OS帮用户关心fd,上面的events事件;
返回时:poll返回时,内核->用户,用户关心的fd上面,有哪些的revents事件准备就绪;
返回结果:
- 大于 0:表示发生事件的文件描述符的数量。
- 等于 0:表示超时,即在指定的时间内没有文件描述符发生事件。
- 等于 -1:表示发生错误,同时会设置 errno 来指示具体的错误类型。
events和revents的取值:
示例:
#include <stdio.h>
#include <poll.h>
#include <unistd.h>
int main() {
struct pollfd fds[1];
fds[0].fd = 0; // 监控标准输入
fds[0].events = POLLIN; // 监控可读事件
int ret = poll(fds, 1, -1); // 一直阻塞,直到有事件发生
if (ret == -1) {
perror("poll");
return 1;
}
if (fds[0].revents & POLLIN) {
char buf[1024];
ssize_t n = read(STDIN_FILENO, buf, sizeof(buf));
if (n > 0) {
buf[n] = '\0';
printf("Read from stdin: %s", buf);
}
}
return 0;
}
pollserver示例:poll_server
优点:
- 可以等待多个fd,效率高
- 输入,输出参数分离(events和revents),不用频繁对poll参数进行重置
- poll关心的fd没上限(内部动态申请空间)
缺点:
- 用户到内核要进行数据拷贝(struct pollfd 结构体数组传递给内核空间,这涉及到用户空间和内核空间之间的数据拷贝操作)
- 应用层使用时,仍然需要遍历,在内核层面,要遍历检测,关心的fd是否有对应的事件就绪;在应用层,当 poll 函数返回后,需要遍历 struct pollfd 结构体数组,检查每个 fd 的 revents 成员,以确定哪些文件描述符上发生了事件。在内核层监控依然是线性遍历;
2. epoll
epoll在man 手册中是这样描述的:是为处理大批量句柄而作了改进的poll;并且解决了poll遗留下来的问题;
解决 poll 遍历开销大的问题:epoll 的改进:epoll 采用红黑树来管理需要监控的文件描述符。
epoll相对于select、poll、提供了更多的操作接口:
int epoll_create(int size);
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);
2.1 epoll_ctl
用于控制 epoll 实例,可以添加、修改或删除要监控的文件描述符及其事件;
接口原型:
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *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 events */
epoll_data_t data; /* User data variable */
};
- events:表示要监听的事件类型,如 EPOLLIN(可读)、EPOLLOUT(可写)等。
- data:是一个联合体,通常使用 fd 成员来存储关联的文件描述符,也可以用 ptr 指向自定义的数据。
参数:
- epfd:epoll_create 返回的 epoll 实例的文件描述符。
- op:操作类型,有 EPOLL_CTL_ADD(添加)、EPOLL_CTL_MOD(修改)、EPOLL_CTL_DEL(删除)。
- fd:要监控的文件描述符。
- event:指向 epoll_event 结构体的指针,指定要监控的事件类型和关联的数据。
返回值:成功返回 0,失败返回 -1
2.2 epoll_wait
等待 epoll 实例中注册的文件描述符上的事件发生;
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
参数:
- epfd:epoll 实例的文件描述符。
- events:用于存储发生事件的 epoll_event 结构体数组。
- maxevents:events 数组的最大元素个数。
- timeout:超时时间,单位为毫秒。-1 表示阻塞等待,0 表示立即返回。
返回值:成功返回发生事件的文件描述符数量,超时返回 0,失败返回 -1
events:
- EPOLLIN : 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭);
- EPOLLOUT : 表示对应的文件描述符可以写;
- EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来);
- EPOLLERR : 表示对应的文件描述符发生错误;
- EPOLLHUP : 表示对应的文件描述符被挂断;
- EPOLLET : 将EPOLL设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的.
- EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要 再次把这个socket加入到EPOLL队列里
简单使用示例:
#include <stdio.h>
#include <sys/epoll.h>
#include <unistd.h>
#define MAX_EVENTS 10
int main() {
// 创建 epoll 实例
int epoll_fd = epoll_create1(0);
if (epoll_fd == -1) {
perror("epoll_create1");
return 1;
}
// 定义要监控的事件
struct epoll_event ev, events[MAX_EVENTS];
ev.events = EPOLLIN;
ev.data.fd = STDIN_FILENO;
// 添加标准输入到 epoll 实例中进行监控
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, STDIN_FILENO, &ev) == -1) {
perror("epoll_ctl: STDIN_FILENO");
close(epoll_fd);
return 1;
}
while (1) {
// 等待事件发生
int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_wait");
close(epoll_fd);
return 1;
}
// 处理事件
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == STDIN_FILENO) {
char buf[1024];
ssize_t n = read(STDIN_FILENO, buf, sizeof(buf));
if (n > 0) {
buf[n] = '\0';
printf("Read from stdin: %s", buf);
}
}
}
}
// 关闭 epoll 实例
close(epoll_fd);
return 0;
}
epollserver的示例:epoll_server
2.3 epoll原理
前边提到epoll是采用红黑树来解决遍历效率低的问题, 而epoll_create的功能其实就是构建红黑树等一系列操作;具体流程:申请struct file,创建epoll模型:构建红黑树,构建就绪队列,设置回调机制;把struct file设置到文件描述符表,返回fd;
epoll_ctl的选项操作,其实就是对epoll模型中的红黑树进行增删改;设置进去的fd和event就会添加到红黑树中(要关心的fd上的哪些事件)
epoll_wait获取准备就绪的fd ,可以直接到就绪队列获取;(内核告诉用户哪些fd的哪些事件就绪);结合epoll_wait参数,"数组的首元素地址""数组大小",epoll会从就绪队列获取节点,会按照顺序严格的放在 events *中(select和poll中的数组中间会出现空缺的情况),并用返回值表明就绪事件个数;
- epoll_wait检测是否有事件就绪,时间复杂度O(1);检查就绪链表是否为空即可判断;
- 获取所有就绪事件,事件复杂度O(N)——这个是必然,无法优化;遍历就绪链表,将N个节点添加到 event* 数组中;
如果 events * 数组满了怎么办?满了就返回;
struct eventpoll{
....
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
....
};
网卡从网络中读取到数据后,通过硬件中断将数据读到网卡驱动,网卡驱动允许注册相应的回调方法,红黑树中每添加一个节点,就会为该节点注册一个相应的回调方法;这个回调方法主要完成两个工作:
- 在内核中确认发生的事件(是否关心)
- 向ready_queue中形成新节点,表明哪个fd上的哪些事件已经发生
这整个过程都是依靠单线程/进程完成的,那如果是多线程/进程呢?
epoll模型创建返回值是一个文件描述符,只需对文件描述符表的管理,就可以做到对epoll模型进行管理;
epoll的优点:
- 接口使用方便:虽然拆分成了三个函数, 但是反而使用起来更方便高效.
- 不需要每次循环都设置关注的文件描述符,也做到了输入输出参数分离开
- 数据拷贝轻量:只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中,这个操作并不频繁(而select/poll都是每次循环都要进行拷贝)
- 事件回调机制:避免使用遍历,而是使用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中,epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度O(1). 即使文件描述 符数目很多, 效率也不会受到影响.
- 没有数量限制: 文件描述符数目无上限.
2.4 epoll的工作模式
epoll有两种工作模式:水平触发、边缘触发;它是epoll提供给用户的一种通知事件就绪的策略;
epoll默认是LT模式;如果数据就绪,但上层并不做处理,epoll就会一直通知——LT模式;
ET策略:底层有数据,只通知一次,就不再通知,直到下次数据发生变化的时候(底层收到新的数据),才通知;
LT模式和ET模式相比,ET模式更高效;因为在通知策略中,没有无效的通知,全部都是有效的;
ET模式只会通知一次,倒逼上层(程序员),要取数据就要把本轮的数据取完;
在TCP服务中,这样的话,底层也会给发送方告知窗口大小,会通告一个更大的窗口;发送方的滑动窗口大小就会变得更大,从概率上,也可以提高双方的通信效率;
ET模式下要求数据一次读完,那如何判断数据是否一次读完了?循环读取,直到缓冲区没有数据,就会阻塞;
但是,epoll一般是单线程/进程,一旦阻塞就没法继续后续的操作了,所以ET模式下必须以非阻塞状态进行IO操作;
epoll的高性能,是有一定的特定场景的。如果场景选择的不适宜,epoll的性能可能适得其反;
对于多连接,且多连接中只有一部分连接比较活跃时,比较适合使用epoll;
2.5 epoll的惊群效应
在多线程或者多进程环境下,有些人为了提高程序的稳定性,往往会让多个线程或者多个进程同时在epoll_wait监听的socket描述符。当一个新的链接请求进来时,操作系统不知道选派那个线程或者进程处理此事件,则干脆将其中几个线程或者进程都给唤醒;而实际上只有其中一个进程或者线程能够成功处理accept事件,其他线程都将失败,这种现象称为惊群效应,结果是肯定的,惊群效应肯定会带来资源的消耗和性能的影响;
解决办法:
- 不建议让多个线程同时在epoll_wait监听的socket,而是让其中一个线程epoll_wait监听的socet,当有新的链接请求进来之后,由epoll_wait的线程调用accept,建立新的连接,然后交给其他工作线程处理后续的数据读写请求;
- 每个子进程仍然管自己在监听的socket上调用epoll_wait,当有新的链接请求发生时,操作系统仍然唤醒其中部分的子进程来处理该事件,仍然只有一个子进程能够成功处理此事件,那么其他被惊醒的子进程捕获EAGAIN错误,并无视;
- 创建一个全局的pthread_mutext,在子进程进行epoll_wait前,先获取锁,在同一时刻,永远都只有一个子讲程在监听的sacket 上epoll_wait;
使用建议
发数据问题:
什么时候不能发?—— 发送缓冲区被写满的时候;
什么时候能发?—— 缓冲区不满,刚开始的时候可以直接发(大部分情况下发送缓冲区不会被写满);不能发的时候,把fd交给epoll,让epoll关心什么时候可以发(写事件就绪);
怎么知道发送缓冲区写满?
一直循环的去写;直到发送条件不具备,再交给epoll处理;
对于多路转接而言:
- 一般对于任何fd,EPOLLIN事件,常设关心;
- 对于写事件,按需关心即可
总结
以上便是本文的全部内容,希望对你有所帮助,感谢阅读!