天天看点

Linux系统的I/O模型,多路复用IO实现并发。

       多为我自己的个人理解,希望不要误导。。。。。。。。。。

         前端时间做了一个基于web服务器的在线商城的项目的练习,其中运用到了TCP的并发服务器(多个客户端同时访问一个服务器),经过查阅相关资料。现将linux系统中的I/O模型总结如下。期间可能有总结的不到位的地方,后期会随着学习的深入,不断改进。

        在讲述IO模型前,先来提一提同步和异步的概念,就我个人的简单理解。

        同步:一段代码中,我发给对方一个通知,我等待对方回复我,对方回复我了,我的代码才继续向下执行,有严格的执行顺序。(效率低,但有结果确认机制)

        异步:一段代码中,我发给对方一个通知,我继续向下执行我的代码,我不等待对方回复我,无严格的执行顺序。(效率高)

        文件描述符:Linux操作系统中,一切皆文件,这句话应该大家都耳熟能详,包括你的终端、支持的控件均是一个文件。但你知道吗,任何打开的文件都对应一个文件描述符。他是一个操作文件的句柄(一个小于1024的整数,每次操作系统都从小开始分配),有了他你就可以对文件进行读写等操作。

1、阻塞IO模型

        例如:管道中收(write)发(read)数据,他的优点是,可以实现同步功能,节省CPU的资源开销,提高执行效率。TCP网络编程中,accept函数和recv函数都自带有阻塞功能,从而实现同步的功能。这个应用也是比较多的,一对一的发送也是比较高效的。

2.非阻塞IO(实现异步功能)

        1.可以同时访问多个IO事件。

        2.浪费CPU资源,CPU一直处于忙碌的状态,不断轮询判断于那个IO来事件了。

函数接口:

功能:设置文件描述符的属性

#include <unistd.h>
#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* arg */ );
           

参数:

        fd:指定那个文件描述符

        cmd:指定命令(Linux内核中定义的宏指定)        

                        1.F_GETFL:获得文件属性

                        2.F_SETFL:设定文件描述符属性        

        arg:传给设置属性的参数

返回值:

        成功:0,失败-1,若是获得文件描述符则返回属性值

说明:(获得文件描述符属性,不用穿第三参数,如果是设定文件描述符属性,则传第三参,是加上属性的文件描述符(获得属性的返回值,加上属性))

O_NONBLOCK是使此文件为非阻塞。

下面给一个demo,是从管道和终端同时收发数据的情况,使其即从终端读,又从管道读。

#include "head.h"

int main(int argc, const char *argv[])
{
	int fd = 0;
	int flag = 0;
	int ret = 0;

	char fifo_buff[4096] = {0};
	char stdin_buff[4096] = {0};
	char *pert = NULL;

	mkfifo("./fifo",0777);
	fd = open("./fifo",O_RDONLY);
	if (-1 == fd)
	{
		perror("failed open");
		return -1;
	}
	flag = fcntl(fd,F_GETFL);//获得当前文件属性
	flag |= O_NONBLOCK;		 //加载非阻塞属性
	fcntl (fd,F_SETFL,flag); //设置文件属性

	flag = fcntl(0,F_GETFL);//获得标准输入属性
	flag |= O_NONBLOCK;		 
	fcntl (0,F_SETFL,flag);

	while(1)
	{
		ret = read(fd,fifo_buff,sizeof(fifo_buff));
		if (ret > 0)
		{
			printf("FIFO:%s\n",fifo_buff);
		}
		pert = gets(stdin_buff);
		if (NULL != pert)
		{
			printf("STDIN:%s\n",stdin_buff);
		}
	}
	close(fd);


	return 0;
}
           

3.异步IO

        这个和非阻塞的函数接口类似,给其属性上加入O_ASYNC这个宏,将此文件变成异步事件。

使其文件接受到某一信号的时候,按照你捕捉的信号方式去处理即可。他仅仅局限于比较少的文件描述符,因为信号使有限的嘛。

#include "head.h"

int fd = 0;
void handler(int signo)
{
	char tmpbuf[4096] = {0};
	read(fd,tmpbuf,sizeof(tmpbuf));
	printf("FIFO:%s\n",tmpbuf);
	return;
}

