IO多路复用之Select、Poll、Epoll详解

一.IO多路复用简介

unxi有五种IO标准

  • 阻塞式IO
  • 非阻塞式IO
  • IO复用
  • 信号驱动IO
  • 异步IO

当多种事件同时发生的时候

阻塞IO:同一时刻只能处理一个事件,多事件使用多进程多线程,耗费内存资源。但是不必时间片轮训,节约CPU宝贵资源

非阻塞IO:提高程序执行效率。不节约CPU资源。节约内存资源

IO多路复用:select poll epoll

二.什么是I/O多路转接技术

构建一张文件描述符列表,将要监听的文件描述符添加至该列表。select poll epoll会一直阻塞,当文件描述符发生变化的时候 该函数返回。

注:文件描述符的检测是通过内核检测的

三.select函数 

Select函数的文件描述符表(32位:1024  64位:2048):

0

1

2

3

4

5

6

...

1021

1022

1023

Select函数的fd_set:

文件描述符

0

1

2

3

4

5

6

...

1021

1022

1023

fd_set

1

当某个描述符发生变化的时候,fd_set列表对应的位变成1.其余位清0.所以在构建列表的时候需要进行备份

这是把fd_set相关的源代码汇总在一起。这样应该知道fd_set的实现详细原理
#define FDSET_LONGS   (FD_SETSIZE/NFDBITS)  1024/32=32   1024/32   1024/64
define FD_SETSIZE      1024
typedef unsigned long   fd_mask;         //long 对于32位机4字节32位   64位机 8个字节能产生64个位
#define NBBY    8                     /* number of bits in a byte */
#define NFDBITS  (sizeof(fd_mask) * NBBY)  /* bits per mask */    4/8    *8  32     64   
typedef struct fd_set {
        fd_mask fds_bits[FDSET_LONGS];   //去用位表达文件描述符 32个long类型数据 32*321024个位
} fd_set;

相关API:

  1. FD_ZERO(fd_set *set);用来清除描述词组set的全部位(在初始化时用到以免里面有垃圾值)------初始化
  2. FD_SET(int fd,fd_set *set); 将想要监听的套接字添加至文件列表
  3. FD_ISSET(int fd,fd_set *set); 用来测试描述词组set中相关fd的位是否为真(遍历检测函数)
  4. int select(int nfds,fd_set *readfds,fd_set *writefds,fd_set * exceptfds,struct timeval *timeouts)

int nfd:监听的最大文件描述符+1

readfds:创建的监听列表监听 事件

writefds:创建的监听列表监听 事件

exceptfds:创建的监听列表监听意外事件

timeouts:超时时间 0--不阻塞  NULL:阻塞

IO多路复用之Select、Poll、Epoll详解_第1张图片

返回值:

       负值:select错误

正值:某些文件可读写或出错(有多少个发生动作 并不能指明哪一个发生的动作)           0:等待超时,没有可读写或错误的文件

本地文件描述符何时发生变化?当本地服务器被别的客户端连接的时候,文件描述符发生变化

客户端传过来的文件描述符何时发生变化?发生读、写事件的时候。

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 


