Linux网络编程——I/O多路转接(2)之 poll、epoll
目录
一、前言
二、多路转接接口 poll
1、poll接口
2、poll服务器的实现
3、poll的优缺点
三、I/O多路转接之epoll
1、epoll概念
2、epoll接口与其相关调用
I、epoll_create
II、epoll_ctl
III、epoll_wait函数
3、epoll的工作机制
4、epoll服务器
5、epoll的优点
6、epoll的工作模式
I、水平触发(Level Triggered, LT)
II、边缘触发(Edge Triggered, ET)
一、前言
上篇文章中我们提到了I/O多路转接的select接口,它存在着很多的不足如:
- select的fd存在着上限
- 每次调用都需要重新设置关心的fd
针对select接口中存在的问题,本章中我们再来介绍其他的多路转接的接口——poll接口和epoll接口。
二、多路转接接口 poll
1、poll接口
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
- fds:一个poll函数监视的结构列表,每一个元素包含三部分内容:文件描述符、监视的事件集合、就绪的事件集合。就像是new/malloc出来的一个数组,解决了select的文件描述符有上限的问题。
- nfds:表示fds数组的长度。
- timeout:表示poll函数的超时时间,单位是毫秒(ms)。
参数timeout的取值
- -1:poll调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
- 0:poll调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,poll检测后都会立即返回。
- 特定的时间值:poll调用后在指定的时间内进行阻塞等待,如果被监视的文件描述符上一直没有事件就绪,则在该时间后poll进行超时返回。
返回值
- 如果函数调用成功,则返回有事件就绪的文件描述符个数。
- 如果timeout时间耗尽,则返回0。
- 如果函数调用失败,则返回-1,同时错误码会被设置。
错误码设置
- EFAULT:fds数组不包含在调用程序的地址空间中。
- EINTR:此调用被信号所中断。
- EINVAL:nfds值超过RLIMIT_NOFILE值。
- ENOMEM:核心内存不足。
struct pollfd 结构体
struct pollfd {int fd; /* 文件描述符,若设置为负值则忽略events字段并且revents字段返回0。 */short events; /* 需要监视该文件描述符上的哪些事件。 */short revents;/* poll函数返回时告知用户该文件描述符上的哪些事件已经就绪*/
};
events和revents的取值
上表中的取值都是大写,他们都是以宏的方式定义的,他们的二进制序列当中有且只有一个比特位是1,且为1的比特位是各不相同的。
- 因此在调用poll函数之前,可以通过或运算符将要监视的事件添加到events成员当中。
- 在poll函数返回后,可以通过与运算符检测revents成员中是否包含特定事件,以得知对应文件描述符的特定事件是否就绪。
从上面看,poll使用了两个量来代表输入和输出,events代表输入,revents代表输出,真正做到了输入与输出的分离。这也就不用像select接口一样每次都要对关心的fd做设置。
2、poll服务器的实现
poll服务器的实现与select实际上差不多的,socket.hpp是对创建套接字做的封装,不用修改。
// poll_server.hpp封装poll服务器
#pragma once
#include "Socket.hpp"
#include <poll.h>#define BACK_LOG 5
#define NUM 1024
#define DFL_FD -1
class PollServer
{
public:
PollServer(int port): _port(port){}void InitPollServer() // 初始化{_LisSocFd = Socket::SocCreate();Socket::SocketBind(_LisSocFd, _port);Socket::SocketListen(_LisSocFd, BACK_LOG);}~PollServer(){if (_LisSocFd >= 0){close(_LisSocFd);}}void HandlerEvent(struct pollfd fds[], int num) // 事件就绪之后的处理{for (int i = 0; i < num; i++)//遍历保存着需要被poll监视的文件描述符的数组{if (fds[i].fd == DFL_FD)//跳过无效的文件描述符continue;if (fds[i].fd == _LisSocFd &&fds[i].revents&POLLIN)//对处于就绪状态的监听套接字的处理{struct sockaddr_in peer;memset(&peer, 0, sizeof(peer));socklen_t len = sizeof(peer);int sockFd = accept(_LisSocFd, (struct sockaddr*)&peer, &len);//获取连接if (sockFd < 0){std::cerr << "accept error" << std::endl;continue;}std::string peer_ip = inet_ntoa(peer.sin_addr);int peer_port = ntohs(peer.sin_port);std::cout << "Get a link sucessfully!" << peer_ip << ":" << peer_port << std::endl;//将获取到的套接字添加到fds数组中,并关心其读事件//如果将该文件描述符无法添加进数组直接关闭该文件描述符if (!SetPollfds(fds, NUM, sockFd)){close(sockFd);std::cout << "select server is full,close fd:" << sockFd << std::endl;}}else if (fds[i].revents&POLLIN)//读事件就绪{char buffer[1024];ssize_t s = read(fds[i].fd, buffer, sizeof(buffer) - 1);if (s > 0)//读取成功{buffer[s] = '\0';std::cout << "echo#:" << buffer << std::endl;}else if (s == 0)//读事件就绪,但是在读的时候没有读到,只能是对端的连接关闭{std::cout << "Client quit!" << std::endl;close(fds[i].fd);UnSetPollfds(fds,i);}else{std::cerr << "read error" << std::endl;close(fds[i].fd);UnSetPollfds(fds,i);}}}}void Run(){struct pollfd fds[NUM];ClearPollfds(fds,NUM,DFL_FD);SetPollfds(fds,NUM,_LisSocFd);for(;;){switch(poll(fds,NUM,-1)){case 0:std::cout<<"time out..."<<std::endl;break;case -1:std::cerr<<"poll error"<<std::endl;break;default:HandlerEvent(fds,NUM);break;}}}private:void ClearPollfds(struct pollfd fds[],int num,int default_fd)//初始化fds{for(int i=0;i<num;i++){fds[i].fd=default_fd;fds[i].events=0;fds[i].revents=0;}}bool SetPollfds(struct pollfd fds[],int num,int fd)//将文件描述符添加至fds中{for(int i=0;i<num;i++){if(fds[i].fd==DFL_FD){fds[i].fd=fd;fds[i].events |= POLLIN;//设置读事件关心return true;}}return false;//fds数据已满}void UnSetPollfds(struct pollfd fds[],int pos)//移除fds中的文件描述符{fds[pos].fd=DFL_FD;fds[pos].events=0;fds[pos].revents=0;}
private:int _port;int _LisSocFd;
};
服务器初始化完毕后就可以开始运行了,而poll服务器要做的就是不断调用poll函数,当事件就绪时对应执行某种动作即可。
- 首先,在poll服务器开始死循环调用poll函数之前,需要定义一个fds数组,该数组当中的每个位置都是一个struct pollfd结构,后续调用poll函数时会作为参数进行传入。先将fds数组当中每个位置初始化为无效,并将监听套接字添加到fds数组当中,表示服务器刚开始运行时只需要监视监听套接字的读事件。
- 此后,poll服务器就不断调用poll函数监视读事件是否就绪。如果poll函数的返回值大于0,则说明poll函数调用成功,此时已经有文件描述符的读事件就绪,接下来就应该对就绪事件进行处理。如果poll函数的返回值等于0,则说明timeout时间耗尽,此时直接准备进行下一次poll调用即可。如果poll函数的返回值为-1,则说明poll调用失败,此时也让服务器准备进行下一次poll调用,但实际应该进一步判断错误码,根据错误码来判断是否应该继续调用poll函数。
连接前
连接后
多客户条件下
退出
3、poll的优缺点
优点
- struct pollfd结构当中包含了events和revents,相当于将select的输入输出型参数进行分离,因此在每次调用poll之前,不需要像select一样重新对参数进行设置。
- poll可监控的文件描述符数量没有限制。
- 当然,poll也可以同时等待多个文件描述符,能够提高IO的效率。
虽然代码中将fds数组的元素个数定义为1024,但fds数组的大小是可以继续增大的,poll函数能够帮你监视多少个文件描述符是由传入poll函数的第二个参数决定的。
而fd_set类型只有1024个比特位,因此select函数最多只能监视1024个文件描述符。
缺点:
poll的主要问题还是遍历问题
- 和select函数一样,当poll返回后,需要遍历fds数组来获取就绪的文件描述符。
- 每次调用poll,都需要把大量的struct pollfd结构从用户态拷贝到内核态,这个开销也会随着poll监视的文件描述符数目的增多而增大。
- 同时每次调用poll都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大。
三、I/O多路转接之epoll
1、epoll概念
epoll也是系统提供的一个多路转接接口。
- epoll系统调用也可以让我们的程序同时监视多个文件描述符上的事件是否就绪,与select和poll的定位是一样的,适用场景也相同。
- epoll在命名上比poll多了一个e,这个e可以理解成是extend,epoll就是为了同时处理大量文件描述符而改进的poll。
- epoll在2.5.44内核中被引进,它几乎具备了select和poll的所有优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法。
2、epoll接口与其相关调用
epoll有三个相关的系统调用,分别是epoll_create、epoll_ctl和epoll_wait。
I、epoll_create
该接口用于创建一个epoll 模型
int epoll_create(int size);
参数说明:
- size:自从Linux2.6.8之后,size参数是被忽略的,但size的值必须设置为大于0的值。
返回值:
- epoll模型创建成功返回其对应的文件描述符,否则返回-1,同时错误码会被设置。
注意: 当不再使用时,必须调用close函数关闭epoll模型对应的文件描述符,当所有引用epoll实例的文件描述符都已关闭时,内核将销毁该实例并释放相关资源。
II、epoll_ctl
该接口用于向指定的epoll模型中注册事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数说明
- epfd:指定的epoll模型。
- op:表示具体的动作,用三个宏来表示。
- EPOLL_CTL_ADD:注册新的文件描述符到指定的epoll模型中。
- EPOLL_CTL_MOD:修改已经注册的文件描述符的监听事件。
- EPOLL_CTL_DEL:从epoll模型中删除指定的文件描述符。
- fd:需要监视的文件描述符。
- event:需要监视该文件描述符上的哪些事件。
返回值说明
- 函数调用成功返回0,调用失败返回-1,同时错误码会被设置。
struct epoll——event结构体
struct epoll_event {uint32_t events; //位掩码,表示感兴趣的事件类型或者已经发生的事件类型,//如EPOLLIN(可读)、EPOLLOUT(可写)等epoll_data_t data; //联合体,允许用户存储额外的数据。当事件发生时,可以通过这个字段快速识 //别对应的文件描述符或其他自定义数据。
}; //可以是一个指针、一个文件描述符、或者其他类型的整数数据
其中 epoll_data_t 是一个联合体,定义如下
typedef union epoll_data {void *ptr; // 指向用户自定义数据的指针int fd; // 文件描述符uint32_t u32; // 32位无符号整数uint64_t u64; // 64位无符号整数
} epoll_data_t;
events的常用取值如下:
- EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭)。
- EPOLLOUT:表示对应的文件描述符可以写。
- EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)。
- EPOLLERR:表示对应的文件描述符发送错误。
- EPOLLHUP:表示对应的文件描述符被挂断,即对端将文件描述符关闭了。
- EPOLLET:将epoll的工作方式设置为边缘触发(Edge Triggered)模式。
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听该文件描述符的话,需要重新将该文件描述符添加到epoll模型中。
同样,这些取值实际也是以宏的方式进行定义的,它们的二进制序列当中有且只有一个比特位是1,且为1的比特位是各不相同的。
III、epoll_wait函数
该接口用于收集监视的事件中已经就绪的事件
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
参数说明
- epfd:指定的epoll模型。
- events:内核会将已经就绪的事件拷贝到events数组当中(events不能是空指针,内核只负责将就绪事件拷贝到该数组中,不会帮我们在用户态中分配内存)。
- maxevents:events数组中的元素个数,该值不能大于创建epoll模型时传入的size值。
- timeout:表示epoll_wait函数的超时时间,单位是毫秒(ms)。
参数 timeout 的取值
- -1:epoll_wait调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
- 0:epoll_wait调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,epoll_wait检测后都会立即返回。
- 特定的时间值:epoll_wait调用后在直到的时间内进行阻塞等待,如果被监视的文件描述符上一直没有事件就绪,则在该时间后epoll_wait进行超时返回。
返回值
- 如果函数调用成功,则返回有事件就绪的文件描述符个数。
- 如果timeout时间耗尽,则返回0。
- 如果函数调用失败,则返回-1,同时错误码会被设置如下
- EBADF:传入的epoll模型对应的文件描述符无效。
- EFAULT:events指向的数组空间无法通过写入权限访问。
- EINTR:此调用被信号所中断。
- EINVAL:epfd不是一个epoll模型对应的文件描述符,或传入的maxevents值小于等于0。
3、epoll的工作机制
在用户空间编程中,我们通常直接与 epoll API 交互,如 epoll_create、epoll_ctl和 epoll_wait等函数,并使用struct epoll_event 结构体来定义感兴趣的事件和获取事件通知。然而,在内核空间中,Linux 使用 eventpoll结构体(有时也称为 epoll实例)来管理这些事件和文件描述符。先来介绍一下重要的结构体
eventpoll结构体
下面是一个简化的版本包含了核心的成员和功能
struct eventpoll {spinlock_t lock; // 保护并发访问的自旋锁struct mutex mtx; // 互斥锁,用于epoll_ctl等操作的互斥/* 就绪事件的双向链表 */struct list_head rdllist; // 就绪的文件描述符链表头unsigned int rdllist_count; // 就绪事件数量/* 监控的文件描述符红黑树 */struct rb_root_cached rbr; // 红黑树根节点,用于快速查找监控的fdwait_queue_head_t wq; // 等待队列,用于epoll_wait的阻塞唤醒struct file *file; // 关联的file指针(每个epoll实例对应一个文件)struct list_head ovflist; // 处理就绪事件溢出的临时链表atomic_t watched_fds; // 当前监控的文件描述符数量
};
epitem结构体
在epoll中,对于每一个事件都会有一个对应的epitem结构体,红黑树和就绪队列当中的节点分别是基于epitem结构中的 rbn 成员和 rdllink 成员的,epitem结构当中的成员 ffd 记录的是指定的文件描述符值,event 成员记录的就是该文件描述符对应的事件。
struct epitem {struct rb_node rbn; // 红黑树节点,用于挂载到 eventpoll.rbr 中struct list_head rdllink; // 就绪队列节点,用于挂载到 eventpoll.rdllist 中struct epoll_filefd ffd; // 被监控的文件描述符和文件指针(核心标识)struct eventpoll *ep; // 指向所属的 eventpoll 实例struct list_head pwqlist; // 关联的 poll 等待队列(用于底层文件事件通知)struct event_poll eppoll; // 包含事件回调函数(ep_poll_callback)/* 事件状态 */u32 event_events; // 用户关注的事件掩码(EPOLLIN/EPOLLOUT 等)u32 event_data; // 就绪的事件返回值(EPOLL_CTL_ADD 时传入的 data)atomic_t refcnt; // 引用计数,防止并发操作时被释放
};
- 对于epitem结构当中rbn成员来说,ffd与event的含义是,需要监视ffd上的event事件是否就绪。
- 对于epitem结构当中的rdlink成员来说,ffd与event的含义是,ffd上的event事件已经就绪了。
epoll机制
当某一进程调用 epoll_create 函数时,Linux内核会创建一个 eventpoll 结构体,也就是我们所说的epoll模型eventpoll 结构体当中的成员 rbr 和 rdlist 与epoll的使用方式密切相关。
如图所示
- rbr 是红黑树的根节点,这颗红黑树中存储着所有添加到epoll中的需要监视的事件。
- rdlist则指向的是一个双链表,该双链表是就绪队列,里面存放着将要通过epoll_wait返回给用户的满足条件的事件
- wq代表一个等待队列头(wait queue head),用于管理等待在某个资源上的进程或线程集合。具体来说,在eventpoll 结构体上下文中,wq 用于当没有事件就绪时,让调用 epoll_wait 的进程进入睡眠状态,直到有感兴趣的事件发生。当多个执行流想要同时访问同一个epoll模型时,就需要在该等待队列下进行等待。
- 接着用户调用 epoll_ctl 接口时,内核创建 epitem 并插入 eventpoll.rbr
,
对想要监控的文件描述符进行注册添加到红黑树中。 - 当数据包从网卡到达接收队列时,如果就绪会触发回调机制
-
-
回调机制:所有添加到红黑树当中的事件,都会与设备(网卡)驱动程序建立回调方法,这个回调方法在内核中叫 ep_poll_callback。
-
对于 select 和 poll 来说,操作系统在监视多个文件描述符上的事件是否就绪时,需要让操作系统主动对这多个文件描述符进行轮询检测,这一定会增加操作系统的负担。
-
而对于epoll来说,操作系统不需要主动进行事件的检测,当红黑树中监视的事件就绪时,会自动调用对应的回调函数,这个回调函数会负责将对应的epitem 添加到 eventpoll 结构体中的就绪列表 (rdllist) 中。这个列表包含了所有当前已就绪的事件,等待 epoll_wait() 调用返回给用户空间的应用程序。即让该就绪事件的节点挂载到 eventpoll.rdllist
-
eventpoll.rdllist 是一个双向链表,用于存储所有已经就绪的 epitem 。当 epoll_wait()被调用时,如果rdllist非空,则意味着有事件已经准备好,可以立即返回给用户空间;如果没有事件准备好,则epoll_wait() 将阻塞直到至少有一个事件进入就绪列表。
-
-
说明与注意:
- 红黑树是一种二叉搜索树,因此必须有键值key,而这里的文件描述符就天然的可以作为红黑树的key值。
- 调用 epoll_ctl 向红黑树当中新增节点时,如果设置了 EPOLLONESHOT 选项,当监听完这次事件后,如果还需要继续监听该文件描述符则需要重新将其添加到 epoll模型中,本质就是当设置了 EPOLLONESHOT 选项的事件就绪时,操作系统会自动将其从红黑树当中删除。
- 而如果调用 epoll_ctl 向红黑树当中新增节点时没有设置 EPOLLONESHOT,那么该节点插入红黑树后就会一直存在,除非用户调用 epoll_ctl 将该节点从红黑树当中删除。
- 只有添加到红黑树当中的事件才会与底层建立回调方法,因此只有当红黑树当中对应的事件就绪时,才会执行对应的回调方法将其添加到就绪队列当中。
- 当不断有监视的事件就绪时,会不断调用回调方法向就绪队列当中插入节点,而上层也会不断调用 epoll_wait 函数从就绪队列当中获取节点,这是典型的生产者消费者模型。
- 由于就绪队列可能会被多个执行流同时访问,因此必须要使用互斥锁对其进行保护,eventpoll结构当中的 lock 和 mtx 就是用于保护临界资源的,因此epoll本身是线程安全的。
- 回调函数的重要性:一旦某个被监视的文件描述符上的事件就绪,内核会自动通过回调机制将该事件添加到 eventpoll 的就绪队列 (rdlist) 中,而无需应用程序进行任何额外的操作或轮询。这种方式不仅提高了效率,也简化了异步I/O操作的编程模型。
4、epoll服务器
综上所述,epoll的使用流程为
- 调用epoll_create创建一个epoll模型。
- 调用epoll_ctl,将要监控的文件描述符进行注册。
- 调用epoll_wait,等待文件描述符就绪。
与之前的select接口和poll接口一样,我们依然在这里实现一个简单的epoll服务器,用来演示一下epoll的使用
代码:
//Socket.hpp#pragma once#include<iostream>#include<unistd.h>#include<sys/socket.h>#include<sys/types.h>#include<arpa/inet.h>#include<cstring>#include<cstdlib>//封装套接字的创建、绑定和监听
class Socket{
public:static int SocCreate()//创建套接字{int sockFd=socket(AF_INET,SOCK_STREAM,0);if(sockFd<0){std::cerr<<"socket error"<<std::endl;exit(2);}//确保服务器在关闭后立即重新启动,而不会因为地址仍在使用中(由于TIME_WAIT状态)而失败。int opt=1;//标志位,开启或者关闭setsockopt(sockFd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));return sockFd;}static void SocketBind(int sockFd,int port)//绑定套接字{struct sockaddr_in local;memset(&local,0,sizeof(local));local.sin_family=AF_INET;local.sin_port=htons(port);local.sin_addr.s_addr=INADDR_ANY;socklen_t len=sizeof(local);if(bind(sockFd,(struct sockaddr*)&local,len)){std::cerr<<"bind error"<<std::endl;exit(3);}}static void SocketListen(int sockFd,int backlog)//监听套接字{if(listen(sockFd,backlog)<0){std::cerr<<"listen error"<<std::endl;exit(4);}}};
// epoll_server.hpp封装epoll服务器
#pragma once
#include "Socket.hpp"
#include <sys/epoll.h>#define BACK_LOG 5
#define SIZE 256//设置的创建的模型大小
#define MAX_NUM 64//表示最多返回的事件的数量
class epollServer
{
public:
epollServer(int port): _port(port){}void InitepollServer() // 初始化{_LisSocFd = Socket::SocCreate();Socket::SocketBind(_LisSocFd, _port);Socket::SocketListen(_LisSocFd, BACK_LOG);_epfd=epoll_create(SIZE);//创建epoll模型if(_epfd<0){std::cerr<<"epoll_create error!"<<std::endl;exit(5);}}~epollServer(){if (_LisSocFd >= 0){close(_LisSocFd);}if(_epfd>=0){close(_epfd);}}void HandlerEvent(struct epoll_event revs[],int num){for(int i=0;i<num;i++){int fd=revs[i].data.fd;if(fd==_LisSocFd&&revs[i].events&EPOLLIN){struct sockaddr_in peer;memset(&peer, 0, sizeof(peer));socklen_t len = sizeof(peer);int sockFd=accept(_LisSocFd,(struct sockaddr*)&peer,&len);if(sockFd<0){std::cerr<<"accept error!"<<std::endl;continue;;}std::string peer_ip = inet_ntoa(peer.sin_addr);int peer_port = ntohs(peer.sin_port);std::cout << "get a new link[" << peer_ip << ":" << peer_port << "]" << std::endl;AddEvent(sockFd, EPOLLIN);//将取到的套接字添加到epoll模型中,并关心其读事件}else if(revs[i].events&EPOLLIN)//读事件就绪{char buffer[64];ssize_t s=recv(fd,buffer,sizeof(buffer)-1,0);if(s>0)//读取成功{buffer[s]='\0';std::cout<<"echo#"<<buffer<<std::endl;}else if(s==0)//对端连接关闭{std::cout<<"Client quit"<<std::endl;close(fd);DelEvent(fd);//将文件描述符从模型中删除}else{//读取错误std::cerr<<"recv error!"<<std::endl;close(fd);DelEvent(fd);}}}}void Run(){AddEvent(_LisSocFd, EPOLLIN);//添加监听套接字到epoll模型中,并关系它的读事件for(;;){struct epoll_event revs[MAX_NUM];//定义一个数组,用于存储epoll_wait返回的就绪事件表int num=epoll_wait(_epfd,revs,MAX_NUM,-1);//等待事件的发生,-1表示永久阻塞直至至少一个事件的发生if(num<0){std::cerr<<"epoll_wait error"<<std::endl;//发生错误continue;}else if(num==0){std::cout<<"timeout..."<<std::endl;//调用超时continue;}else{HandlerEvent(revs,num);}}}private:void AddEvent(int sockFd,uint32_t event)//添加文件描述符和事件到epoll模型中{struct epoll_event ev;//创建结构体,将文件描述符和想要被关注的事件添加到结构体中ev.events=event;ev.data.fd=sockFd;epoll_ctl(_epfd,EPOLL_CTL_ADD,sockFd,&ev);//添加该结构体到模型中}void DelEvent(int sockFd)//从epoll模型中删除文件描述符,不关心事件,所以事件设为nullptr{epoll_ctl(_epfd,EPOLL_CTL_DEL,sockFd,nullptr);}
private:int _port;//端口号int _LisSocFd;//监听套接字int _epfd;//epoll模型
};
//serverRun.cc
#include"epoll_server.hpp"
#include<string>static void Usage(std::string proc)
{std::cerr<<"Usage::"<<proc<<"proc"<<std::endl;
}
int main(int argc,char* argv[])
{if(argc!=2){Usage(argv[0]);exit(1);}int port =atoi(argv[1]);epollServer* svr=new epollServer(port);svr->InitepollServer();svr->Run();return 0;}
客户端连接前:
客户端连接后:
多客户端连接:
文件描述符使用情况:
使用 ls /proc/[pid]/fd 命令查看epoll服务器的文件描述符使用情况 ,可以看到文件描述符0、1、2是默认打开的,分别对应的是标准输入、标准输出和标准错误,3号文件描述符对应的是监听套接字,4号文件描述符对应的是服务器创建的epoll模型,5号和6号文件描述符对应的分别是正在访问服务器的两个客户端。
客户端退出:
5、epoll的优点
- 接口使用方便:虽然拆分成了三个函数,但是反而使用起来更方便高效。
- 数据拷贝轻量:只在新增监视事件的时候调用epoll_ctl将数据从用户拷贝到内核,而select和poll每次都需要重新将需要监视的事件从用户拷贝到内核。此外,调用epoll_wait获取就绪事件时,只会拷贝就绪的事件,不会进行不必要的拷贝操作。
- 事件回调机制:避免操作系统主动轮询检测事件就绪,而是采用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中。调用epoll_wait时直接访问就绪队列就知道哪些文件描述符已经就绪,检测是否有文件描述符就绪的时间复杂度是O (1),因为本质只需要判断就绪队列是否为空即可。
- 没有数量限制:监视的文件描述符数目无上限,只要内存允许,就可以一直向红黑树当中新增节点。
需要注意的是:
- 有人说epoll中使用了内存映射机制,内核可以直接将底层就绪队列通过mmap的方式映射到用户态,此时用户就可以直接读取到内核中就绪队列当中的数据,避免了内存拷贝的额外性能开销。
- 这种说法是错误的,实际操作系统并没有做任何映射机制,因为操作系统是不相信任何人的,操作系统不会让用户进程直接访问到内核的数据的,用户只能通过系统调用来获取内核的数据。
- 因此用户要获取内核当中的数据,势必还是需要将内核的数据拷贝到用户空间。
为什么说epoll是现在使用多路转接效率最高的接口
- 在使用select和poll时,都需要借助第三方数组来维护历史上的文件描述符以及需要监视的事件,这个第三方数组是由用户自己维护的,对该数组的增删改操作都需要用户自己来进行。
- 而使用epoll时,不需要用户自己维护所谓的第三方数组,epoll底层的红黑树就充当了这个第三方数组的功能,并且该红黑树的增删改操作都是由内核维护的,用户只需要调用epoll_ctl让内核对该红黑树进行对应的操作即可。
- 在使用多路转接接口时,数据流都有两个方向,一个是用户告知内核,一个是内核告知用户。select和poll将这两件事情都交给了同一个函数来完成,而epoll在接口层面上就将这两件事进行了分离,epoll通过调用epoll_ctl完成用户告知内核,通过调用epoll_wait完成内核告知用户。
6、epoll的工作模式(通知机制)
epoll提供了两种工作模式,分别是水平触发(Level Triggered, LT)和边缘触发(Edge Triggered, ET)。这两种模式决定了当文件描述符的状态发生变化时,epoll如何通知应用程序。
I、水平触发(Level Triggered, LT)
在水平触发模式下,只要文件描述符处于就绪状态(例如,可读或可写),每次调用 epoll_wait 都会返回该文件描述符,直到其状态变为非就绪。
epoll默认状态下就是LT工作模式。
- 由于在LT工作模式下,只要底层有事件就绪就会一直通知用户,因此当epoll检测到底层读事件就绪时,可以不立即进行处理,或者只处理一部分,因为只要底层数据没有处理完,下一次epoll还会通知用户事件就绪。
- select和poll其实就是工作是LT模式下的。
- 支持阻塞读写和非阻塞读写。
该适用于大多数标准的I/O操作,特别是不能保证一次性读取或写入所有数据时。
II、边缘触发(Edge Triggered, ET)
在边缘触发模式下,只有在文件描述符的状态从非就绪变为就绪时才会产生一次通知。这意味着即使文件描述符保持就绪状态(例如,有更多数据可读),也不会再次触发通知,除非又有新的数据到达使状态再次改变。
如果要将epoll改为ET工作模式,则需要在添加事件时设置 EPOLLET 选项。
- 由于在ET工作模式下,只有底层就绪事件无到有或由有到多发生变化的时候才会通知用户,因此当epoll检测到底层读事件就绪时,必须立即进行处理,而且必须全部处理完毕,因为有可能此后底层再也没有事件就绪,那么epoll就再也不会通知用户进行事件处理,此时没有处理完的数据就相当于丢失了。
- ET工作模式下epoll通知用户的次数一般比LT少,因此ET的性能一般比LT性能更高,Nginx就是默认采用ET模式使用epoll的。
- 只支持非阻塞的读写。
适用于高性能要求的应用程序,尤其是在高并发环境下,能够减少不必要的系统调用次数。
那么该模式下如何读写呢?
因为在ET工作模式下,只有底层就绪事件无到有或由有到多发生变化的时候才会通知用户,这就倒逼用户当读事件就绪时必须一次性将数据全部读取完毕,当写事件就绪时必须一次性将发送缓冲区写满,否则可能再也没有机会进行读写了,这也就是逼着让上层尽快把数据取走,这样在TCP底层就可以给发送方提供一个更大的窗口大小,可以让对方更新出更大的滑动窗口,进而提高底层的数据发送效率,可以更好地利用诸如TCP地延迟应答策略等!!!
这时候有没有联想到TCP中的 PSH 的作用?他就是使得底层的数据就绪事件再让上层知道,催促上层尽快拿走数据。
因此读数据时必须循环调用recv函数进行读取,写数据时必须循环调用send函数进行写入。
- 当底层读事件就绪时,循环调用recv函数进行读取,直到某次调用recv读取时,实际读取到的字节数小于期望读取的字节数,则说明本次底层数据已经读取完毕了。
- 但有可能最后一次调用recv读取时,刚好实际读取的字节数和期望读取的字节数相等,但此时底层数据也恰好读取完毕了,如果我们再调用recv函数进行读取,那么recv就会因为底层没有数据而被阻塞住。
- 而这里的阻塞是非常严重的,就比如我们这里写的服务器都是单进程的服务器,如果recv被阻塞住,并且此后该数据再也不就绪,那么就相当于我们的服务器挂掉了,因此在ET工作模式下循环调用recv函数进行读取时,必须将对应的文件描述符设置为非阻塞状态。
- 调用send函数写数据时也是同样的道理,需要循环调用send函数进行数据的写入,并且必须将对应的文件描述符设置为非阻塞状态。
Tips:ET工作模式下,recv和send操作的文件描述符必须设置为非阻塞状态,这是必须的,不是可选的。(面试题)
感谢阅读!