上次我们介绍了多路复用IO的select。poll和select十分类似,把用户传入的数组拷贝到内核空间,然后查询每个fd的状态,如果对应设备就绪就放入等待队列中,如果没有设备就绪就把进程挂起,直到有设备就绪或者超时了,但是他比select好的一点是他没有数目限制,因为poll是基于链表来存储的,但是还是和select有相同的最大缺点:大量的fd数组从用户复制到内核,并且poll还有一个特点是水平触发,如果报告了fd没有被处理,那么下次poll还会再次报告这个fd文件描述符。随着文件描述符数量的增长效率会大大下降。
所以这里就重点介绍一下epoll的实现和使用
//这是一个基于epoll多路复用的服务端
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<fcntl.h>
#include<string.h>
#include<errno.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<sys/socket.h>
#include<sys/epoll.h>
int recv_data(int fd,char *buff)
{
int ret;
int alen=0;
while(1)
{
ret=recv(fd,buff+alen,2,0);
if(ret<=0)
{
if(errno==EAGAIN)
{
break;
}
else if(errno==EINTR)
{
continue;
}
return -1;
}
else if(ret<2)
{
break;//代表数据读取完了
}
alen+=ret;
}
}
int main(int argc,char *argv[])
{
if(argc!=3)
{
perror("please ip port\n");
return -1;
}
int i=0;
char buff[1024]={0};
int sockfd=socket(AF_INET,SOCK_STREAM,0);
if(sockfd<0)
{
perror("sockfd error\n");
return -1;
}
struct sockaddr_in srv_addr;
struct sockaddr_in cli_addr;
srv_addr.sin_family=AF_INET;
srv_addr.sin_port=htons(atoi(argv[2]));
srv_addr.sin_addr.s_addr=inet_addr(argv[1]);
socklen_t len=sizeof(struct sockaddr_in);
int ret=bind(sockfd,(struct sockaddr*)&srv_addr,len);
if(ret<0)
{
perror("bind error\n");
return -1;
}
if(listen(sockfd,5)<0)
{
perror("listen error\n");
return -1;
}
int epfd=epoll_create(10);
//在内核创建eventpoll结构 size只要大于0就可以返回的是epoll句柄
if(epfd<0)
{
perror("epoll_create error\n");
return -1;
}
struct epoll_event ev;
ev.events=EPOLLIN;
ev.data.fd=sockfd;
epoll_ctl(epfd,EPOLL_CTL_ADD,sockfd,&ev);
//epoll_ctl 向内核中添加事件函数
//epfd epoll的句柄
//第二个参数为当前要进行的操作函数
//EPOLL_CTL_ADD 添加事件
//EPOLL_CTL_MOD 修改事件
//EPOLL_CTL_DEL 删除事件
//第三个参数是所监控的文件描述符
//第四个参数为所关联的事件
//EPOLLIN 可读事件
//EPOLLOUT 可写事件
//EPOLLET 边缘触发属性
//每当有新数据到来的时候时间就会触发,如果这次没有读取完,也就是还有数据需要处理,但是不会再进行提醒。所以这种特性下我们需要一次性把缓冲区内的数据全部读完。
//EPOLLLT 水平触发特性
//水平特性和边缘正好相反,只要缓冲区中有数据可以读取就不断的读,默认是水平触发特性
while(1)
{
struct epoll_event evs[10];
int nfds=epoll_wait(epfd,evs,10,3000);
//用来设置epoll的等待时间
//第一个参数是epoll句
//第二个是定义的结构体数组用于获取就绪的描述事件这个时间就是我们之前添加描述符关联的时间
//第三个参数是能够获取的最大就绪的事件个数和上边定义的数组大小相等
//epoll最大超时等待时间 单位毫秒
//如果返回值为-1的话代表错误。返回0代表超时。返回值大于0代表就绪的个数
if(nfds<0)
{
perror("epoll_wait error");
return -1;
}else if(nfds==0)
{
printf("have no data arrived!!\n");
continue;
}
for(i=0;i<nfds;i++)
{
if(evs[i].data.fd==sockfd)
{
//和select一样特殊处理监听描述符
int new_fd=accept(sockfd,(struct sockaddr*)&cli_addr,&len);
if(new_fd<0)
{
perror("accept error\n");
continue;
}
ev.events=EPOLLIN|EPOLLET;
ev.data.fd=new_fd;
epoll_ctl(epfd,EPOLL_CTL_ADD,new_fd,&ev);
}
else
{
char buff[1024]={0};
ret=recv_data(evs[i].data.fd,buff);
if(ret<=0)
{
if(errno==EAGAIN||errno==EINTR)
{
continue;
}
epoll_ctl(epfd,EPOLL_CTL_DEL,evs[i].data.fd,&ev);
close(evs[i].data.fd);
}
printf("client say:%s\n",buff);
}
}
}
close(sockfd);
return 0;
}
epoll里边最大的特点在于水平触发和边缘触发,水平触发是当我们缓冲区有数据的时候就一直提醒,但是边缘触发是当有了消息来了之后只进行一次提醒,如果这次读取了之后缓冲区的数据没有读取完,下次就不再进行提醒。LT也就是水平触发是epoll默认的方式,也就是说当前如果有一个文件描述符就绪了,你可以不马上进行处理,会一直提醒你,但是ET也就是边缘触发模式,是高速工作方式,如果某个未就绪的事件变成了就绪状态,epoll会告诉你,并且不会再为那个描述符发送更多的请求通知,直到你做了某些事情让描述符的状态不再是就绪状态。ET模式很大程度上减少了epoll事件被重复触发的次数。ET模式下必须使用非阻塞的套接字,以免造成某一个文件描述符阻塞让其他文件描述符饿死的情况。
Select、poll进程只有在调用一定的方法之后,内核才会对所有监视的文件描述符进行扫描,而会通过epoll_ctl()来注册一个文件描述符,一旦某个文件描述符就绪之后,内核会采用类似的回调机制,迅速激活对应的文件描述符。这样当有大量等待链接的时候epoll的效率会比select和poll高出很多。
这里我们对比一下select poll和epoll
-
select 链接数有限制,一般来说是1024个,poll和select本质上没有区别,但是链接数不存在限制,因为poll是基于链表来实现的,epoll虽然链接数有一定的限制,但是1G内存的机器可以打开10w左右的链接。
-
当链接数目剧增的时候select和poll每次都会对所有的链接进行线性遍历,所以当fd增加的时候就会出现性能下降的问题。但是epoll是通过callback回调函数来实现的所以即使有大量的链接,也不会出现性能下降的问题,但是当所有链接都十分活跃的时候可能会出现性能下降的问题。
-
Select和poll在传递消息的时候需要将消息传递到用户空间,都需要内核进行拷贝动作,但是epoll是通过内核和用户之间一块共享内存来实现的。