一、功能
针对大量描述符进行IO事件监控,让进程可以只针对就绪的描述符进行IO操作,提高IO效率,避免针对未就绪描述符操作而导致的效率低或阻塞。
引入:
问题:
以前的tcp服务器,会为每个客户端创建一个套接字,用于与指定客户端进行通信,但是因为不清楚哪个描述符什么时候会有数据到来,因此流程固定:先获取新建连接,然后收发数据,这样就有可能导致程序阻塞。
解决方法1:
多执行流(多线程or多进程),为每个客户端都创建一个单独的执行流负责通信,这样即使一个执行流阻塞,也不会影响其他客户端通信。
缺陷:
多执行流方式简单粗暴,但是占用资源较多。
解决方法2:使用多路转接模型
针对大量描述符进行IO事件监控,可以让进程知道哪个描述符有数据到来,即哪个就绪,就操作哪个。
二、应用场景
多路转接模型:是监控大量描述符,然后针对就绪描述符逐个进行处理。
(1)有大量描述符需要进行IO就绪事件监控,但是同一时间只有少量活跃的场景。
如果有大量描述符进行监控,并且同一时间活跃数量非常多,会导致排在后面的描述符不能及时得到处理。(可搭配多执行流处理解决)
(2)针对单个描述符有收发数据的超时控制的场景。
三、多路转接模型的实现
1.select模型
1.1操作流程
(1)针对不同的IO事件(可读事件,可写事件,异常事件),定义不同的描述符集合,若需要对哪个描述符监控哪个事件,就把这个描述符添加到对应的集合中。
(2)调用监控接口,将集合拷贝到内核中,进行轮询遍历进行监控;当有描述符就绪,或监控超时都没有就绪,则监控返回。
在内核中,先遍历一遍集合,没有描述符就绪,则将描述符添加到内核的IO事件队列,然后等待,被唤醒后,进程会再次遍历集合,判断哪个描述符是否就绪了指定的事件。
注意:监控调用返回之前,会将集合中所有没有就绪的描述符,从集合内移除(即监控调用返回后,集合中只含有就绪的描述符)。
(3)调用返回后,这时判断哪个描述符还在哪个事件的描述符集合中,就表示哪个描述符就绪了哪个事件,从而进行操作。
1.2相关接口
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
nfds:几种事件的描述符集合中,最大的描述符的值+1;
readfds:可读事件的描述符集合;
writefds:可写事件的描述符集合;
exceptfds:异常事件的描述符集合;
timeout:struct timeval{ uint32_t tv_usec; uint32_t tv_sec};监控超时等待时间;
返回值:
成功,返回就绪的事件个数;出错,返回-1;没有描述符就绪,返回0。
辅助接口:
(1)从set集合中移除fd描述符
void FD_CLR(int fd, fd_set *set);
(2)判断fd描述符是否在集合set中
int FD_ISSET(int fd, fd_set *set);
返回值:存在,返回true;不存在,返回false。
(3)向set集合中添加fd描述符
void FD_SET(int fd, fd_set *set);
(4)初始化清空set集合
void FD_ZREO(fd_set *set);
1.3示例
对标准输入进行可读事件的监控,在描述符可读时从标准输入读取数据。
#include<stdio.h>
#include<unistd.h>
#include<sys/select.h>
#include<time.h>
//#include<sys/socket.h>
int main() {
fd_set rfds;
//1.初始化集合
FD_ZERO(&rfds);
while (1) {
struct timeval tv;
tv.tv_usec = 0;
tv.tv_sec = 3;//设置超时时间为3s
//2.将标准输入描述符添加到集合中
FD_SET(0, &rfds);//0是标准输入描述符
int max_fd = 0;
//因为select会修改rfds集合(返回前会将未就绪的描述符剔除),所以需要在循环内每次添加
int ret = select(max_fd + 1, &rfds, NULL, NULL, &tv);
if (ret < 0) {
perror("select error!\n");
usleep(1000);
continue;
}
else if (ret == 0) {//没有描述符就绪
printf("wait timeout!\n");
usleep(1000);
continue;
}
//有描述符就绪
for (int i = 0; i <= max_fd; ++i) {
if (FD_ISSET(i, &rfds)) {//若i在集合中,说明描述符i就绪
char buf[1024] = {0};
int res = read(i, buf, 1023);
if (res < 0) {//接收数据出错
perror("recv error!\n");
FD_CLR(i, &rfds);
return -1;
}
printf("描述符%d就绪, 读取数据为: %s\n", i, buf);
}
}
}
return 0;
}
实现效果:
1.4常见使用方式
封装一个Select类,实例化的每个Select对象都是一个多路转接对象,能够完成对大量描述符的监控,向外界返回就绪的描述符数组。
class Select{
private:
fd_set _rfds;//可读事件的描述符集合的备份,每次监控都是从该集合复制一份出来进行监控
int _max_fd;
public:
Select(){//针对成员变量进行初始化}
bool Add(const TCPsocket& sock);//将sock中的描述符fd,添加到rfds可读事件描述符集合中
bool Del(const TCPsocket& sock);//将sock中的描述符fd,从rfds可读事件描述符集合中移除
bool Wait(std::vector<TCPsocket>& array);//开始监控,返回就绪的描述符的数组
};
1.5优缺点
优点:
遵循posix标准,跨平台移植性好(在其他平台下也可以使用select实现多路转接)。
缺点:
(1)所能监控的描述符数量有上限。
select的监控集合是一个数组,当作位图使用,因此能监控多少描述符取决于比特位数,_FD_SETSIZE=1024。
(2)select监控原理,需要进行多次轮询遍历集合,会导致监控的描述符越多,效率就越低。
(3)因为select会修改监控集合,所以每次监控都需要重新添加描述符到集合中。
(4)select返回的是就绪的描述符集合,因此需要用户遍历集合查看哪个描述符还在集合中,操作复杂。
2.poll模型
poll模型:也是针对大量描述符进行监控,但是poll的监控是为每个描述符设置了一个事件结构体。
2.1操作流程
(1)定义要监控的描述事件结构体数组,向数组中添加需要监控的描述符及时间信息。
(2)调用监控接口开始监控,将需要监控的数据拷贝到内核中进行监控(监控原理也是多次轮询遍历)。
注意:监控调用返回前,会将描述符实际就绪事件,填充到结构体的revents成员中,若没有就绪事件则置为0。
(3)监控接口调用完毕后,遍历事件结构体数组,通过每个元素的revents成员确定描述符就绪了什么事件,进行对应操作。
2.2相关接口
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
fds:事件结构体的首地址;
nfds:数组中有效元素的个数;
timeout:监控超时时间,以毫秒为单位;
返回值:
返回实际就绪的事件个数;返回0,表示监控超时;返回值小于零,表示出错。
事件结构体pollfd:
struct pollfd{
int fd;//要监控的事件描述符
short events;//针对这个描述符要监控的事件
//POLLIN-可读、POLLOUT-可写
short revents;//监控调用返回后,这个描述符实际就绪的事件
};
2.3示例
利用poll模型,监控标准输入的可读事件:
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<poll.h>
int main() {
struct pollfd pfds[10];
int poll_count = 0;
pfds[0].fd = 0;//设置要监控的描述符是标准输入
pfds[0].events = POLLIN;//针对标准输入要监控的是可读事件
poll_count++;
while (1) {
int ret = poll(pfds, 1, 3000);
if (ret < 0) {
perror("poll error!");
usleep(1000);
continue;
}
else if (ret == 0) {
printf("poll timeout!\n");
usleep(1000);
continue;
}
for (int i = 0; i < poll_count; ++i) {
if (pfds[i].revents & POLLIN) {//就绪了可读事件
char buf[1024] = {0};
read(pfds[i].fd, buf, 1023);
printf("描述符%d就绪,读取数据为:%s\n", pfds[i].fd, buf);
}
else if (pfds[i].revents & POLLOUT) {
//就绪的是可写事件,对应操作
}
}
}
return 0;
}
实现效果:
2.4优缺点
优点:
(1)相较于select模型操作更加简单。
(2)所能监控的描述符数量没有上限。
缺点:
(1)无法跨平台。
(2)监控原理涉及多次轮询遍历,因此效率也会随着监控的描述符数量增加而降低。
3.epoll模型
epoll模型:针对大量描述符进行事件监控(被认为是Linux2.6以后最好用的多路转接模型)。
3.1操作流程
(1)创建epoll句柄
在内核中会创建struct eventpoll,含有两个重要成员:rbr(红黑树)和rdllist(双向链表)。
(2)向epoll添加监控
组织对应事件结构体,添加到epoll红黑树中。
(3)开始监控
注意:
①epoll的监控默认是一个异步阻塞操作。
②epoll开始监控,实际上是告诉系统可以开始监控了,监控过程由系统完成。
③系统在进行监控的时候为每个描述符都定义了一个回调函数,回调函数的功能是:一旦描述符就绪了指定事件,就把描述符对应的事件结构体信息添加到rdllist双向链表中。
④我们的进程则只需要根据rdllist双向链表是否为空,来判断是否有描述符就绪。
3.2相关接口
(1)创建句柄
int epoll_create(int size);
size:所能监控的最大描述符数量,但是在Linux2.6.8之后被忽略(但是必须大于0)。
返回值:
成功,返回epoll句柄-描述符。
注意:在内核中会创建struct eventpoll结构体
(2)添加监控
int epoll_ctl(int epfd, int cmd, int fd, struct epoll_event *ev);
epfd:epoll_create返回的操作句柄;
cmd:要进行的操作:EPOLL_CTL_ADD-添加、EPOLL_CTL_DEL-移除、EPOLL_CTL_MOD;
fd:要操作的描述符;
ev:要操作的描述符所对应的事件结构体;
返回值:
成功,返回0;失败返回-1。
(3)开始监控
int epoll_wait(int epfd, struct epoll_event *evs, int maxevents, int timeout);
epfds:epoll操作句柄;
evs:事件结构体的数组首地址,用于存放就绪的事件信息;
maxevents:数组的空间大小(或者说元素个数),防止就绪事件过多,数组空间不够用,因此做的获取就绪事件个数限制。
timeout:监控的超时时间,一毫秒为单位;
返回值:
成功,返回就绪的事件个数;出错,返回-1;超时,返回0。
注意:在调用返回后,evs数组里保存的都是就绪的描述符对应的事件结构体,返回值就是元素的个数,用户就可以直接针对事件结构体中的evs[i].data.fd进行events事件操作。
事件结构体epoll_event:
struct epoll_event{
uint32_t events;//要监控的事件,以及监控返回后实际就绪的事件
//EPOLLIN-可读、EPOLLOUT-可写
epoll_data_t data;//用户数据变量
};
typedef union epoll_data{
void *ptr;
int fd;
}epoll_data_t;
3.3常见使用方式
封装Epoll类,实例化的对象可以实现对大量描述符的监控,并返回就绪的描述符。
class Epoll{
private:
int _epfd;///epoll操作句柄
public:
Epoll(){}
bool Add(TCPsocket& sock);//对sock描述符添加epoll监控
bool Del(TCPsocket& sock);//移除对sock描述符的监控
bool Wait(std::vector<TCPsocket> *array, int timeout = 3000);//开始监控
};
示例:
#include "socket_tcp.hpp"
#include <vector>
#include <sys/epoll.h>
#include <cstdlib>
#define EPOLL_MAX 10
class Epoll{
private:
int _epfd;///epoll操作句柄
public:
Epoll():_epfd(-1) {
_epfd = epoll_create(1);
if (_epfd < 0) {
perror("epoll error!");
exit(0);
}
}
bool Add(TCPsocket& sock) {//对sock描述符添加epoll监控
struct epoll_event ev;
ev.events = EPOLLIN;//对可读事件进行监控
ev.data.fd = sock.GetFd();
int ret = epoll_ctl(_epfd, EPOLL_CTL_ADD, sock.GetFd(), &ev);
if (ret < 0) {
perror("epoll_ctl error!");
return false;
}
return true;
}
bool Del(TCPsocket& sock){//移除对sock描述符的监控
int ret = epoll_ctl(_epfd, EPOLL_CTL_DEL, sock.GetFd(), NULL);
if (ret < 0) {
perror("epoll_ctl error!");
return false;
}
return true;
}
bool Wait(std::vector<TCPsocket> *array, int timeout = 3000){//开始监控
struct epoll_event evs[EPOLL_MAX];
int ret = epoll_wait(_epfd, evs, EPOLL_MAX, timeout);
if (ret < 0) {
perror("epoll_wait error!");
return false;
}
else if (ret == 0) {
printf("epoll timeout!\n");
return false;
}
//返回ret个就绪的描述符对应的事件结构体,保存在evs中
for (int i = 0; i < ret; ++i) {
if (evs[i].events & EPOLLIN) {//只需要可读事件就绪的描述符
TCPsocket sock;
sock.SetFd(evs[i].data.fd);
array -> push_back(sock);
}
}
return true;
}
};
实现效果:
3.4epoll的事件触发方式
(一)水平触发(默认的触发方式)
触发特性:
可读事件:只要接收缓冲区中有数据,就会触发事件。
可写事件:只要发送缓冲区中有剩余空间,就会触发事件。
(二)边缘触发(EPOLLET)
触发特性:
可读事件:只有新数据到来,才会触发事件,若没有新数据到来,无论缓冲区中有没有数据,都不会二次触发事件。
可写事件:发送缓冲区剩余空间大小从无到有,才会触发事件。
作用:为了让程序员一次性将请求处理完毕,减少处理次数,以此提高效率(但实际上提高并不明显)。
问题:这么才能一次性将数据处理完?
通常都是循环读取数据,直到缓冲区中没有数据为止。
注意:但是如果循环读取,缓冲区中没有数据时,recv就会阻塞导致程序卡死,因此边缘触发方式下,通常需要将描述符设置为非阻塞,让描述符的操作都成为非阻塞操作。这样没有数据时,读取则会报错返回。
属性相关接口:
int fcntl(int fd, int cmd, int arg……);
(1)获取描述符属性
int flag = fcntl(fd, F_GETFL, 9);
(2)新增非阻塞属性
fcntl(fd, F_SETFL, flag | O_NONBLOCK);
注意:设置非阻塞属性,是在原先的基础上新增非阻塞属性。且描述符为非阻塞属性,其相关操作也就都是非阻塞操作。
边缘触发适用场景:
(1)想要尽量一次性处理所有请求,减少事件触发次数提高效率。
(2)避免水平触发模式下,因为半条数据没有进行处理,不断触发事件的情况。即缓冲区中数据不完整,想要等到新数据到来,再去取出完整数据进行处理的场景。
3.5优缺点
缺点:
跨平台移植性差。
优点
(1)所能监控的描述符数量没有上限限制。
(2)监控的原理是异步阻塞,让系统进行监控。系统为事件做了回调函数,将就绪事件添加到就绪链表,进程只需要判断就绪链表是否为空,就可以确定是否有描述符就绪,其中不需要轮询遍历操作,因此性能不会随着描述符增多而下降。
(3)直接返回就绪的事件信息,可以直接对就绪的描述符进行指定事件的操作,不需要遍历判断哪个描述符就绪。
select&poll相较于epoll的优点:
虽然针对大量描述符的监控性能没有epoll高,但是在单个描述符的超时控制上,select和poll操作更加轻便。
注:自了解epoll惊群问题。