typedef struct sockaddr_in SIN;
typedef struct sockaddr  SA;
int Socket(int domain,int type,int protocol);
int Bind(int sockfd,struct sockaddr * my_addr,int addrlen);
int Listen(int sockfd,int backlog);
int Accept(int socket,struct sockaddr *addr,int *addrlen);
int main (int argc ,char * argv[])
{
	//检验参数
	if(argc != 3)
	{
		printf("命令%s+IP+端口号\n",argv[0]);
		return -1;
	}
	//1.创建监听套接字:socket
	int sock_fd =  Socket(AF_INET, SOCK_STREAM,0);
	//2.编写服务器地址信息;
	SIN  sever_info;
	//memset  bzero 
	bzero(&sever_info, sizeof(SIN));
	sever_info.sin_family =  AF_INET;    //ipv4
	sever_info.sin_port = htons(atoi(argv[2]));   //端口号8888
	sever_info.sin_addr.s_addr = inet_addr(argv[1]);  //ip地址
	//3.将服务器地址信息与监听套接字绑定:bind;
	int  BIND_Val = Bind(sock_fd ,(SA * )&sever_info,sizeof(SA));
	//4.开始监听:listen
	int  LI_val = Listen(sock_fd,10);
	//select(把文件描述符统一检测) -->read(文件描述符)  accpet(文件描述符)
	//初始化环节
	fd_set readfd,Change;
 
所以需要备份要检测的文件描述符,避免内核在检测时候将其余要检测的文件描述符清0;
	FD_ZERO(&readfd);         //清空避免有垃圾值存在
	int nfds = sock_fd+1;     //检测文件描述符最大值:监听文件描述符加一
	FD_SET(sock_fd,&readfd);  //需要监听的文件描述符  //如果之后检测到新的文件描述符在进行添加
	SIN cliet;
	int len=sizeof(SA);
	while(1)
	{
		Change = readfd;      //readfd做备份   Change应用层与内核层进行交互(他是发生变化的)
		int ret_val = select(nfds,&Change,NULL,NULL, NULL);  //读集合传地址  时间永久等待---》阻塞函数
		if(ret_val < 0)
		{
			perror("select");
			return -1;
		}
		//需要遍历到底是哪一个文件描述符发生动作
		for(int i = 0; i< nfds ;i++)
		{
			//检测
			if(FD_ISSET(i,&Change))//用来测试描述词组set中相关fd的位是否为真
			{
				//1表示有新客户端连接
				if(i == sock_fd)
				{
					//accept进行连接
					int New_fd = accept(sock_fd,(SA*)&cliet,&len);
					FD_SET(New_fd,&readfd);//用来设置描述词组set中相关fd的位 
					//nfds发生变化/nfds不用发生变化    1024
					New_fd >=(nfds -1)?(nfds = nfds + 1):(nfds=nfds);
					printf("有新客户端链接\n");
				}
				//2表示接收到新的消息
				else
				{
						char buff[512]={0};
						int len_val = read(i,buff,512);
						if(len_val >0)
						{
							printf("服务器接收到:%s\n",buff);
						}
						else if(len_val == 0)
						{
							printf("客户端断开\n");
							FD_CLR(i,&readfd);         //该处只是清空文件描述符列表里面的数值
							close(i);                  //关闭真实的文件描述符
						}
						else
						{
							printf("客户端异常断开\n");
							FD_CLR(i,&readfd);         //该处只是清空文件描述符列表里面的数值
							close(i);                  //关闭真实的文件描述符
						}
				}
			}
		}
		

	}
	//7.关闭通信套接字:close
	sleep(1);
	close(sock_fd);
	return 0;
}
//包裹函数
int Socket(int domain,int type,int protocol)
{
	int sck_fd =  socket(domain, type,protocol);
	if(sck_fd  == -1 ){
		perror("创建监听套接字socket:");
		exit(0);                 //退出进程
	}
	printf("创建套接字成功\n");
	return sck_fd;
}
//包裹函数
int Bind(int sockfd,struct sockaddr * my_addr,int addrlen)
{
	int  bind_val = bind(sockfd, my_addr, addrlen);
	if(bind_val == -1){
		perror("绑定失败bind\n");
		exit(0);                 //退出进程
	}
	printf("绑定成功\n");
	return bind_val;
}
int Listen(int sockfd,int backlog)
{
	int  lis_val = listen(sockfd,backlog);
	if(lis_val == -1){
		perror("监听失败\n");
		exit(0);                 //退出进程
	}
	printf("监听成功\n");
	return lis_val;
}
int Accept(int socket,struct sockaddr *addr,int *addrlen)
{
	int  new_fd = accept(socket,addr,addrlen);
	if(new_fd == -1 )
	{
		perror("等待连接失败\n");
		exit(0);                 //退出进程
	}
	printf("等待连接成功\n");
	return new_fd;
}

四.poll函数 

poll没有连接数量限制,原理与select相同、

int poll(struct pollfd fd[], nfds_t nfds, int timeout);

1)第一个参数:一个结构数组,struct pollfd结构如下:

