多为我自己的个人理解,希望不要误导。。。。。。。。。。
前端时间做了一个基于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 只支持水平触发,一般而言,边缘触发的方式会比水平触发的效率高。