概述
在进行网络服务器开发时,为了提高服务器支持的连接处理能力,通常采用多线程、select轮询(或者poll函数轮询)来实现,由于多方面的原因,以上方式都不能完美解决大批量连接的问题。在Windows NT系统里,很早就有I/O Complete Port ,可以解决网络大批量连接的问题,但在Linux系统,直到内核版本2.5.44时,才开发出一个新的方法来处理大并发连接,这个方法就是epoll,在linux 内核2.6以后,epoll变得成熟,可以大胆使用了。本文就epoll的开发进行介绍说明。
概述
1.1:新增的专用函数3个
epoll是linux中新生的事物,专门用于处理大数量I/O 并发请求的,它不像select(或者poll)模型的轮询处理机制,对于大量的文件描述符处理,epoll确实能够作为解决方案。
epoll 新增了三个系统API 来对epoll进行调用。
epoll_create: 创建一个epoll内核对象,返回该内核对象的描述符句柄;epoll创建的是新的内核对象,不需要时需要调用系统函数close来关闭,否则会有内存泄漏问题;
epoll_ctl:对epoll对象进行控制,包括注册或修改感兴趣的文件描述符,注册的描述符集合称为epoll 集合;
epoll_wait: 监听epoll对象中是否有IO事件,调用线程将会阻塞在此函数里;
1.2:事件响应方式两种:水平触发和边沿触发
EPOLL事件分发接口有两种工作方式:边沿触发(edge-triggered, ET)和水平触发(level-triggered,LT)。边沿触发模式是epoll的高效工作模式。
水平触发是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表。
两种方式的不同可以通过下面的示例说明:
1:rfd是管道读一端的描述符,并且在epoll实例上进行了注册
2:管道另一端写入了2KB的数据
3:调用epoll_wait返回,rfd成为可读描述符
4:从rfd中读取1KB的数据
5:调用epoll_wait
如果rfd在注册到epoll实例时使用了EPOLLET标志,也就是边沿触发,则尽管在缓冲区中依然有数据可读,第5步的epoll_wait调用还是会阻塞,同时管道的另一端有可能在等待发送数据的回应。
原因在于,边沿触发模式,只有在监控的文件描述符状态发生变化的时候,才会触发事件。所以,第5步的epoll_wait有可能一直阻塞下去。上面的示例,第2步的数据写入导致了rfd上事件的产生,并且在第3步中对事件进行了触发。因第4步的读操作没有完全消费掉缓冲区中的数据,所以,第5步的epoll_wait会永远阻塞下去。
对于监听可读事件时,如果是socket是监听socket,那么当有新的主动连接到来为状态发生变化;对一般的socket而言,协议栈中相应的缓冲区有新的数据为状态发生变化。但是,如果在一个时间同时接收了N个连接(N>1),但是监听socket只accept了一个连接,那么其它未 accept的连接将不会在ET模式下给监听socket发出通知,此时状态不发生变化;对于一般的socket,如果对应的缓冲区本身已经有了N字节的数据,而只取出了小于N字节的数据,那么残存的数据不会造成状态发生变化。
仅当对方的动作(发出数据,关闭连接等)造成的事件才能导致状态发生变化,而本方协议栈中已经处理的事件(包括接收了对方的数据,接收了对方的主动连接请求)并不是造成状态发生变化的必要条件,状态变化一定是对方造成的。
使用边沿触发模式的epoll接口,建议是:将文件描述符置为非阻塞;只有在read或write返回EAGAIN后才继续等待事件。
即使在边沿触发模式下,如果收到了多次数据,那么事件也会产生多次。调用者可以指定EPOLLONESHOT标志,使得epoll在接收到一个事件之后,便不再监听相应的文件描述符。如果使用了该标志,则需要调用者用EPOLL_CTL_MOD,调用epoll_ctl,将该文件描述符重新置为监听。
1.3:/proc 接口限制
下面的接口可用来限制内核中epoll使用的内存总量:
/proc/sys/fs/epoll/max_user_watches (since Linux 2.6.28)
该接口限定了,每个实际用户ID,可以在所有epoll实例上注册的描述符总数。每个注册的描述符,在32位系统上大约耗费90字节,在64位系统上大约耗费160字节。
select 轮询的fd的数量由FD_SETSIZE设置,默认值是1024。对于那些需要支持的上万连接数目的IM服务器来说显然太少了。这时候你一是可以选择修改这个宏然后重新编译服务器代码,不过资料也同时指出这样会带来网络效率的下降,二是可以选择多进程的解决方案(传统的Apache方案),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的方案。不过 epoll则没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以 cat /proc/sys/fs/file-max 查看,一般来说这个数目和系统内存关系很大。
1.4:其它注意事项
如果在同一个epoll实例上,注册同一个描述符两次的话,则会返回EEXIST错误。
然而,可以向同一个epoll实例添加重复的描述符(dup, dup2),当重复的描述符注册不同的事件时,使用这种技巧可以用来过滤事件。
如果向两个epoll实例注册了相同的描述符,那么事件触发时,会通知到所有的epoll实例,但是这么做时,一定要小心。
epoll实例的描述符本身,也可以使用poll/epoll/select进行监听,如果该epoll实例正在等待事件,则该epoll实例就是可读的。
如果试图将epoll实例描述符注册到自己的epoll实例上,那么epoll_ctl会返回EINVAL错误。
尽管可以通过UNIX域套接字来传递epoll文件描述符,但是这样做是没有意义的,因为接收进程没有该epoll实例的描述符集合的副本。
关闭一个描述符,会导致该描述符从所有epoll集合中自动被移除。但前提是该描述符没有通过dup等函数进行过复制。一个描述符,在通过dup、dup2、fcntl的F_DUPFD,或者fork之后,会产生一个新的重复描述符,指向相同的文件表。因此,只有当引用同一文件表的所有重复描述符都关闭之后(引用计数为0),该描述符才会从epoll集合中删除。(可以使用EPOLL_CTL_DEL调用epoll_ctl来明确删除epoll集合中的该描述符)
这意味着,即使epoll集合中的某个描述符被关闭了,但是只要该描述符有打开的重复描述符,那该描述符上的事件,依然会被报告。
如果在epoll_wait调用时,发生了多个事件,则这些事件会合在一起进行报告。
在使用边沿触发时,是否需要持续调用read/write,直到它们返回EAGAIN,这取决于描述符类型,如果描述符是packet/token-oriented类型的,比如UDP数据报,或者canonical模式下的终端,则探测IO读写空间耗尽唯一方法就是持续调用read/write直到返回EAGAIN。
对于流式文件,比如管道、FIFO、流套接字等,则探测IO读写空间耗尽,还可以通过检测read/write返回值进行判断。比如,若调用read请求一定数量的数据,但是read返回值小于该请求数,则可以断定该描述符的IO读空间已经耗尽了。这种情形同样类似于write。
二:epoll 相关的API使用说明
2.1:epoll对象的创建
创建epoll对象是由epoll_create函数来完成的,但新的linux内核又增加了一个epoll_create1函数,连个函数调用类型,参数含义不相同。
#include<sys/epoll.h>
int epoll_create(int size);
int epoll_create1(int flags);
epoll_create创建一个epoll对象实例。size参数,自Linux2.6.8开始就被忽略了,但是它的值必须大于0。
epoll_create返回epoll实例的文件描述符,该描述符用于后续的epoll接口调用。当不在需要时,该描述符需要用close关闭。如果一个epoll实例的所有描述符都关闭了,则内核会销毁该实例,并释放相关资源。
epoll_create1,如果flags参数为0,则除了省略了size参数之外,它与epoll_create是相同的。如果flags参数不为0,则目前它只能是EPOLL_CLOEXEC,用于设置该描述符的close-on-exec(FD_CLOEXEC)标志。
这两个系统调用,成功时返回非负的文件描述符,失败时返回-1(unix系统函数失败大多数是返回-1的).
epoll_create是在2.6内核版本中引入,在glibc2.3.2开始支持;
epoll_create1是在2.6.27内核版本中引入,在glibc2.9开始支持。
最初的epoll_create实现中,size参数告诉内核,调用者期望添加到epoll实例中的文件描述符数量。内核使用该信息作为初始分配内部数据结构空间大小的“提示”(如果调用者的使用超过了size,则内核会在必要的情况下分配更多的空间)。目前,这种“提示”已经不再需要了,内核动态的改变数据结构的大小,但是为了保证向后兼容性(新的epoll应用运行于旧的内核上),size参数还是要大于0。
2.2 对epoll对象进行操作
#include<sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
该系统调用控制在epoll实例epfd上的执行动作。在epoll实例上注册、注销、更改文件描述符。
该调用会在描述符fd上执行op操作,op的值可以有:
EPOLL_CTL_ADD,在epoll实例epfd上注册文件描述符fd,并将事件event关联到fd。
EPOLL_CTL_MOD,更改fd上关联的事件为event
EPOLL_CTL_DEL,在epoll实例epfd上,删除(注销)描述符fd。event参数被忽略,可以为NULL(在2.6.9之前,即使event参数被忽略,它也不能为NULL。自2.6.9起,操作EPOLL_CTL_DEL上,event才可以为NULL。如果应用程序需要在2.6.9之前的内核上运行,则需要指定非NULL的event)。
event参数,描述了关联到fd上的事件对象。struct epoll_event的定义如下:
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 */
};
成员data字段由用户使用。监听事件触发后,data会被返回给用户。通常将event.data.fd设定为fd,这样就可以知道哪个文件描述符触发了事件。ptr成员可用来指定与fd相关的用户数据,但因为data是个union,所以,不能同时使用ptr和fd,因此,可以使用其他手段使得文件描述符和用户数据关联起来,比如放弃使用fd,而在ptr指向的用户数据中包含fd。
成员events是由下列值组成的位数组:
EPOLLIN,文件可读;
EPOLLOUT,文件可写;
EPOLLRDHUP (since Linux 2.6.17),流式套接字,对端关闭了连接,或者半关闭了写操作。该标志,在边沿触发模式下检测对端关闭时,是很有用的;
EPOLLPRI,读操作上有紧急数据;
EPOLLERR,文件描述符上发生了错误。epoll_wait始终监听该事件,无需再events上设置;
EPOLLHUP,文件描述符上发生了Hang up。epoll_wait始终监听该事件,无需再events上设置;
EPOLLET,置文件描述符为边沿触发模式,epoll上默认的行为模式是水平触发模式;
EPOLLONESHOT (since Linux 2.6.2),置文件描述符为一次性的(one-shot)。这意味着,当该文件上通过epoll_wait触发了一个事件之后,该文件描述符就被内部disable了,在epoll实例上不再报告该文件描述符上的事件了。用户必须用EPOLL_CTL_MOD调用epoll_ctl,以新的事件掩码再次注册该描述符。
epoll_ctl成功时返回0,失败返回-1.
2.3 等待事件通知
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
int epoll_pwait(int epfd, struct epoll_event *events, int maxevents, int timeout, const sigset_t *sigmask);
epoll_wait系统调用在epoll实例epfd上等待事件的发生。成功返回时,events指向的内存空间,会记录触发的事件。maxevents告知内核events的大小。epoll_wait的返回值最大为maxevents, maxevents参数必须大于0.
当调用返回时,如果检测到事件,就将所有就绪的事件从内核事件表中复制到events指向的数组中。这个数组只用于输出epoll_wait检测到的就绪事件,而不像select和poll的数组参数那样,即用于传入用户注册的事件,又用于输出检测到的事件。
timeout参数指定epoll_wait的阻塞时间,单位为毫秒。epoll_wait会一直阻塞,直到:
某个文件描述符上触发了一个事件;
epoll_wait调用被信号中断;
超时时间到;
注意,timeout在内部会向上取整到系统时钟粒度,而且由于内核调度的原因,阻塞时间会有少量的延长。如果timeout为-1,意味着epoll_wait会一直阻塞;如果timeout为0,则即使没有事件发生,epoll_wait也会立即返回。
结构体epoll_event的定义见上,epoll_wait返回时,该结构中的data成员,与调用epoll_ctl(EPOLL_CTL_ADD, EPOLL_CTL_MOD)时的data一样。events成员包含触发的事件掩码。
epoll_wait和epoll_pwait之间的关系,类似于select和pselect之间的关系:epoll_pwait可以使应用程序安全的等待信号的发生。下面的语句:
ready = epoll_pwait(epfd, &events, maxevents, timeout, &sigmask);
等价于下列语句的原子执行:
sigset_t origmask;
sigprocmask(SIG_SETMASK, &sigmask, &origmask);
ready = epoll_wait(epfd, &events, maxevents, timeout);
sigprocmask(SIG_SETMASK, &origmask, NULL);
如果将sigmask置为NULL,则epoll_pwait等价于epoll_wait。
epoll_wait成功时返回准备好的文件描述符数,如果超时时间到了,还没有准备好的文件描述符,则返回0.出错时返回-1.
epoll_wait自2.6内核引入,glibc2.3.2开始支持;epoll_pwait自2.6.19内核引入,glibc2.6开始支持.
注意,如果某个线程阻塞于epoll_wait时,另一个线程向相同的epoll实例上添加了新的文件描述符,而且该描述符上的事件触发,则会导致原来阻塞于epoll_wait上的线程停止阻塞。
在select系统调用中,如果select上监听的描述符,在其他线程上被关闭了,则这种行为是未定义的。某些UNIX系统上,select会停止阻塞直接返回,并且将该描述符视为准备好的(接下来的IO操作会发生错误)。在Linux(以及其他系统)上,在其他线程上关闭该描述符对select无影响,epoll上的处理方式也一样。
在2.6.37之前,如果timeout参数大于(LONG_MAX/HZ)毫秒的话,则会被视为-1,也就意味着永远等待。所以,如果某系统上sizeof(long)等于4,HZ等于1000,则如果timeouts大于35.79的话,就会给视为无限等待。((2^31 – 1) /1000/1000/60 == 35.79)
三:epoll API使用例子
3.1 基本的调用
对于采用LT工作模式的文件描述符,当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。当应用程序下一次调用epoll_wait时,epoll_wait还会再次向应用程序通告此事件,直到该事件被处理。也就是说只要还有没有处理的事件就会一直通知。
而对于采用 ET工作模式的文件描述符,当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因为后续的epoll_wait调用将不再向应用程序通知这一事件。可见,ET模式在很大程度上降低了同一个epoll事件被重复触发的次数,因此效率要比LT模式高。边沿触发需要一个不同的方式来写程序,通常利用非阻塞IO。并需要仔细检查EAGAIN。
下面的代码体现了LT和ET在工作方式上的差异。
#define MAX_EVENT_NUMBER 1024
#define BUFFER_SIZE 10
int setnonblocking( int fd )
{
int old_option = fcntl( fd, F_GETFL );
int new_option = old_option | O_NONBLOCK;
fcntl( fd, F_SETFL, new_option );
return old_option;
}
void addfd(int epollfd, int fd, bool enable_et)
{
struct epoll_event event;
event.data.fd = fd;
event.events = EPOLLIN;
if( enable_et )
{
event.events |= EPOLLET;
}
epoll_ctl( epollfd, EPOLL_CTL_ADD, fd, &event );
setnonblocking(fd);
}
void lt(struct epoll_event* events, int number, int epollfd, int listenfd )
{
char buf[ BUFFER_SIZE ];
for ( int i = 0; i < number; i++ )
{
int sockfd = events[i].data.fd;
if ( sockfd == listenfd )
{
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof( client_address );
int connfd = accept(listenfd, (struct sockaddr* )&client_address, &client_addrlength );
addfd( epollfd, connfd, false );
}
else if ( events[i].events & EPOLLIN )
{
printf( "event trigger oncen" );
memset( buf, '