int main(int argc, const char *argv[])
{
	int flag = 0;

	char fifo_buff[4096] = {0};
	char stdin_buff[4096] = {0};
	
	signal(SIGIO,handler);//捕捉此信号

	mkfifo("./fifo",0777);
	fd = open("./fifo",O_RDONLY);
	if (-1 == fd)
	{
		perror("failed open");
		return -1;
	}
	flag = fcntl(fd,F_GETFL);//获得当前文件属性
	flag |= O_ASYNC;		 //加载异步信号属性
	fcntl (fd,F_SETFL,flag); //设置文件属性
	fcntl(fd,F_SETOWN,getpid());//设定接受异步事件通知的进程,发送SIGAL信号
								//得到异步事件通知,默认结束进程

	while(1)
	{
		gets(stdin_buff);
		printf("STDIN:%s\n",stdin_buff);
	}
	close(fd);


	return 0;
}
           

4.多路复用IO

多路复用:阻塞IO只能阻塞一个事件,而多路复用可以处理多个IO事件,对多个读、写可以同时检测==同时监听多个文件描述符,正因为此可能才有个多路复用的概念。

        1.select

#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int select(int nfds, fd_set *readfds, fd_set *writefds,
                  fd_set *exceptfds, struct timeval *timeout);

void FD_CLR(int fd, fd_set *set);//将fd从set文件描述符集合中清除。
int  FD_ISSET(int fd, fd_set *set);//判断文件描述符fd是否在文件描述符集合中;
void FD_SET(int fd, fd_set *set);//将fd加入文件描述符集合中;
void FD_ZERO(fd_set *set);//将文件描述符集合清0;

           

功能:监听一个文件描述符集合 

参数:

        nfds:最大的文件描述符集合+1;

        readfds:读事件的文件描述符集合

        writefds:写事件的文件描述符集合

        exceptfds:其余事件的文件描述符集合

        timeout:设置最大等待时间,NULL为一直等待。

返回值:

             成功:返回产生时间的文件描述符个数

             失败:-1

缺点:

        1.select监听的最大文件描述符个数为1024;

        2.select只能工作字水平触发模式,(内核未处理会通知多次),无法工作在边沿触发模式(同一事件不需要多次通知)

        3.select监听的文件描述符集合在用户层,需要向内核层传递数据,这是一种资源开销。

        4.select需要每次循环遍历一遍(整个文件描述符集合)才能找到产生事件的文件描述符集合。

同样是前面提到的例子,一个文件描述符负责总终端接,一个负责从管道中接、

#include "head.h"

int main(int argc, const char *argv[])
{
	int fd = 0;
	int flag = 0;

	fd_set rdfds;
	fd_set tmpfds;
	int maxfd = 0;
	int nready = 0;

	char buff[4096] = {0};

	mkfifo("./fifo",0777);
	fd = open("./fifo",O_RDONLY);
	if (-1 == fd)
	{
		perror("failed open");
		return -1;
	}

	FD_ZERO(&rdfds);

	FD_SET(fd,&rdfds);
	FD_SET(0,&rdfds);
	maxfd = fd;
	tmpfds = rdfds;

	while(1)
	{
		tmpfds = rdfds;//每次会将无事件的文件描述符踢出
					   //每次要监听所有文件描述符
		memset(buff,0,sizeof(buff));
		nready = select(maxfd+1,&tmpfds,NULL,NULL,NULL);
		if (-1 == nready)
		{
			perror("failed select");
			return -1;
		}
		if(FD_ISSET(fd,&tmpfds))
		{
			read(fd,buff,sizeof(buff));
			printf("FIFO:%s\n",buff);
		}
		if ( FD_ISSET(0,&tmpfds) )
		{
			gets(buff);
			printf("STDIN:%s\n",buff);
		}

	}
	close(fd);


	return 0;
}
           

2.poll

#include <poll.h>
       int poll(struct pollfd *fds, nfds_t nfds, int timeout);
           

功能:监听一个文件描述符集合 

