多路转接IO模型:多路转接&多路复用

IO模型:多路转接&多路复用

  • 一、多路转接IO模型
    • (一)作用
    • (二)IO就绪事件
      • 1.可读
      • 2.可写
      • 3.异常
  • 二、技术实现
    • (一)select模型
      • 1.select操作流程
      • 2.Linux下的select模型简单实现
      • 3.封装一个select类
      • 4.select模型优缺点总结
    • (二)poll模型
      • 1.poll操作流程
      • 2.Linux下的poll模型简单实现
      • 3.poll优缺点总结
    • (三)epoll模型
      • 1.epoll操作流程
      • 2.接口认识
      • 3.Linux下的epoll模型简单实现
      • 4.epoll的事件触发方式
      • 5.边缘触发所带来的其他影响
      • 6.epoll优缺点总结
    • (四)多路转接模型中的对比
      • 1.select与poll
      • 2.epoll
      • 3.多路转接模型的总体适用场景


一、多路转接IO模型

(一)作用

针对大量描述符的IO进行就绪事件监控的一种技术
在某个描述符的某个IO事件就绪后告知进程,避免进程针对未就绪的描述符进行操作,
进而提高处理效率,以及避免可能出现的流程阻塞。

就绪事件

描述符可读了,描述符可写了,描述符异常了。

思考:
TCP服务器-服务端会为每个客户端创建一个新的套接字,有各自的描述符句柄,
因为不知道哪个描述符有数据到来,有新连接到来,只能使用固定的流程进行操作,
这样会导致程序要么阻塞在recv接收某个客户端数据上,要么阻塞在获取新连接上,
而无法对有数据到来的描述符进行操作。

(二)IO就绪事件

1.可读

一个描述符的接收缓冲区中的数据大小大于低水位标记(一个基准判断值-默认1字节)

2.可写

一个描述符的发送缓冲区中的剩余空间大小大于低水位标记(一个基准判断值-默认1字节)

3.异常

一个描述符产生了异常(比如一个连接断开了,描述符被关闭了,描述符没有打开…)

二、技术实现

(一)select模型

针对大量描述符进行IO就绪事件监控。

1.select操作流程

(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);

2.Linux下的select模型简单实现

#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;
}

3.封装一个select类

将其应用在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); //开始监控,返回就绪的套接字数组
}

4.select模型优缺点总结

优点:

  1. 遵循posix标准,跨平台移植性良好。

缺点:

  1. select所能监控的描述符有数量上限,上限取决于宏__FD_SETSIZE,默认是1024;
  2. select因为每次监控都会修改超时时间,以及描述符集合,
    因此每次监控都需要重新设置(大量描述符监控时较为麻烦);
  3. select每次监控都需要重新把数据拷贝到内核中,并且监控原理需要多次遍历集合,
    因此描述符越多,效率越低;
  4. select只是返回了就绪的描述符集合,仍然需要用户判断哪个描述符在哪个集合中才能确定那个描述符就绪了哪个事件。

(二)poll模型

#include 
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

1.poll操作流程

(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表示就绪的事件个数。

2.Linux下的poll模型简单实现

#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;
}

3.poll优缺点总结

优点:

  1. 所能监控的描述符数量没有上限限制。
  2. 使用事件结构体实现监控,简化了select多种集合以及每次重新添加描述符的缺点,就是说操作流程更加简单。

缺点:

  1. 跨平台移植性没有select好。
  2. 大量描述符监控上性能没有epoll好;
    (因为监控原理与select是一样的,多次遍历,性能会随着描述符增多而降低)。
  3. 依然需要在监控调用返回后,遍历数组才能知道哪个描述符就绪了哪个事件。

(三)epoll模型

号称linux2.6版本之后最好用的多路转接模型。

1.epoll操作流程

(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)只需要根据返回的事件结构,对对应的描述符进行对应事件的操作即可。
多路转接IO模型:多路转接&多路复用_第1张图片

  • 根据evs[i].events确定描述符就绪了什么事件;
  • 根据evs[i].data.fd确定就绪的描述符。

2.接口认识

(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中有效的数据个数)。

3.Linux下的epoll模型简单实现

#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;
	}
};

4.epoll的事件触发方式

(1)EPOLLLT-默认触发方式-水平触:

  • 可读:接收缓冲区中数据大小 大于 低水位标记。
  • 可写:发送缓冲区中剩余空间大小 大于 低水位标记。

(2)EPOLLET-边缘触发

  • 可读:缓冲区中每当有新数据到来时才会触发一次事件。
    (如果没有新数据到来,就算缓冲区中有数据也不会触发事件)
  • 可写:剩余空间从无到有的时候才会触发一次事件。

5.边缘触发所带来的其他影响

因为边缘触发是每次数据到来,才会触发一次事件,
这就导致我们必须在一次事件触发中把所有的数据都读取出去,
否则缓冲区中的剩余数据不会引起二次事件触发,也就不会再次获取数据,
相当于数据就没有处理完。

然而因为我们不知道数据有多少,所以只能循环读取,
但是问题是如果缓冲区没有数据了继续读取就会阻塞;
这时候,我们针对边缘触发,就必须使用非阻塞操作(当没有数据的时候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);只获取不删除,相当于查看缓冲区数据。

边缘触发,归根结底是为了避免不需要的事件触发,所导致循环操作的效率降低。

6.epoll优缺点总结

优点:

  1. 监控描述符数量没有上限。
  2. 监控原理是异步操作,监控由内核完成,进程只需要判断就绪链表是否为空即可,效率不会随着描述符的增多而下降。
  3. 直接返回的全是就绪的描述符事件信息,可以直接针对就绪的描述符进行操作,没有空遍历。

缺点:

  1. 跨平台移植性较差。

(四)多路转接模型中的对比

1.select与poll

效率会随着描述符的增多而降低,流程select相较复杂;
但是如果是单描述符的监控,或者单描述符操作的超时控制非常适用。

2.epoll

性能不会随着描述符的增多而降低, 适用于针对大量描述符监控的场景,
而不太适用于单个描述符的超时操作;
因为它需要在内核中创建句柄,进行各种操作,不用了还需要销毁。

3.多路转接模型的总体适用场景

多路转接模型,要么适用于单个描述符的超时控制,要么针对大量描述符的事件监控;

但是多路转接模型在大量描述符的时候,
只适用于有大量描述符,但是同一时间只有少量就绪的场景。

因为多路转接模型,是一种单执行流的并发轮询操作,如果同一时间就绪的描述符过多,会导致前边的处理完毕后,后边的才能得到处理,这时候有可能有些描述符已经等待超时。

所以通常我们是多路转接模型搭配线程池一起使用
使用多路转接模型进行事件监控,有就绪则将就绪的描述符抛入线程池中进行处理,
这样还能避免描述符没有数据空占线程的场景。

扩展:epoll惊群问题。

你可能感兴趣的:(服务器,网络,数据库,数据仓库,linux)