struct pollfd{

int fd;              //文件描述符          所要检测的文件描述符  //  3  socket()

short events;        //请求的事件          检测文件描述符的事件  //POLLIN

short revents;      //返回的事件(内核给的反馈) 内核反馈到应用层(select函数咱们读集合read(备份);change(内核改变的的集合))

};
revents:revents 域是文件描述符的操作结果事件,内核在调用返回时设置这个域。events 域中请求的任何事件都可能在 revents 域中返回.
注意:每个结构体的 events 域是由用户来设置,告诉内核我们关注的是什么,而 revents 域是返回时内核设置的,以说明对该描述符发生了什么事件
nfds:用来指定第一个参数数组元素个数
timeout:指定等待的毫秒数,无论 I/O 是否准备好,poll() 都会返回. -1:阻塞  0:立即返回  >0:毫秒数

IO多路复用之Select、Poll、Epoll详解_第2张图片

IO多路复用之Select、Poll、Epoll详解_第3张图片

#include 
#include           /* See NOTES */
#include 
#include 
#include 
#include 
#include 
#include 

int main()
{
	int lis_sd,cli_sd,ret,i;
	struct sockaddr_in ser_addr;
	int addrlen = sizeof(ser_addr);
	char tempBuf[32] = {0};	
	struct pollfd fds[20] = {0};
	int fds_num = 0;
	int temp_num;

	lis_sd = socket(AF_INET ,SOCK_STREAM ,0);
	if(lis_sd < 0)
	{
		perror("socket");
		return -1;
	}

	ser_addr.sin_family = AF_INET;
	ser_addr.sin_port = htons(8080);
	inet_aton("192.168.31.93",&ser_addr.sin_addr);
    	bind(lis_sd,(struct sockaddr *) &ser_addr,addrlen);	
   
	listen(lis_sd, 10);
	fds[0].fd = lis_sd;
	fds[0].events = POLLIN;
	fds_num++;
	while(1)
	{
		printf("fds_num = %d\n",fds_num);
		ret = poll(fds,fds_num,-1);
		if(ret < 0){
			perror("poll");
			continue;
		}else if(ret == 0){
			printf("time out\n");
		}else{
			if(fds[0].revents == POLLIN){
				cli_sd = accept(lis_sd, NULL, NULL);
				if(cli_sd < 0){
					perror("accept");
					return -1;
				}
				fds[fds_num].fd = cli_sd;
				fds[fds_num].events = POLLIN;
				fds_num++;
			}
			temp_num = fds_num;
			for(i=1;i

五.epoll函数

1.epoll概述

在linux 没有实现epoll事件驱动机制之前,我们一般选择用select或者poll等IO多路复用的方法来实现并发服务程序。Linux内核2.6版本之后,都具有epoll函数, 在大数据、高并发、集群等一些名词唱得火热之年代,select和poll的用武之地越来越有限,风头已经被epoll占尽。

epoll的设计和实现与select完全不同。epoll通过在Linux内核中申请一个简易的文件系统。把原先的select/poll调用分成了3个部分:

IO多路复用之Select、Poll、Epoll详解_第4张图片

2.epoll工作原理

epoll数据结构采用红黑二叉树,在根节点创建,分节点插入 struct epoll_event结构体。每个epoll对象与网卡(设备)建立回调关系。

事件变化----网卡中断---eppoll_callback---讲事件添加到rdlist列表。

每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树(二叉树)中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)。

IO多路复用之Select、Poll、Epoll详解_第5张图片

struct epoll_event
结构体epoll_event被用于注册所感兴趣的事件和回传所发生待处理的事件,定义如下:
typedef union epoll_data_t {           //联合体
      void *ptr;
      int fd;                       //比较常用
      __uint32_t u32;
      __uint64_t u64;
} epoll_data_t;//保存触发事件的某个文件描述符相关的数据
struct epoll_event {
      __uint32_t events;      /* epoll event */
      epoll_data_t data;      /* User data variable */
};
其中events表示感兴趣的事件和被触发的事件,可能的取值为:
EPOLLIN:表示对应的文件描述符可以读;
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数可读;
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET:    ET的epoll工作模式;

epoll调用方法:

  1. 第一步:epoll_create()系统调用。此调用返回一个句柄(根节点),之后所有的使用都依靠这个句柄来标识。

  2. 第二步:epoll_ctl()系统调用。通过此调用向epoll对象(红黑树)中添加、删除、修改的事件,返回0标识成功,返回-1表示失败。

  3. 第三步:epoll_wait()系统调用。通过此调用收集收集在epoll监控中已经发生的事件。

3.API函数

int epoll_create(int size)  0 -1

调用epoll_create方法创建一个epoll的句柄。 当创建好epoll句柄后,它就会占用一个fd值。在使用完epoll后,必须调用close函数进行关闭,否则可能导致fd被耗尽。
 在现版本Linux参数size现在无效。

int epoll_ctl(int epfd,int op,int fd,struct epoll_event *event)  0 -1

epfd:epoll_create创建的根节点

op:要进行的操作  EPOLL_CTL_ADD:注册  EPOLL_CTL_MOD:修改   EPOLL_CTL_DEL:删除

fd:想要监听的文件描述符

event:传入信息

int epoll_wait(int epfd,struct epoll *events,int maxevents,int timeouts) 0 -1

epfd:epoll_create创建的根节点

events:用于回传代处理事件的数组;(发生动作 相应文件描述符events,从这里可以看出比select/poll好,主要能确定哪一些文件描述符进行动作

maxevents:每次能处理的事件数

timeout:等待I/O事件发生的超时值;如果为-1 永久等待  0轮洵     >0等待时间

/*************************************************************************
	> File Name: main.c
	> Author: csy
	> Mail: [email protected] 
	> Created Time: 2021年03月09日 星期二 16时44分00秒
 ************************************************************************/

#include 
#include 
#include 
#include 
#include           /* See NOTES */
#include 
#include 
#include 
#include 
#include 
#include 
#include 



int main(int argc , char *argv[])
{
	int sockfd , clientfd , ret , i , count;
	char rcvbuf[32] = {0};
	struct  sockaddr_in seraddr = {0};
	socklen_t addrlen = sizeof(seraddr);

	sockfd = socket(AF_INET, SOCK_STREAM, 0);
	if (sockfd < 0) {
		perror("socket");
		return -1;
	}
	int keepAlive = 1; // 开启keepalive属性
	int keepIdle = 10; // 如该连接在10秒内没有任何数据往来,则进行探测 
	int keepInterval = 3; // 探测时发包的时间间隔为3 秒
	int keepCount = 3; // 探测尝试的次数.如果第1次探测包就收到响应了,则后2次的不再发.

	setsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE, (void *)&keepAlive, sizeof(keepAlive));
	setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPIDLE, (void*)&keepIdle, sizeof(keepIdle));
	setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPINTVL, (void *)&keepInterval, sizeof(keepInterval));
	setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPCNT, (void *)&keepCount, sizeof(keepCount));

	seraddr.sin_family = AF_INET;
	seraddr.sin_port   = htons(8080);
	//seraddr.sin_addr.s_addr = inet_addr("192.168.0.148");
	seraddr.sin_addr.s_addr = inet_addr("0.0.0.0");
	if (bind(sockfd, (struct sockaddr *)&seraddr,addrlen)<0) {
		perror("bind");
		return 0;
	}

	listen(sockfd, 10);

	struct epoll_event event , events[1024];
	int maxevents = 1;
	int epfd = epoll_create(1024);
	if (epfd < 0) {
		perror("epoll_create");
		exit(0);
	}
	
	event.events = EPOLLIN;
	event.data.fd = sockfd;
	epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event);
	printf("wait connect\n");

	while (1) {
		ret = epoll_wait(epfd, events , maxevents, 3000);
		switch(ret) 
		{
			case -1:perror("epoll_wait");break;
			case 0 :printf("timeout\n");break;
			default:
				for (i=0;i

五.三者区别

select poll epoll
监听文件描述符MAX 1024 所支持的文件描述符上限是整个系统最大可以打开的文件数目。在1GB内存的机器上,这个限制大概为10万左右
列表属性 fd_set *readfd  数组 struct pollfd POLL[NUM]  链表 struct epoll_event events[1024]  二叉树
判断描述符是否变化                                                                                     轮训列表
特点

有限的文件列表

应用程序需要遍历整个数组

需要经过内存拷贝

应用程序需要遍历整个数组

需要经过内存拷贝

已经就绪的文件描述符集合

mmap()文件映射内存加速与内核空间的消息传递,减少复制开销

触发方式 水平触发(内核会告诉开发者一个文件描述符是否就绪,如果开发者不采取任何操作,内核仍会一直通知) 水平触发 (Level Triggered)

水平触发(LT)就是只有高电平(1)或低电平(0)时才触发通知,只要在这两种状态就能得到通知.上面提到的只要有数据可读(描述符就绪)那么水平触发的epoll就立即返回.

边缘触发(ET)假定开发者在接收到一次通知后,会完整地处理该事件,因此内核将不再通知这一事件只有电平发生变化(高电平到低电平,或者低电平到高电平)的时候才触发通知.上面提到即使有数据可读,但是io状态没有变化epoll也不会立即返回.。

注意,缓冲区中还有未处理的数据不能说是状态变化,因此,在ET模式下,开发者如果只读取了一部分数据,其将再也得不到通知

实现过程

定义最大文件描述符nfds = nfds+1

定义fd_set readfd,tempfd

清除FD_ZERO(readfd)

把要监听的文件描述符加入列表FD_SET(fd,&readfd)

tempfd = readfd

阻塞等待文件描述符发生变化,返回就绪描述符个数select(nfds,&tempfd,NULL,NULL,NULL)

轮训nfds判断对应位是否变化FD_ISSET(fd,&tempfd )

清空文件描述符列表FD_CLR(fd,&tempfd )

定义最大文件描述符nfds = nfds+1

struct pollfd fds[20] = {0};

将要监听的文件描述符添加至结构体数组

fds[0].fd = lis_sd;
fds[0].events = POLLIN;

 阻塞等待文件描述符发生变化,返回就绪描述符个数

poll(fds,nfds,-1)

轮训nfds判断fds[i].revents ==POLLIN

关闭close(fds[i].fd); 

定义struct epoll_event event , events[1024];

创建根节点epfd = epoll_create(1024);

向二叉树加入文件描述符信息

event.events = EPOLLIN;
event.data.fd = sockfd;

epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event);

阻塞等待ret = epoll_wait(epfd, events , maxevents, 3000);

轮训ret判断events[i].data.fd或events[i].data.events

maxevents++

关闭close(events[i].data.fd);

 大家可能还不能完全了解这两种模式的区别,我们可以举例说明:一个管道收到了1kb的数据,epoll会立即返回,此时读了512字节数据,然后再次调用epoll.这时如果是水平触发的,epoll会立即返回,因为有数据准备好了.如果是边缘触发的不会立即返回,因为此时虽然有数据可读但是已经触发了一次通知,在这次通知到现在还没有新的数据到来,直到有新的数据到来epoll才会返回,此时老的数据和新的数据都可以读取到(当然是需要这次你尽可能的多读取).所以当我们写epoll网络模型时,如果我们用水平触发不用担心数据有没有读完因为下次epoll返回时,没有读完的socket依然会被返回,但是要注意这种模式下的写事件,因为是水平触发,每次socket可写时epoll都会返回,当我们写的数据包过大时,一次写不完,要多次才能写完或者每次socket写都写一个很小的数据包时,每次写都会被epoll检测到,因此长期关注socket写事件会无故cpu消耗过大甚至导致cpu跑满,所以在水平触发模式下我们一般不关注socket可写事件而是通过调用socket write或者send api函数来写socket,说到这我们可以看到这种模式在效率上是没有边缘触发高的,因为每个socket读或者写可能被返回两次甚至多次,所以有时候我们也会用到边缘触发但是这种模式下在读数据的时候一定要注意,因为如果一次可写事件我们没有把数据读完,如果没有读完,在socket没有新的数据可读时epoll就不回返回了,只有在新的数据到来时,我们才能读取到上次没有读完的数据。

你可能感兴趣的:(网络专题,epoll,linux)