参数:

        fds: 文件描述符集合首地址

        nfds:文件描述符的个数

        timeout:-1是一直阻塞等待

返回值:

             成功:返回产生时间的文件描述符个数

             失败:-1,0:时间到了未有时间发生

缺点:

        对于poll'而言,它内核中是用链表形式存储文件描述符集合的,所以仅对于select,没有事件个数的限制。其余缺点它全部具备。

同样是前面提到的例子,一个文件描述符负责总终端接,一个负责从管道中接、

#include "head.h"

int main(int argc, const char *argv[])
{
	int fd = 0;
	int ret = 0;
	struct pollfd fds[2];//文件模式符数组

	char tmpbuff[1024] = {0};

	int nready = 0;//实际产生事件的文件描述符个数


	mkfifo("./fifo",0777);
	fd = open("./fifo",O_RDONLY);
	if (-1 == fd)
	{
		perror("failed open");
		return -1;
	}

	fds[0].fd = fd;			//文件描述符接受体赋值
	fds[0].events = POLLIN;//事件

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


	while(1)
	{
		nready = poll(fds, 2, -1);
		if (-1 == nready)
		{
			perror("failed poll");
			return -1;
		}
		if(fds[0].revents & POLLIN)//检测文件描述符的时间是否发生
		{
			memset(tmpbuff,0,sizeof(tmpbuff));
			read(fd,tmpbuff,sizeof(tmpbuff));
			printf("FIFO:%s\n",tmpbuff);
		}
		if(fds[1].revents & POLLIN)//检测文件描述符的时间是否发生
		{
			memset(tmpbuff,0,sizeof(tmpbuff));
			gets(tmpbuff);
			printf("STDIN:%s\n",tmpbuff);
		}
	}
	close(fd);


	return 0;
}
           

3.epoll

#include <sys/epoll.h>


int epoll_create(int size);
功能:创建一个监听事件表
参数:size:最大监听事件的个数
返回值:
        成功:新的文件描述符(事件表)
        失败:-1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
功能:在监听事件表里新增一个事件
参数:
    epfd:事件表的文件描述符
    op:EPOLL_CTL_ADD:新增事件
        EPOLL_CTL_MOD:修改事件
        EPOLL_CTL_DEL:删除事件
    fd:文件描述符(指定那个事件)
    event:加入事件的结构题,删除不需要此参数
返回值:成功0,失败-1

int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
功能:监听事件表中的事件,并将产生的事件放至自己定义的结构体数组数组中
参数:epfd:事件表的文件描述符
      events:存放事件结构体空间的首地址
      maxevents:最多存放事件个数
      timeout:超时时间,-1永远阻塞
返回值:返回产生事件个数
        失败:-1,0超时时间到未有时间发生
 typedef union epoll_data {
               void    *ptr;
               int      fd;
               uint32_t u32;
               uint64_t u64;
} epoll_data_t;

 struct epoll_event {
        uint32_t     events;    /* Epoll events */
        epoll_data_t data;      /* User data variable */
};


           

优点:

它是所有多路复用的加强版,完全避讳了selsect所有的缺点。

1.没有最大的文件事件个数限制,(查资料说它内部通过链表实现)

2.默认工作在水平触发模式,但可以通过设定使其工作在边沿触发。

3.监听文件描述符集合在内核层,需要拷贝空间,节省资源开销。

4.只需要传入一个待检测的文件描述符,内核通过红黑树实现检测发生事件的文件描述符。

不需要轮询遍历整张文件描述符表。

同样是前面提到的例子,一个文件描述符负责总终端接,一个负责从管道中接、

#include "head.h"

