epoll的产生
上一篇文章中我们讲解了多路I/O复用中的select、poll,它们存在的主要问题有两个:
(1)线程不安全
(2)函数不能告诉应用程序具体需要处理那一路I/O,需要应用程序轮询判断。
基于select和poll存在的这些问题,于是在poll出现的5年之后,也就是2002年,大神Davide Libenzi实现了epoll。epoll是I/O多路复用的最新一个实现,epoll修复了poll和select的绝大部分问题,比如:
(1)epoll是线程安全的。
(2)epoll不仅可以告诉应用程序sock组中数据,还可以告诉应用程序具体是那个sock有数据,不需要程序自己判断。
多路I/O复用----epoll
epoll是linux特有的I/O复用函数,它在实现和使用上与select、poll有很大差异。通过上一篇文章我们直到select和poll都是一个函数,而epoll则是一组函数。epoll将用户关心的文件描述符上的事件放在了一个内核事件表中,从而无须像select和poll那样每次调用都要重复传入文件描述符集后事件集。但epoll需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表。这个文件描述符通过epoll_create来创建。
相关函数:
1、epoll_create
#include <sys/epoll.h>
//创建内核事件表
int epoll_create(int size);
参数说明:
(1)size:size参数现在并不起作用,只是给内核一个提示,告诉它事件表需要多大。
返回值:函数返回值为事件表的文件描述符。
2、epoll_ctl
//操作内核事件表
int epoll_ctl(int epfd,int op,int fd,struct epoll_event *event);
参数说明:
(1)epfd:内核事件表的文件描述符。
(2)op:指定操作类型。操作类型有以下3种:
EPOLL_CTL_ADD //向事件表中注册fd上事件
EPOLL_CTL_MOD //修改fd上注册的事件
EPOLL_CTL_DEL //删除fd上注册的事件
(3)fd:要操作的文件描述符。
(4)event:作用是指定事件。epoll_event的定义如下:
struct epoll_event
{
_uint32 events; //epoll事件
epoll_data_t data; //用户数据
};
其中events成员描述事件类型。poll支持的事件类型epoll基本支持的,使用时只需要将poll事件中宏的前缀“POLL”换成“EPOLL”就可以了。除了这些事件类型之外,epoll还额外支持EPOLLET和EPOLLONSHOT两个事件类型。这两个事件时epoll高效运行的关键。
data成员用于存储用户数据,epoll_data_t的定义如下:
typedef union epoll_data
{
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
}epoll_data_t;
epoll_data_t是一个联合体,其中使用最多的时fd,它可以指定事件所从属的目标文件描述符。ptr成员可以用来指定与fd相关的用户数据,但同时只能使用一个成员,所有如果要将文件描述符和用户数据关联起来,以实现快速访问,就必须使用其他的方法,在用户数据中包含fd,这样就可以不适用fd成员。
返回值:epoll_ctl成功返回0;失败返回-1,并设置errno。
3、epoll_wait
#include <sys/epoll.h>
int epoll_wait(int epfd,struct epoll_event* evnts,int maxevents,int timeout);
参数说明:
(1)epfd:内核事件表的文件描述符。
(2)events:指定事件。
(3)maxevents:指定最多监听多少事件。
(4)timeout:和poll函数的timeout参数意义相同。
返回值:和poll的返回值意义相同。
Epoll_wait函数如果检测到事件,就将所有就绪事件从内核事件表(由epfd参数指定)中复制到第二个参数events指向的数组中。这个数组只作用于epoll_wait检测到的就绪事件,所有在判断数据的来源就很方便。
LT和ET模式:
epoll对文件描述符的操作由两种模式:LT(Level Trigger,电平触发)模式和ET(Edge Trigger,边沿触发)模式。那这两种工作模式有什么区别那?
LT(电平触发)模式:epoll默认的工作模式,这种模式下的epoll相当于一个效率较高的poll。对于工作在LT工作模式下的文件描述符,当epoll_wait检测到其上有事件发生并通知应用程序后,应用程序可以不立即处理该事件。这样,当应用程序下一次调用epoll_wait时,epoll_wait还会再次向应用程序通知此事件,直到该事件被处理。
ET(边沿触发)模式:当向epoll内核事件表中注册一个EPOLLET事件时,epoll将以ET模式操作应用程序,ET模式是一种高效的工作模式。对于工作在ET模式下的文件描述符,当epoll_wait检测到其上有事件发生并将此事件通知给应用程序,应用程序必须立即处理该事件,因为后续的epoll_wait调用将不再向应用程序通知此事件。ET模式在一定程度上大大降低了同一个epoll事件被重复触发的次数,因此效率要比LT模式高。需要注意的是在ET模式下的文件描述符都应该是非阻塞的,如果文件描述符是阻塞的,那么读或写操作将会因为没有后续事件而一直处于阻塞状态(饥渴状态)。
EPOLLONESHOT事件
即使使用ET模式,一个socket上的某些事件还是可能被触发多次。这在并发程序中就会引起一个问题。比如一个线程(或进程,下同)在读取完某个socket上的数据后开始处理这些程序,而在数据处理的过程中该socket上又有新的数据可读(EPOLLIN再次被触发),此时就会出现两个线程同时处理一个socket,这样就会产生一定的问题。这可以利用epoll的EPOLLONESHOT来解决。
对于注册了EPOLLONESHOT事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或异常事件,且只触发一次,除非使用epoll_ctl函数重置该文件描述符的EOPLLONESHOT事件。这样,当一个线程在处理某个socket时,其他线程不能再操作该socket。再处理完数据后,将EPOLLONESHOT重置,这样就可以让其他线程再次使用(和锁的效果差不多)。
简单epoll服务器程序:
#include <time.h>
#include <stdio.h>
#include <string.h>
#include <sys/epoll.h>
#include "sockErrHand.h"
#define PORT 5500
#define MAXLISTEN 32
#define MAXSIZE 512
int main()
{
int serSock=Socket(AF_INET,SOCK_STREAM,0);
struct sockaddr_in serAddr;
bzero(&serAddr,sizeof(serAddr));
serAddr.sin_family=AF_INET;
serAddr.sin_port=htons(PORT);
serAddr.sin_addr.s_addr=htonl(INADDR_ANY);
Bind(serSock,(struct sockaddr*)(&serAddr),sizeof(serAddr));
int eventList=epoll_create(MAXLISTEN);
struct epoll_event cliEvent;
struct epoll_event cliEvents[MAXLISTEN];
cliEvent.events=EPOLLIN;
cliEvent.data.fd=serSock;
if(epoll_ctl(eventList,EPOLL_CTL_ADD,serSock,&cliEvent)<0)
{
Exit("epoll_ctl");
}
Listen(serSock,MAXLISTEN);
printf("SERVER LISTEN\n");
while(1)
{
//epoll_wait成功返回准备好的文件描述符数目
int nReady=epoll_wait(eventList,cliEvents,MAXLISTEN,-1);
if(nReady<0)
{
Exit("epoll_wait");
}
int i=0;
for(i=0;i<nReady;++i)
{
//有客户端连接
if(cliEvents[i].data.fd==serSock)
{
struct sockaddr_in cliAddr;
bzero(&cliAddr,sizeof(cliAddr));
socklen_t cliAddrLen=sizeof(cliAddr);
int cliSock=Accept(serSock,(struct sockaddr*)(&cliAddr),&cliAddrLen);
cliEvent.events=EPOLLIN;
cliEvent.data.fd=cliSock;
epoll_ctl(eventList,EPOLL_CTL_ADD,cliSock,&cliEvent);
}
else
{
//处理客户数据
int i=0;
char dataBuf[MAXSIZE];
memset((void*)dataBuf,0,MAXSIZE);
Read(cliEvents[i].data.fd,(void*)dataBuf,MAXSIZE);
if(strcmp(dataBuf,"Time\n")==0)
{
memset((void*)dataBuf,0,MAXSIZE);
time_t t;
time(&t);
sprintf(dataBuf,"进程号:%d\n当前服务器时间为:%s\n",getpid(),ctime(&t));
}
else
{
sprintf(dataBuf,"%s无效命令\n",dataBuf);
}
Write(cliEvents[i].data.fd,(void*)dataBuf,strlen(dataBuf));
}
}
}
Close(serSock);
return 0;
}
运行结构展示:
浅谈epoll:
再使用epoll开发服务器时,就需要使用上面介绍的三个函数。
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_ctl(int epfd,int op,struct epoll_event *event);
int epoll_wait(int epfd,struct epoll_event *events,int maxevents,int timeout);
epoll_create的作用:创建一个epoll对象(内核事件表),参数size内核保证能够正确处理的最大句柄数(socket),多于最大数时内核不能保证效果。
epoll_ctl:可以操作上面epoll(内核事件表),添加/修改/删除。
epoll_wait:在规定的事件内,当监视的所有句柄(socket)有事件发生时,就返回用户态的继承。
为什么epoll的效率比select和poll的效率高?
(1)epoll的实现利用了红黑树。在我们调用epoll_create函数之后,内核就会创建一棵红黑树用来存储之后epoll_ctl传来的socket。
(2)epoll的在内部还建立了一张“就绪socket”的链表,用于存储准备就绪的事件。
当函数返回时,直接返回“就绪socket”的链表即可。
这样利用一棵红黑树和一张“就绪socket”链表就可以解决大并发下的socket处理问题。
从这里我们也可以看出epoll的比较使用的情况是:虽然服务器上的连接很多,但同时活跃的用户很少的情况,一旦活跃的用户很多,那么epoll的效率的就会退化的poll差不多(活跃用户多就会成为轮询模型,因为那个用户基本都要处理)。
参考文章:
select、poll和epoll的区别:
系统调用 | select | poll | epoll |
事件集合 | 用户通过3个参数分别传输需要检测的事件(可读、可写、异常),内核通过对这些参数的在线修改来反馈其中的就绪事件。这使得用户每次调用select都要重置这3个参数 | 统一处理所有事件类型,因此只需一个事件集参数。用户通过pollfd,events传入需要检测的事件,内核通过修改pollfd,revents反馈其就绪事件,所以不需要在重新传入events | 内核通过一个事件表直接管理用户需要检测的事件,因此每次调用epoll_wait时,无需反复传入用户需要检测的事件。 |
返回事件集 | 全部事件集 | 全部事件集 | 就绪事件集 |
应用程序索引就绪文件描述符的事件复杂读 | O(n)轮询 | O(n)轮询 | O(1) |
最大支持 文件描述数 | 1024 | 65535(端口号最大值) | 65535 |
工作模式 | LT(电平触发) | LT | LT和ET(边沿触发) |
内核实现 和工作效率 | 采用轮询方式来检测就绪事件,算法事件复杂度为O(n) | 采用轮询方式来检测就绪事件,算法事件复杂度为O(n) | 采用回调方式来检测就绪事件,算法事件复杂度为O(1) |