欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 财经 > 创投人物 > 【C++高并发服务器WebServer】-15:poll、epoll详解及实现

【C++高并发服务器WebServer】-15:poll、epoll详解及实现

2025/2/10 8:00:23 来源:https://blog.csdn.net/theaipower/article/details/145535512  浏览:    关键词:【C++高并发服务器WebServer】-15:poll、epoll详解及实现

在这里插入图片描述

本文目录

  • 一、poll
  • 二、epoll
    • 2.1 相对poll和select的优点
    • 2.2 epoll的api
    • 2.3 epoll的demo实现
    • 2.5 epoll的工作模式

一、poll

poll是对select的一个改进,我们先来看看select的缺点。

在这里插入图片描述
我们来看看poll的实现。

struct pollfd {int fd; /* 委托内核检测的文件描述符 */short events; /* 委托内核检测文件描述符的什么事件 */short revents; /* 文件描述符实际发生的事件 */
};struct pollfd myfd;
myfd.fd = 5;
myfd.events = POLLIN | POLLOUT;int poll(struct pollfd *fds, nfds_t nfds, int timeout);

参数说明如下。
fds:是struct pollfd结构体数组,这是一个需要检测的文件描述符集合。

当内核检测到有变动之后,有需要修改的,会直接修改revents,不需要修改events了,相对select来说,就不需要每次重置fds集合。

除此之外,相对于select来说,并没有1024的限制。
nfds是第一个参数数组中最后一个有效元素的下标+1。

timeout,注意这个是int类型的,当为0时代表不阻塞,当为-1时表示阻塞,当检测到需要检测的文件描述符发生了变化,解除阻塞。>0表示阻塞的时长。

poll函数返回值为-1时表示失败,>0会返回n,表示检测到集合中有n个描述符发生了变化。

在这里插入图片描述

poll的服务端实现代码如下。

#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <poll.h>int main() {// 创建socketint lfd = socket(PF_INET, SOCK_STREAM, 0);struct sockaddr_in saddr;saddr.sin_port = htons(9999);saddr.sin_family = AF_INET;saddr.sin_addr.s_addr = INADDR_ANY;// 绑定bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));// 监听listen(lfd, 8);// 初始化检测的文件描述符数组struct pollfd fds[1024];for(int i = 0; i < 1024; i++) {fds[i].fd = -1;fds[i].events = POLLIN;}fds[0].fd = lfd;int nfds = 0;while(1) {// 调用poll系统函数,让内核帮检测哪些文件描述符有数据int ret = poll(fds, nfds + 1, -1);if(ret == -1) {perror("poll");exit(-1);} else if(ret == 0) {continue;} else if(ret > 0) {// 说明检测到了有文件描述符的对应的缓冲区的数据发生了改变if(fds[0].revents & POLLIN) {// 表示有新的客户端连接进来了struct sockaddr_in cliaddr;int len = sizeof(cliaddr);int cfd = accept(lfd, (struct sockaddr *)&cliaddr, &len);// 将新的文件描述符加入到集合中for(int i = 1; i < 1024; i++) {if(fds[i].fd == -1) {fds[i].fd = cfd;fds[i].events = POLLIN;break;}}// 更新最大的文件描述符的索引nfds = nfds > cfd ? nfds : cfd;}for(int i = 1; i <= nfds; i++) {if(fds[i].revents & POLLIN) {// 说明这个文件描述符对应的客户端发来了数据char buf[1024] = {0};int len = read(fds[i].fd, buf, sizeof(buf));if(len == -1) {perror("read");exit(-1);} else if(len == 0) {printf("client closed...\n");close(fds[i].fd);fds[i].fd = -1;} else if(len > 0) {printf("read buf = %s\n", buf);write(fds[i].fd, buf, strlen(buf) + 1);}}}}}close(lfd);return 0;
}

对应的客户端代码我们继续沿用之前的即可。