int main(int argc, const char *argv[])
{
	int fd = 0;
	int ret = 0;
	int epfd = 0;//监听事件表的文件描述符
	char tmpbuff[1024] = {0};
	int nready = 0;//实际产生事件的文件描述符个数

	int i = 0;
	struct epoll_event env;//加入事件的结构体
	struct epoll_event retenv[2];//存放时间信息的结构体数组


	mkfifo("./fifo",0777);
	fd = open("./fifo",O_RDONLY);
	if (-1 == fd)
	{
		perror("failed open");
		return -1;
	}

	epfd = epoll_create(2);//创建2个监听事件的文件描述符表
	if (-1 == epfd)
	{
		perror("failed epoll_create");
		return -1;
	}

	env.events = EPOLLIN;//第一个事件监听方式
	env.data.fd = fd;	 //给入的信息,wait可以拿到此信息
	ret = epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&env);//在监听事件中加入此事件
	if (-1 == ret)
	{
		perror("failed epoll_ctl");
		return -1;
	}

	env.events = EPOLLIN;
	env.data.fd = 0;
	ret = epoll_ctl(epfd,EPOLL_CTL_ADD,0,&env);
	if (-1 == ret)
	{
		perror("failed epoll_ctl");
		return -1;

	}

	while(1)
	{
		nready = epoll_wait(epfd,retenv,2,-1);//监听事件表,并将产生的事件放入结构体(retenv)
		if(-1 == nready)
		{
			perror("failed to epoll_wait");
			return -1;
		}
		for (i = 0; i < nready; i++)
		{
			if(retenv[i].data.fd == fd)//拿到信息后判断是那个时间发生
			{
				memset(tmpbuff,0,sizeof(tmpbuff));
				read(fd,tmpbuff,sizeof(tmpbuff));
				printf("FIFO:%s\n",tmpbuff);
			}
			else if(retenv[i].data.fd == 0)
			{
				memset(tmpbuff,0,sizeof(tmpbuff));
				gets(tmpbuff);
				printf("STDIN:%s\n",tmpbuff);
			}
		}
	}
	close(fd);
	return 0;
}
           

总结:

(这个使一位老哥总结的(32条消息) 答应我,这次搞懂 I/O 多路复用!_小林coding-CSDN博客,非常到位)

最基础的 TCP 的 Socket 编程,它是阻塞 I/O 模型,基本上只能一对一通信,那为了服务更多的客户端,我们需要改进网络 I/O 模型。

比较传统的方式是使用多进程/线程模型,每来一个客户端连接,就分配一个进程/线程,然后后续的读写都在对应的进程/线程,这种方式处理 100 个客户端没问题,但是当客户端增大到 10000 个时,10000 个进程/线程的调度、上下文切换以及它们占用的内存,都会成为瓶颈。

为了解决上面这个问题,就出现了 I/O 的多路复用,可以只在一个进程里处理多个文件的 I/O,Linux 下有三种提供 I/O 多路复用的 API,分别是: select、poll、epoll。

select 和 poll 并没有本质区别,它们内部都是使用「线性结构」来存储进程关注的 Socket 集合。

在使用的时候,首先需要把关注的 Socket 集合通过 select/poll 系统调用从用户态拷贝到内核态,然后由内核检测事件,当有网络事件产生时,内核需要遍历进程关注 Socket 集合,找到对应的 Socket,并设置其状态为可读/可写,然后把整个 Socket 集合从内核态拷贝到用户态,用户态还要继续遍历整个 Socket 集合找到可读/可写的 Socket,然后对其处理。

很明显发现,select 和 poll 的缺陷在于,当客户端越多,也就是 Socket 集合越大,Socket 集合的遍历和拷贝会带来很大的开销,因此也很难应对 C10K。

epoll 是解决 C10K 问题的利器,通过两个方面解决了 select/poll 的问题。

epoll 在内核里使用「红黑树」来关注进程所有待检测的 Socket,红黑树是个高效的数据结构,增删查一般时间复杂度是 O(logn),通过对这棵黑红树的管理,不需要像 select/poll 在每次操作时都传入整个 Socket 集合,减少了内核和用户空间大量的数据拷贝和内存分配。

epoll 使用事件驱动的机制,内核里维护了一个「链表」来记录就绪事件,只将有事件发生的 Socket 集合传递给应用程序,不需要像 select/poll 那样轮询扫描整个集合(包含有和无事件的 Socket ),大大提高了检测的效率。

而且,epoll 支持边缘触发和水平触发的方式,而 select/poll 只支持水平触发,一般而言,边缘触发的方式会比水平触发的效率高。

继续阅读