目录
本文中的代码示例建议实践一遍。
1 为什么要进行I/O复用?
在这之前,大家应该先对5中I/O模型有一个简单的了解。
首先我们应该都知道,I/O复用可以同时对多个文件描述符进行监听,能很大程度上去提高程序的性能。就相当于一个同时能监听多个电话的电话接线员。
I/O复用在网络编程中最为常用,在传统的网络编程中,如果有多个TCP连接,往往都是去开辟多个线程去进行处理,在线程数量较多的情况下,线程的开辟、唤醒等操作大大的浪费CPU时间。因此如果能有一个类似于观察者的角色去监听多个连接,那么程序的性能将能得到大幅度的提升。这个时候I/O复用就闪亮登场了。
在网络编程的过程中,以下几种情况(不限于)需要使用I/O复用模型:
- 客户端要处理多个socket。
- 客户端程序要同时处理用户输入和网络连接。
- TCP服务器要同时处理监听socket和连接socket。这种情况是I/O复用使用最多的一种情况。
- 服务器要同时处理TCP请求和UDP请求。
- 服务器要同时监听多个窗口,或者处理多种任务。
I/O复用和其他I/O模型的比较如下图所示:
Linux通过select、poll和epoll三种系统调用实现了I/O复用。epoll是linux特有的。下面进行一一介绍。
2 select
select系统调用可以在某一时间段内监听文件描述符上的可读、可写或者异常事件。
为了便于理解select,我形象的比喻一下这句话:
假如你是公司老板,我们将select看作是你的专属接线员,你有几个客户的电话号码(相当于文件描述符),然后你将这几个电话好号码告诉了你的接线员,并且告诉他,如果这些客户要是买产品的话再告诉你(将可读操作比喻为买产品),其他情况不用告诉我,并且一个小时汇报一次,不管有没有客户买产品。那么对于你而言,只有当固定的几个客户打电话并且要买产品,你才能从接线员那里收到消息。当然除了买产品以外,你还可以规定其他两件事情,但是不能超过三件。(这些事情相当于:可读、写和异常)
select其实是在指定事件内轮询一定数量的文件描述符,检测是否有就绪者。
下面是select常用的操作:
#include<sys/select.h>
int select(int nfds,fd_set *readfds,fd_set* writefds,fd exceptfds,struct timeval * timeout);
此函数用于监听文件描述符,文件描述符存于readfds,writefds,exceptfds.
nfds是需要监听文件描述符的个数,假设readfds包含1个文件描述符,writefds包含两个文件描述符,exceptfds包含3个文件描述符,那么,nfds = 1+2+3=6。通常设置为文件描述符的最大值+1,因为文件描述符是从0开始计数的。
readfds、writefds和exceptfds是三个fd_set指针,fd_set是一个结构体,里面有一个整型数组,用于存储文件描述符。三个指针为空,那么select只监听超时。如果不为空,那么select就去监听文件描述符的响相应事件。readfds监听读事件,writefds监听写事件,exceptfds监听异常事件。某一个为NULL,那就不监听相应的事件。
timeout为一个结构体,用于设置超时时间。
struct timeval
{
long tv_ser;//秒
long tv_usec;//毫秒
};
select失败返回-1,超时返回0,事件就绪返回大于0的数,表示满足就绪事件文件描述符的个数。
例如有3个文件描述符满足可读操作,那么select就返回3。
FD_ZERO(fd_set* fdset);//对fdset进行清零操作
FD_SET(int fd,fd_set *fdset);//即将fd添加到fdset的存储描述符的数组中
FD_CLR(int fd,fd_set* fdset);//将fd从fdset的文件描述符数组中删除
int FD_ISSET(int fd,fd_set *fdset);//即检查fdset文件描述符数组中的fd是否发生变化,即相应的事件是否满足,如果事件满足返回真,否则返回假。这个函数是在使用select中最常使用的一个函数,用于检测相应文件描述符是否有事件发生。
下面是一个示例,用监听socket和连接socket,包含两个文件select.c和cli.c。
在介绍poll和epoll的时候客户端的代码和cli.c一样,这里给出,后面不再展示。
select.c
#include <stdio.h>
#include<unistd.h>
#include<string.h>
#include<assert.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<sys/select.h>
#include<sys/time.h>
#include<signal.h>
//同时监听套接字的最大个数
# define MAXD 10
//收集文件描述符
void FdsAdd(int fds[],int fd)
{
int i = 0;
for(;i<MAXD;++i)
{
//找到一个位置放入
if(fds[i]==-1)
{
fds[i]=fd;
break;
}
}
}
//删除文件描述符
void FdsDel(int fds[],int fd)
{
int i = 0;
for(;i<MAXD;++i)
{
if(fds[i]==fd)
{
fds[i]=-1;
break;
}
}
}
int main()
{
//创建套接字和地址
int sockfd = socket(AF_INET,SOCK_STREAM,0);
assert(sockfd!=-1);
struct sockaddr_in saddr;
memset(&saddr,0,sizeof(saddr));
saddr.sin_family=AF_INET;
saddr.sin_port = htons(6000);
saddr.sin_addr.s_addr=inet_addr("127.0.0.1");
int res = bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));
assert(res!=-1);
listen(sockfd,5);
fd_set fdSet;//一个描述符集合,用于监听固定事件
int fds[MAXD];//收集要监听的描述符
//对fds进行初始化操作
int i = 0;
for(;i<MAXD;++i)
fds[i]=-1;
//将监听套接字sockfd添加到集合fds中
FdsAdd(fds,sockfd);
while(1)
{
//对fdSet进行清零操作
FD_ZERO(&fdSet);
//用于存储要最大的文件描述符
int maxFd = 0;
for(i=0;i<MAXD;++i)
{
if(fds[i]==-1)
continue;
//将文件描述符添加到fdSet中
FD_SET(fds[i],&fdSet);
if(fds[i]>maxFd)
maxFd=fds[i];
}
//设置超时时间
struct timeval tv = {5,0};
int n = select(maxFd+1,&fdSet,NULL,NULL,&tv);//只对读事件感兴趣
printf("n = %d\n",n);
//从select的返回值来判断
if(n==-1)
perror("error\n");
else if(n==0)
printf("time out\n");
else
{
printf("进入事件处理\n");
//判断是哪个文件描述符有可读的操作
for(i = 0;i<MAXD;++i)
{
if(fds[i]==-1)
continue;
//可读
if(FD_ISSET(fds[i],&fdSet))
{
//监听套接字sockfd可读,表明有新连接
if(fds[i]==sockfd)
{
//创建新连接
struct sockaddr_in caddr;
int len = sizeof(caddr);
int c = accept(sockfd,(struct sockaddr*)&caddr,&len);
if(c<0)
continue;
printf("accept c =%d\n",c);
//将连接套接字放入集合中
FdsAdd(fds,c);
}
//监听套接字可读
else
{
char buff[128] = {0};
int res = recv(fds[i],buff,127,0);
if(res<=0)//断开连接
{
close(fds[i]);
FdsDel(fds,fds[i]);
printf("one client is over\n");
}
else
{
printf("recv %d :%s\n",fds[i],buff);
send(fds[i],"ok",2,0);
}
}
}
}
}
}
return 0;
}
cli.c
#include <stdio.h>
#include<unistd.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<assert.h>
#include<string.h>
#include<stdlib.h>
int main()
{
int sockfd = socket(AF_INET,SOCK_STREAM,0);
struct sockaddr_in caddr;
memset(&caddr,0,sizeof(caddr));
caddr.sin_family=AF_INET;
caddr.sin_port=htons(6000);
caddr.sin_addr.s_addr=inet_addr("127.0.0.1");
int res = connect(sockfd,(struct sockaddr*)&caddr,sizeof(caddr));
assert(res!=-1);
while(1)
{
printf("input:\n");
char buff[128]={0};
fgets(buff,128,stdin);
if(strncmp(buff,"end",3)==0)
{
break;
}
send(sockfd,buff,strlen(buff),0);
memset(buff,0,128);
recv(sockfd,buff,127,0);
printf("recv buff = %s\n",buff);
}
close(sockfd);
return 0;
}
执行结果如下图所示:
可以发现select可以完美的处理监听套接字和连接套接字。
我们可以发现,select有个缺点,假设我们有一个文件描述符,如果我们要监听它的可读和可写,那么这个文件描述符就需要传入到两个fd_set中,再将两个fd_set传入到select中,略显繁琐,另一个系统调用poll将这个问题完美的解决掉了。另外,select监测的事件只有3种,而poll将监测事件细化了。
3 poll
poll和select非常相似,也是在指定事件内轮询一定数量的文件描述符,检测是否有就绪者。
至于poll和select有什么区别,看完poll的相关操作就明了了。
#include<poll.h>
int poll(struct pollfd* fds,nfds_t nfds,int timeout);
用于监听文件描述符。nfds文件描述符个数,timeout为超时时间,单位毫秒,如果为-1,poll永远阻塞,为0立即返回。
返回值的含义也和select相同,所以我们着重看一下pollfd这个结构体。
struct pollfd
{
int fd;//文件描述符
short events;//注册的事件,即感兴趣的事件。可以同时注册多个事件,用“|”隔开即可
short revents;//实际发生的事件,当检测有某个事件发生后,内核会将事件填充到这个变量里。如果没有事件发生,内核会将这个变量清零。
}
poll中传入的是一个pollfd数组
poll可以监测的事件和相应的描述如下所示:(图片来源于网络)
其中POLLIN最常用。接下来我们来看poll的示例,两个文件poll.c和cli.c,cli.c上一节中已经给出。
poll.c
#include <stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
#include<assert.h>
#include<poll.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<sys/socket.h>
//监听文件符最大个数
#define MAXFD 10
//创建监听套接字
int CreateSocket()
{
int sockfd= socket(AF_INET,SOCK_STREAM,0);
assert(sockfd!=-1);
struct sockaddr_in saddr;
memset(&saddr,0,sizeof(saddr));
saddr.sin_family=AF_INET;
saddr.sin_port = htons(6000);
saddr.sin_addr.s_addr=inet_addr("127.0.0.1");
int res = bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));
assert(res!=-1);
listen(sockfd,5);
return sockfd;
}
//创建连接套接字
int CreateConSocket(int sockfd)
{
struct sockaddr_in caddr;
int len = sizeof(caddr);
memset(&caddr,0,sizeof(caddr));
int c = accept(sockfd,(struct sockaddr*)&caddr,&len);
assert(c>=0);
printf("accept c = %d\n",c);
return c;
}
//添加文件描述符
void FdsAdd(struct pollfd fds[],int fd)
{
int i =0;
for(;i<MAXFD;++i)
{
//插入到合适的位置
if(fds[i].fd==-1)
{
fds[i].fd= fd;
fds[i].events=POLLIN;//注册可读事件
fds[i].revents=0;
break;
}
}
}
//删除文件描述符
void FdsDel(struct pollfd fds[],int fd)
{
int i = 0;
for(;i<MAXFD;++i)
{
if(fds[i].fd==-1)
continue;
if(fds[i].fd==fd)
{
fds[i].fd=-1;
fds[i].events=0;
fds[i].revents=0;
break;
}
}
}
//对pollfd数组进行初始化
void FdsInit(struct pollfd fds[])
{
int i = 0;
for(;i<MAXFD;++i)
{
fds[i].fd=-1;
fds[i].events=0;
fds[i].revents=0;
}
}
int main()
{
int sockfd = CreateSocket();
struct pollfd fds[MAXFD];
FdsInit(fds);
FdsAdd(fds,sockfd);
while(1)
{
int n = poll(fds,MAXFD,5000);//5秒超时
if(n==-1)
perror("poll error\n");
if(n ==0)
printf("time out\n");
else
{
int i = 0;
for(;i<MAXFD;++i)
{
if(fds[i].fd==-1)
continue;
if(fds[i].revents&POLLIN)
{
//监听套接字
if(fds[i].fd==sockfd)
{
int c =CreateConSocket(sockfd);
FdsAdd(fds,c);
}
//连接套接字
else
{
char buff[128]={0};
int n = recv(fds[i].fd,buff,127,0);
if(n<=0)
{
close(fds[i].fd);
FdsDel(fds,fds[i].fd);
printf("one client is over\n");
}
else
{
printf("recv %d:%s\n",fds[i].fd,buff);
send(fds[i].fd,"ok",2,0);
}
}
}
}
}
}
return 0;
}
程序运行效果如下图所示:
select和poll采用的都是轮询检测的机制,即每次调用都要重复的将文件描述符传入到内核当中,这一点很大程度上降低了程序的运行效率,因此linux提出了自己特有的epoll。
4 epoll(linux特有)
epoll是linux特有的I/O复用函数,它在实现、使用上与select和poll有很大的差异。首先,epoll是用一组函数来完成监听任务,而不是一个函数。其次,epoll把用户关心的文件描述符上的事件放在内核中的一个事件表上,无需像select和poll那样每次都将文件描述符拷入到内核当中。
4.1 epoll介绍与示例
接下来我们介绍一下epoll的几个操作函数。
#include<sys/epoll.h>
int epoll_create(int size);
此函数用于在内核中创建一个事件表,size并不起作用,只是给一个提示,告诉内核事件表多大。
函数返回内核事件表的文件描述符。
因为epoll需要在内核中去创建一个内核事件表,因此需要使用一个额外的文件描述符去标识内核中的这个事件表。
int epoll_ctl(int epfd,int op,int fd,struct epoll_event *event);
此函数用于对事件表进行操作,epfd为epoll_create的返回值。
op为操作方式,有如下几种:
EPOLL_CTL_ADD:向事件表中添加事件。
EPOLL_CTL_MOD:修改事件表上的事件。
EPOLL_CTL_DEL:删除事件表上的事件。
fd为要操作的文件描述符,event参数指定事件,即用来表明用户感兴趣的文件描述事件。
epoll_event的定义如下:
struct epoll_event
{
_uint32_t events;//epoll事件,即用户感兴趣的事件
epoll_data_t data; //用户数据
};
typedef union epoll_data
{
……
int fd;//用来存储文件描述符
……
};
当要删除文件描述符的时候,可以将event设为NULL.
epoll的核心函数为epoll_wait,用于监听文件描述符
int epoll_wait(int epfd,struct epoll_event* events,int maxevents,int timeout);
epfd、maxevents和timeout很好理解。
events是指向一个epoll_event的数组,是一个用来收集文件描述符的集合。内核会将事件发生的文件描述符和相关信息存入events。
返回值的含义与select和poll一样。
什么都没有代码来的直观,我们能来看一下epoll的示例。epoll.c和cli.c,cli.c和前面一样。
#include <stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
#include<assert.h>
#include<sys/epoll.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<sys/socket.h>
//监听文件符最大个数
#define MAXFD 10
//创建监听套接字
int CreateSocket()
{
int sockfd= socket(AF_INET,SOCK_STREAM,0);
assert(sockfd!=-1);
struct sockaddr_in saddr;
memset(&saddr,0,sizeof(saddr));
saddr.sin_family=AF_INET;
saddr.sin_port = htons(6000);
saddr.sin_addr.s_addr=inet_addr("127.0.0.1");
int res = bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));
assert(res!=-1);
listen(sockfd,5);
return sockfd;
}
//创建连接套接字
int CreateConSocket(int sockfd)
{
struct sockaddr_in caddr;
int len = sizeof(caddr);
memset(&caddr,0,sizeof(caddr));
int c = accept(sockfd,(struct sockaddr*)&caddr,&len);
if(c<0)
perror("accept error\n");
printf("accept c = %d\n",c);
return c;
}
//向事件表中注册事件
void EpollAdd(int epfd,int fd)
{
//创建epoll_event结构体
struct epoll_event ev;
ev.data.fd=fd;
ev.events=EPOLLIN;//这个事件和poll的很相似,前面加E
//将事件添加到事件表
if(epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&ev)==-1)
perror("epoll add error\n");
}
//从事件表中删除事件
void EpollDel(int epfd,int fd)
{
if(epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL)==-1)
perror("epoll del error\n");
}
int main()
{
int sockfd = CreateSocket();
//创建内核事件表
int epfd = epoll_create(MAXFD);
if(epfd==-1)
perror("epoll create errror\n");
//将监听套接字加入
EpollAdd(epfd,sockfd);
//用来存储事件发生变化的文件描述符和相关的信息
struct epoll_event events[MAXFD];
while(1)
{
int n = epoll_wait(epfd,events,MAXFD,5000);
if(n==-1)
perror("epoll wait error\n");
if(n==0)
printf("time out\n");
else
{
int i = 0;
for(;i<n;++i)
{
int fd = events[i].data.fd;
if(events[i].events&EPOLLIN)
{
//监听套接字
if(fd==sockfd)
{
int c =CreateConSocket(sockfd);
EpollAdd(epfd,c);
}
else
{
char buff[128]={0};
int res = recv(fd,buff,127,0);
if(res<=0)
{
EpollDel(epfd,fd);
close(fd);
printf("one client is over\n");
}
else
{
printf("recv %d : %s\n",fd,buff);
send(fd,"ok",2,0);
}
}
}
}
}
}
return 0;
}
代码运行结果如下图所示:
你以为epoll这就完了?想的太简单,接下来我介绍一些epoll的两种工作模式。
4.2 LT模式和ET模式
linux epoll的工作方式有两种,一种是LT(电平触发)模式,另一种是ET(边沿触发)模式。
LT模式:即当文件描述符上有数据的时候,如果一次没有读完,io复用函数会一直提醒我们知道数据读完。LT模式下有阻塞和非阻塞两种模式,epoll默认的工作方式是阻塞的LT模式。
ET模式:当数据描述符上有数据时,io复用函数只会提醒一次。因此在ET模式下,当文件描述符事件发生的时候,要一次将数据处理完,如果一次没有将数据处理完那么不会有第二次提醒。因此ET工作方式只有非阻塞模式,因为如果是阻塞模式的话,那么程序一定会阻塞在最后一次的write或者read函数。
如上述,ET模式很大程度上降低了同一个epoll事件被重复触发的次数,因此ET模式的效率要比LT模式高。
应用下面代码可以将文件描述符设置位非阻塞的:
void setnonblock(int fd)
{
int oldfl = fcntl(fd,F_GETFL);//取得文件标志位
int newfl = oldfl|O_NONBLOCK;//设置非阻塞模式
if(fcntl(fd,F_SETFL,newfl)==-1)
{
perror("fcntl error\n");
}
}
下面的代码实现了一个非阻塞的ET模式,大家可以参考下面代码来搞清楚上述函数setnoblock是怎么使用的。
# include<stdio.h>
# include<unistd.h>
# include<stdlib.h>
# include<string.h>
# include<assert.h>
# include<poll.h>
# include<netinet/in.h>
# include<sys/socket.h>
# include<arpa/inet.h>
# include<sys/epoll.h>
# include<fcntl.h>
# include<signal.h>
# define MAXFD 10
# include <errno.h>
int pipefd[2];
int create_sockfd()
{
int sockfd = socket(AF_INET,SOCK_STREAM,0);
if(sockfd ==-1)
{
return -1;
}
struct sockaddr_in saddr;
memset(&saddr,0,sizeof(saddr));
saddr.sin_family = AF_INET;
saddr.sin_port = htons(6000);
saddr.sin_addr.s_addr=inet_addr("127.0.0.1");
int res = bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));
if(res ==-1)
{
return -1;
}
listen(sockfd,5);
return sockfd;
}
void setnonblock(int fd)
{
int oldfl = fcntl(fd,F_GETFL);//取得文件标志位
int newfl = oldfl|O_NONBLOCK;//设置非阻塞模式
if(fcntl(fd,F_SETFL,newfl)==-1)
{
perror("fcntl error\n");
}
}
void epoll_add(int epfd,int fd)
{
struct epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLIN|EPOLLRDHUP;//开启et EPOLLET
if(epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&ev)==-1)
{
perror("epoll add error \n");
}
setnonblock(fd);
}
void epoll_del(int epfd,int fd)
{
if(epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL)==-1)
{
perror("epoll del error");
}
}
void fun(int sig)
{
write(pipefd[1],&sig,sizeof(sig));
}
int main()
{
int sockfd = create_sockfd();
assert(sockfd !=-1);
int epfd = epoll_create(MAXFD);//创建了一个内核事件表
assert(epfd!=-1);
epoll_add(epfd,sockfd);
pipe(pipefd);
epoll_add(epfd,pipefd[0]);
signal(SIGINT,fun);
struct epoll_event events[MAXFD];
int run =1;
while(run)
{
int n = epoll_wait(epfd,events,MAXFD,5000);
if(n==-1)
{
if(errno!=EINTR)
{
perror("epoll wait error\n");
}
}
if(n==0)
{
printf("time out\n");
}
else
{
int i = 0;
for(;i<n;++i)
{
int fd = events[i].data.fd;
if(events[i].events&EPOLLIN)
{
if(fd == sockfd)
{
struct sockaddr caddr;
int len = sizeof(caddr);
int c =accept(sockfd,(struct sockaddr*)&caddr,&len);
if(c<0)
{
continue;
}
printf("accept c = %d\n",c);
epoll_add(epfd,c);
}
else if(fd == pipefd[0])
{
int sig = 0;
read(pipefd[0],&sig,sizeof(sig));
printf("recv sig = %d",sig);
run = 0;
}
else
{
char buf[128]={0};
int res = recv(fd,buf,1,0);
if(res==0)
{
epoll_del(epfd,fd);
close(fd);
//epoll_del(epfd,fd);
printf("client %d is out\n",fd);
}
else if(res==-1)
{
send(fd,"ok",2,0);
break;
}
else
{
printf("recv(%d)=%s\n",fd,buf);
}
}
}
}
}
}
close(sockfd);
close(epfd);
printf("service over\n");
exit(0);
}
如上所示,ET模式除了在添加事件的时候要添加EPOLLRDHUP事件,开启ET模式外,还要设置非阻塞的文件描述符。
5 select、poll和epoll的区别
经过上面的几个示例,对三种模式有了很深的了解,三者的区别上述也断断续续有提到,下图展示了三种模式的详细区别。
I/O复用是网络编程的一个重点内容,面试也会经常问道,希望我的博文能够帮助大家进行理解。同时欢迎各位批评指正。