#include <stdio.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>int main() {// 创建socketint fd = socket(PF_INET, SOCK_STREAM, 0);if(fd == -1) {perror("socket");return -1;}struct sockaddr_in seraddr;inet_pton(AF_INET, "127.0.0.1", &seraddr.sin_addr.s_addr);seraddr.sin_family = AF_INET;seraddr.sin_port = htons(9999);// 连接服务器int ret = connect(fd, (struct sockaddr *)&seraddr, sizeof(seraddr));if(ret == -1){perror("connect");return -1;}int num = 0;while(1) {char sendBuf[1024] = {0};sprintf(sendBuf, "send data %d", num++);write(fd, sendBuf, strlen(sendBuf) + 1);// 接收int len = read(fd, sendBuf, sizeof(sendBuf));if(len == -1) {perror("read");return -1;}else if(len > 0) {printf("read buf = %s\n", sendBuf);} else {printf("服务器已经断开连接...\n");break;}// sleep(1);usleep(1000);}close(fd);return 0;
}

二、epoll

首先调用epoll_create实现一个epoll的实例,这个epoll实例是在内核区,是结构体类型,可以理解成一块数据。返回值是一个文件描述符,那我们就可以通过这个文件描述符来操作这块内核当中的epoll数据(通过epoll提供的一些api来进行操作)。

eventpoll中有两个最关键的数据,就是rbrrdlist,也就是红黑树双向就绪链表

rbr记录需要检测的文件描述符。(之前需要把一些表从用户态拷贝到内核态,现在是直接在内核态,效率高了很多。另外现在是红黑树,之前是链表,红黑树的遍历效率也高很多。)

rdlist是检测文件描述符当中哪些是有数据发生改变的。

在函数epoll_ctl(epfd,EPOLL_CTL_ADD,lfd,&ev)中最后一个参数evstruct epoll_event类型,需要设置eventsev.data.fd

调用epoll_wait之后内核就会去检测rbr里面的文件描述符是否有对应的数据改变。如果有改变的(就绪的),就会把文件描述符的信息放到rdlist中,然后会把这个list拷贝到用户区,这样用户区直接遍历这几个fd,就可以进行对应的读写操作了。

在这里插入图片描述

2.1 相对poll和select的优点

时间复杂度方面,每次调用 select 或 poll 时,内核需要遍历所有被监控的文件描述符,检查它们的状态。select 和 poll 的时间复杂度是 O(n)。当文件描述符数量很大时(例如成千上万个),这种线性扫描的效率会非常低。

epoll 的时间复杂度是 O(1),epoll 使用红黑树和双向链表来管理文件描述符。当文件描述符的状态发生变化时,内核会将其加入到就绪链表中,用户程序只需要检查就绪链表即可,而不需要遍历所有文件描述符。

