针对大量描述符的IO进行就绪事件监控的一种技术,
在某个描述符的某个IO事件就绪后告知进程,避免进程针对未就绪的描述符进行操作,
进而提高处理效率,以及避免可能出现的流程阻塞。
就绪事件:
描述符可读了,描述符可写了,描述符异常了。
思考:
TCP服务器-服务端会为每个客户端创建一个新的套接字,有各自的描述符句柄,
因为不知道哪个描述符有数据到来,有新连接到来,只能使用固定的流程进行操作,
这样会导致程序要么阻塞在recv接收某个客户端数据上,要么阻塞在获取新连接上,
而无法对有数据到来的描述符进行操作。
一个描述符的接收缓冲区中的数据大小大于低水位标记(一个基准判断值-默认1字节)
一个描述符的发送缓冲区中的剩余空间大小大于低水位标记(一个基准判断值-默认1字节)
一个描述符产生了异常(比如一个连接断开了,描述符被关闭了,描述符没有打开…)
针对大量描述符进行IO就绪事件监控。
(1) 用户定义一个指定事件的描述符集合(三种-可读,可写,异常)进行初始化;
接口:
struct fd_set{ __fd_mask__fds_bits[__FD_SETSIZE=1024 / __NFDBITS=64] }
这个结构体只有一个数组成员,被当做位图使用,拥有1024个比特位,取决于__FD_SETSIZE大小;
接口:
void FD_ZERO(fd_set *set); //初始化,清空集合
(2)将需要监控指定事件的描述符添加到指定集合中
(例如:对描述符监控可读事件,则将其添加到可读事件描述符集合中);
接口:
void FD_SET(int fd, fd_set *set);
//添加fd描述符到set集合中(其实就是把fd对应的比特位 置1);
(3)将集合中数据拷贝到内核中,开始监控
当某个描述符就绪了指定要监控的事件,或者监控超时了则监控返回;
接口:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
nfds:当前所有集合中最大的描述符+1。
readfds:可读事件集合。
writefds:可写事件集合。
exceptfds:异常事件集合。
struct timeval timeout {time_t tv_sec; time_t tv_usec;}:所设置的监控超时时间。
若为NULL,则表示永久阻塞(没有描述符就绪则一直等待);
若其中数据为0,则表示非阻塞(没有描述符就绪则立即返回)。
返回值:
返回当前就绪的描述符个数;
返回-1表示出错;
返回0则表示没有描述符就绪,超时了。
原理:
将集合中的数据拷贝到内核,先遍历一遍,有就绪的则直接遍历完毕后返回,
将所有描述符添加到内核的事件队列中,
当有描述符就绪或者超时进程被唤醒,再次遍历集合中所有的描述符,将没有就绪的移除掉。
(4)在监控返回之前,select会将事件描述符集合中未就绪的描述符从集合中删除掉
(这时候集合中的描述符都是就绪描述符);
(5)用户遍历所有监控的描述符,看哪个还在哪个集合中,则表示这个描述符就绪了什么事件,进而进行对应的操作。
接口:
int FD_ISSET(int fd, fd_set *set); //判断fd描述符是否在set集合中
(6)如果不想监控某个描述符,则可以移除监控(把描述符从监控集合中移除掉)
接口:
void FD_CLR(int fd, fd_set *set);
#include
#include
#include
#include
#include
int main()
{
//对标准输入进行可读事件监控,标准输入有数据了再读取,否则不动。
fd_set readfds;
FD_ZERD(&readfds);//初始化清空
int maxfd = 0;//标准输出描述符
while (1){
struct timeval tv;
tv.tv_sec = 3;
tv.tv_usec = 0;
FD_SET(maxfd, &readfds);//添加描述符到集合中,每次都要重新添加。
//select (maxfd+1, readfds, writefds, exceptfds, timeout)
int ret = select(maxfd + 1, &readfds, NULL, NULL, &tv);//select会重置tv为0;select会删除集合中未就绪的描述符
if (ret < 0){
perror("select error");
return -1;
}
else if (ret ==0){
printf("select timeout!\n");
continue;
}
for (int i = 0; i <= maxfd; i++){
if (!FD_ISSET(i, &readfds)){
continue;//不在集合中,就表示没有就绪对应事件
}
//else if (!FD_ISSET(i, &writefds)){}....
printf("描述符%d就绪了可读事件!\n", i);
char buf[1024] = { 0 };
read(i, buf, 1023);
printf("buf:[%s]\n", buf);
}
}
return 0;
}
将其应用在TCP服务器程序中,针对所有的描述符进行可读事件监控,
如果有数据了则进行读取操作处理;
封装的select类所实例化的每一个对象都可以认为是一个监控对象,
可以向其中添加需要监控的描述符。
class Select {
private:
int max_fd; //当前集合中最大的描述符
fd_set rfds; //备份所有已经添加过的描述符集合
public:
Select ();
bool Add(TcpSocket &sock); //添加监控
bool Del(TcpSocket *sock); //移除监控
bool Wait(std::vector <TcpSocket> *rarry); //开始监控,返回就绪的套接字数组
}
优点:
缺点:
#include
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
(1)用户定义一个IO就绪事件结构体数组
struct pollfd { int fd; short events; short revents;}
fd:要监控的描述符;
events:对应fd描述符想要监控的事件;
events:POLLIN—可读 、 POLLOUT—可写。
revents:监控返回后描述符实际就绪的事件。
(2)向事件结构体数组中,添加需要监控的描述符以及对应的事件信息。
(3)调用监控接口,将数据拷贝到内核,开始监控,当监控超时或者与描述符就绪了对应事件,
则调用返回。
(4)调用返回前监控会将每个事件结构体中revents成员进行置位,置为实际就绪的事件
(没有就绪则置0)。
(5)当调用返回后,则遍历事件结构体数据,就能确定哪个描述符就绪了哪个事件,进而可以进行对应的操作。
接口:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
fds:事件结构体数组的首元素地址。
nfds:数组中有效元素个数。
timeout:监控超时时间,单位是毫秒,
-1表示阻塞监控,没有就绪则一直等待;
0表示非阻塞,没有就绪也会直接返回。
返回值:
返回-1则出错;
返回0则表示监控超时;
返回值大于0表示就绪的事件个数。
#include
#include
#include
#include
#include
int main()
{
int fd_count = 0;
struct pollfd fd_arry[10];
fd_arry[0].fd = 0;//对0号描述符进行监控
fd_arry[0].events = POLLIN;// |POLLOUT;监控可读事件
fd_count++;
while (1){
int ret = poll(fd_arry, fd_count, 3000);
if (ret < 0){
perror("poll error");
return -1;
}
else if (ret == 0){
printf("poll timeout\n");
continue;
}
for (int i = 0; i < fd_count; i++){
//监控调用返回后,成员revents中保存的是实际就绪的事件
if (fd_arry[i].revents & POLLIN) {
printf("%d描述符就绪了可读事件\n", fd_arry[i].fd);
char tmp[1024] = { 0 };
read(fd_arry[i].fd, tmp, 1023);
printf("buf:[%s]\n", tmp);
}
//else if (fd_arry[i].revent & POLLOUT)
}
}
return 0;
}
优点:
缺点:
号称linux2.6版本之后最好用的多路转接模型。
(1)在内核中创建一个epoll句柄
struct eventpoll{ rdllist-双向链表; rbr-红黑树};
(2)向内核的epoll句柄中添加需要监控的描述符以及对应的事件结构
(添加到内核句柄的红黑树成员中)。
struct epoll_event{
uint32_t events; union epoll_data {void *ptr; int fd; } data;}
events:监控前填充描述符需要监控的事件,
以及监控返回后修改实际就绪的事件; EPOLLIN | EPOLLOUT
data:是一个联合体,其中有一个成员fd,这个通常填充我们所监控的描述符。
(3)开始监控,等到监控超时或者有描述符就绪了,则监控返回,返回的是实际就绪了指定事件的描述符对应的事件结构。
(4)只需要根据返回的事件结构,对对应的描述符进行对应事件的操作即可。
(1)
int epoll_create(int size); //在内核中,创建epoll句柄
size:最早用于确定所要监控的最大的描述符数量上限-在linux2.6.8之后被忽略,但是必须大于0。
返回值:成功返回epoll描述符句柄; 失败返回-1。
(2)
int epoll_ctl(int epfd, int cmd, int fd, struct epoll_event *ev);
epfd:epoll_create返回的句柄描述符—通过他找到内核中对应的epoll句柄结构。
cmd:EPOLL_CTL_ADD / EPOLL_CTL_MOD / EPOLL_CTL_DEL。
fd:要监控的描述符。
ev:这个描述符对应事件结构。
返回值:成功返回0;失败返回-1。
(3)
int epoll_wait(int epfd, struct epoll_event *evs, int max_events, int timeout)
epfd:epoll_create返回的句柄描述符—通过他找到内核中对应的epoll句柄结构。
evs:是一个事件结构数组,用于接收就绪描述符的对应事件结构。
max_events:是evs数组的大小-表示要获取的事件个数,防止就绪事件过多,但是传入的空间不够,
导致的操作越界。
timeout:监控超时时间,单位是毫秒;-1表示阻塞监控,0表示非阻塞。
返回值:
出错返回-1;
返回0表示监控超时;
返回大于0,则表示就绪的事件个数(evs中有效的数据个数)。
#include
#include
#include
#include
#include
#include "tcpsocket.hpp"
class Epoll{
private:
int _epfd;
public:
Epoll() :_epfd(-1){
//创建epoll句柄
_epoll = epoll_create(1);
if (_epfd < 0){
perror("epoll create error");
exit(-1);
}
}
bool Add(TcpSocket &sock){
//epoll_ctl(epoll句柄,操作类型,描述符,事件结构)
int fd = sock.GetFd();
struct epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLIN;//可读事件
int ret = epoll_ctl(_epfd, EPOLL_CTL_ADD, fd, &ev);
if (ret < 0){
perror("epoll add error");
return false;
}
return true;
}
bool Del(TcpSocket &sock){
int fd = sock.GetFd();
int ret = epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, NULL);
if (ret < 0){
perror("epoll del error");
return false;
}
return true;
}
bool Wait(std::vector<TcpSocket> *rarry, int timeout = 3000){
//epoll_wait(句柄,事件结构数组,数组大小,超时时间)
rarry->clear();
struct epoll_evebt evs[10];
int ret = epoll_wait(_epfd, evs, 10, timeout);
if (ret < 0){
perror("epoll_wait error");
return false;
}
else if (ret == 0){
printf("epoll timeout\n");
return true;
}
for (int i = 0; i < ret; i++){
if (evs[i].events & EPOLLIN) {
TcpSocket sock;
sock.SetFd(evs[i].data.fd);
rarry->push_back(sock);
}
}
return true;
}
};
(1)EPOLLLT-默认触发方式-水平触:
(2)EPOLLET-边缘触发
因为边缘触发是每次数据到来,才会触发一次事件,
这就导致我们必须在一次事件触发中把所有的数据都读取出去,
否则缓冲区中的剩余数据不会引起二次事件触发,也就不会再次获取数据,
相当于数据就没有处理完。
然而因为我们不知道数据有多少,所以只能循环读取,
但是问题是如果缓冲区没有数据了继续读取就会阻塞;
这时候,我们针对边缘触发,就必须使用非阻塞操作(当没有数据的时候recv也会立即报错返回)。
(1)非阻塞操作
ssize_t recv(int fd, char *buf, int len, int flag == MSG_DONTWAIT)
int fcntl(int fd, int cmd, … /* arg */ );
cmd:F_GETFL-获取描述符属性;
F_SETFL-设置描述符属性。
arg:要设置的属性。
int flag = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, O_NONBLOCK | flag):在原有属性基础上新增非阻塞属性。
边缘触发,在读取的时候只有新数据到来才会触发事件,考虑这么一种应用场景:
我们想要读取一个完整的数据,但是现在缓冲区中的数据不完整,如果我们不把他读取出来,意味着这个数据会在水平触发下一直触发事件,但是操作获取数据不完整,这种情况下就适用于边缘触发,有新数据到来的时候再看一下数据是否完整。
recv实际上是有一种操作,获取缓冲区中的数据,但是不移除缓冲区中的数据,
其实就是看一下缓冲区有什么数据。
常规情况下,recv获取数据会伴随将缓冲区中获取的数据也删除掉:
recv(int fd, char *buf, int len, int flag == MSG_PEEK);只获取不删除,相当于查看缓冲区数据。
边缘触发,归根结底是为了避免不需要的事件触发,所导致循环操作的效率降低。
优点:
缺点:
效率会随着描述符的增多而降低,流程select相较复杂;
但是如果是单描述符的监控,或者单描述符操作的超时控制非常适用。
性能不会随着描述符的增多而降低, 适用于针对大量描述符监控的场景,
而不太适用于单个描述符的超时操作;
因为它需要在内核中创建句柄,进行各种操作,不用了还需要销毁。
多路转接模型,要么适用于单个描述符的超时控制,要么针对大量描述符的事件监控;
但是多路转接模型在大量描述符的时候,
只适用于有大量描述符,但是同一时间只有少量就绪的场景。
因为多路转接模型,是一种单执行流的并发轮询操作,如果同一时间就绪的描述符过多,会导致前边的处理完毕后,后边的才能得到处理,这时候有可能有些描述符已经等待超时。
所以通常我们是多路转接模型搭配线程池一起使用,
使用多路转接模型进行事件监控,有就绪则将就绪的描述符抛入线程池中进行处理,
这样还能避免描述符没有数据空占线程的场景。
扩展:epoll惊群问题。