文件描述符数量限制方面,默认情况下,select 只能监控最多 1024 个文件描述符(由 FD_SETSIZE`定义)。如果需要监控更多的文件描述符,需要修改内核参数并重新编译程序。poll 使用数组来存储文件描述符,理论上可以监控任意数量的文件描述符。但当文件描述符数量很大时,遍历整个数组的效率会非常低。

epoll 可以轻松支持数万个甚至更多的文件描述符。它使用红黑树来存储文件描述符,查找和插入的效率很高。

用户态和内核态的数据拷贝方面,每次调用 select 或 poll 时,都需要将文件描述符集合从用户态拷贝到内核态:当文件描述符数量很大时,这种拷贝操作会带来较大的开销。

对于epoll,文件描述符只需要通过 epoll_ctl 添加到内核事件表中一次,后续不需要重复拷贝。当文件描述符状态变化时,内核会直接将事件放入就绪链表中,用户程序通过 epoll_wait 获取就绪事件。

事件触发模式方面,select 和 poll 只支持水平触发(Level-Triggered,LT)模式:如果文件描述符的状态满足条件(例如有数据可读),select 和 poll 会一直通知用户程序,直到状态发生变化。

在这里插入图片描述

epoll 支持水平触发(LT)和边缘触发(Edge-Triggered,ET)模式:
水平触发(LT):与 select 和 poll 的行为相同,只要文件描述符的状态满足条件,就会一直通知用户程序。边缘触发(ET):只有当文件描述符的状态发生变化时,才会通知用户程序。这种模式可以减少重复通知的次数,提高效率。但是需要用户程序一次性处理完所有数据,否则可能会丢失数据。

内核实现机制方面,select 和 poll 是基于轮询的机制:每次调用时,内核需要遍历所有文件描述符,检查它们的状态。这种机制在大规模并发场景下效率较低。

epoll 是基于事件回调的机制:内核会为每个文件描述符注册回调函数,当文件描述符的状态发生变化时,内核会调用回调函数将其加入到就绪链表中。这种机制避免了不必要的遍历,效率更高。

2.2 epoll的api

头文件如下。

#include <sys/epoll.h>

/创建一个新的epoll实例。在内核中创建了一个数据,这个数据中有两个比较重要的数据,一个是需要检测的文件描述符的信息(红黑树),还有一个是就绪列表,存放检测到数据发送改变的文件描述符信息(双向链表)。【从linux内核2.6.8开始,size这个参数已经被忽略了,但是必须大于0。】

int epoll_create(int size);
- 参数:size : 目前没有意义了。随便写一个数,必须大于0
- 返回值:-1 : 失败> 0 : 文件描述符,操作epoll实例的

对epoll实例进行管理:添加文件描述符信息,删除信息,修改信息

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- 参数:- epfd : epoll实例对应的文件描述符- op : 要进行什么操作EPOLL_CTL_ADD: 添加EPOLL_CTL_MOD: 修改(比如从读事件改成写事件)EPOLL_CTL_DEL: 删除- fd : 要检测的文件描述符- event : 检测文件描述符什么事情

epoll_event是检测事件的结构体,定义如下。

struct epoll_event {uint32_t events; /* Epoll events */epoll_data_t data; /* User data variable */
};
常见的Epoll检测事件:- EPOLLIN - EPOLLOUT - EPOLLERR- EPOLLET (设置边沿触发)

在其中,又有一个联合体epoll_data_t,定义如下。通过联合体,用户可以选择存储不同类型的数据,如指针、文件描述符、32 位或 64 位整数。

ptr是一个指向任意类型的指针。用户可以将与事件相关的任意数据存储在这个指针中,例如指向某个结构体的指针。这种方式非常灵活,可以存储用户自定义的数据结构。
fd,这是 epoll 最常用的用途之一,直接存储与事件相关的文件描述符。
u32是一个 32 位的无符号整数。用户可以存储一些简单的整数值作为用户数据。u64同理。

联合体 epoll_data_t 的设计允许用户根据需要选择存储不同类型的数据。联合体的特性是所有成员共享同一块内存,因此在任何时刻,联合体中只有一个成员是有效的。用户可以根据实际需求选择存储哪种类型的数据。

typedef union epoll_data {void *ptr;int fd;uint32_t u32;uint64_t u64;
} epoll_data_t;

检测函数如下。

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, inttimeout);- 参数:- epfd : epoll实例对应的文件描述符- events : 传出参数,保存了发送了变化的文件描述符的信息- maxevents : 第二个参数结构体数组的大小- timeout : 阻塞时间- 0 : 不阻塞- -1 : 阻塞,直到检测到fd数据发生变化,解除阻塞
-		 > 0 : 阻塞的时长(毫秒)- 返回值:- 成功,返回发送变化的文件描述符的个数 > 0- 失败 -1

这里有个问题是,在使用 epoll 时,epoll_ctl 函数确实已经将文件描述符(fd)注册到了 epoll 实例中,但 epoll_event 结构体中的 data.fd 仍然需要存储文件描述符的原因主要有以下几点:

首先epoll_ctl是用于将文件描述符注册到 epoll 实例中,并设置相关的事件类型(如 EPOLLIN、EPOLLOUT 等)。它的作用是告诉 epoll 哪些文件描述符需要被监控,以及监控哪些类型的事件。

epoll_event 中 用于在 epoll_wait 调用时返回检测到的事件。
它的作用是告诉用户哪些文件描述符发生了事件,以及发生了哪些类型的事件。epoll_event 中的 data.fd 是为了方便用户在 epoll_wait 返回后,能够直接获取到发生事件的文件描述符。

epoll_wait 返回时,它会返回一个 epoll_event 数组,每个 epoll_event 表示一个发生事件的文件描述符及其事件类型。通过在 epoll_event 中存储 fd,用户可以直接从 epoll_event 中获取到发生事件的文件描述符,而无需额外查找。

2.3 epoll的demo实现

#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>int main() {// 创建socketint lfd = socket(PF_INET, SOCK_STREAM, 0);struct sockaddr_in saddr;saddr.sin_port = htons(9999);saddr.sin_family = AF_INET;saddr.sin_addr.s_addr = INADDR_ANY;// 绑定bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));// 监听listen(lfd, 8);// 调用epoll_create()创建一个epoll实例int epfd = epoll_create(100);// 将监听的文件描述符相关的检测信息添加到epoll实例中struct epoll_event epev;epev.events = EPOLLIN;epev.data.fd = lfd;epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &epev);struct epoll_event epevs[1024];while(1) {int ret = epoll_wait(epfd, epevs, 1024, -1);if(ret == -1) {perror("epoll_wait");exit(-1);}printf("ret = %d\n", ret);for(int i = 0; i < ret; i++) {int curfd = epevs[i].data.fd;if(curfd == lfd) {// 监听的文件描述符有数据达到,有客户端连接struct sockaddr_in cliaddr;int len = sizeof(cliaddr);int cfd = accept(lfd, (struct sockaddr *)&cliaddr, &len);epev.events = EPOLLIN | EPOLLOUT; //监听的事件比较多,所以每一种事件在下方都需要进行对应的处理。epev.data.fd = cfd;epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &epev);} else {if(epevs[i].events & EPOLLOUT) {continue;}   // 有数据到达,需要通信char buf[1024] = {0};int len = read(curfd, buf, sizeof(buf));if(len == -1) {perror("read");exit(-1);} else if(len == 0) {printf("client closed...\n");epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL);close(curfd);} else if(len > 0) {printf("read buf = %s\n", buf);write(curfd, buf, strlen(buf) + 1);}}}}close(lfd);close(epfd);return 0;
}

2.5 epoll的工作模式

epoll 有两种工作模式:LT(水平触发)模式和 ET(边沿触发)模式。在 LT 模式中,当内核检测到文件描述符(fd)的读缓冲区中有数据时,会通知用户。如果用户没有读取数据,数据会一直保留在缓冲区中,epoll 会持续通知用户。即使用户只读取了一部分数据,epoll 也会继续通知,直到缓冲区的数据被完全读走。LT 模式同时支持阻塞(block)和非阻塞(non-block)的 socket,它是一种缺省的工作方式,内核会持续告知用户文件描述符是否就绪,并允许用户对这个就绪的 fd 进行 I/O 操作。如果用户不进行任何操作,内核会继续发送通知。

相比之下,ET 模式是一种高速工作方式,仅支持非阻塞 socket。在这种模式下,内核仅在文件描述符从未就绪变为就绪时通过 epoll 通知用户一次。一旦通知,内核会假定用户知道文件描述符已经就绪,并且不会再为该文件描述符发送更多的就绪通知,除非用户执行了某些操作导致文件描述符不再处于就绪状态。在 ET 模式中,如果用户不对 fd 执行 I/O 操作,从而使得它再次变为未就绪状态,内核不会再次发送通知。这种模式显著减少了 epoll 事件被重复触发的次数,因此比 LT 模式更高效。在 ET 模式下工作时,必须使用非阻塞套接字,以避免由于单个文件句柄的阻塞读/写操作导致处理多个文件描述符的任务饿死。

需要特别注意的是,ET模式中,如果用户不读数据,数据一直在缓冲区中,epoll下次检测的时候就不会再通知了。

如果使用了ET模式,那么在监听到有客户端连接之后,对cfd的属性需要设置非阻塞。

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com