内核事件表
1、epoll使用一组函数来完成任务,而不是单个函数
2、epoll把用户关心的文件描述符上的事件放在内核里的一个事件表中,从而无需像select和poll一样每次调用都要重复传入文件描述符集或是事件集。
3、但是epoll需要使用一个额外的文件描述符,来唯一标志内核中的这个事件表
使用 int epoll_create(int size) 来创建,
size现在并不其作用,只是给内核一个提示,告诉它事件表需要多大
使用 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event) 操作epoll的内核事件表
fd参数是要操作的文件描述符,op参数指定操作类型,包含:
1、EPOLL_CTL_ADD
2、EPOLL_CTL_MOD
3、EPOLL_CTL_DEL
event参数指定事件,类型如下:
struct epoll_event
{
_uint32_t events; //epoll事件
epoll_data_t data; //用户数据
}
其中,events成员描述事件类型,与poll类型差不多,只是在其前面加‘E’,比如可读事件是EPOLLIN。但是epoll有两个额外的事件类型,EPOLLET, EPOLLONESHOT
其中,epoll_data_t类型如下:
typedef union epoll_data
{
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
}epoll_data_t;
可以看出,这里使用union,只能使用其中一个。
epoll_wait函数
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
函数成功时返回就绪的文件描述符个数
该函数如果检测到事件,就将所有就绪事件从内核事件表中复制到它的第二个参数events指向的参数中。这就是epoll函数高效率的一个因素,如下:
比较
//使用poll监听
int nr = poll(fds, MAXEVENT_NUMBER, -1);
for(int i=0; i<MAXEVENT_NUMBER; i++)
{
if(fds[i].revents & POLLIN)
{
int sockfd = fds[i].fd;
//deal with sockfd
}
}
//使用epoll
int nr = epoll_wait(epollfd, events, MAXEVENT_NUMBER, -1);
for(int i=0; i<nr; i++) //体现在这里
{
int sockfd = events[i].data.fd;
//deal with sockfd
}
LT和ET模式
epoll对文件描述符的工作方式有两种: 1、LT(Level Trigger) 2、ET(Edge Trigger)
默认工作方式是LT模式
对于采用LT工作模式的文件描述符,当epoll_wait侦测到其上有事件发生并将此事件通知应用进程后,应用程序可以不立即处理该事件。这样,应用程序下次调用epoll_wait时,epoll_wait还是会再次向应用程序通告此事件,直到该时间被处理
对于ET模式,当epoll_wait侦测到其上有事件发生并将此事件通知应用进程后,应用程序必须立刻处理事件,后续调用epoll_wait将不再通知此事件
下面给出使用epoll的两种模式的例子:
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
typedef int bool;
#define false 0
#define true 1
#define MAX_EVENT 1024
#define BUFFER_SIZE 10
void lt(struct epoll_event *events, int number, int epollfd, int listenfd);
void et(struct epoll_event *events, int number, int epollfd, int listenfd);
void setnonblocking(int fd);
void addfd(int epollfd, int fd, bool enable_et);
int main(int ac, char *av[])
{
if(ac != 3)
{
fprintf(stderr, "Usage: %s address port",av[0]);
}
char *ip = av[1];
int port = atoi(av[2]);
int ret;
struct sockaddr_in addr;
bzero(&addr, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
inet_pton(AF_INET, ip, &addr.sin_addr);
int sock = socket(AF_INET, SOCK_STREAM, 0);
if(sock < 0)
{
perror("socket error");
exit(1);
}
ret = bind(sock, (struct sockaddr *)&addr, sizeof(addr));
if(ret < 0)
{
perror("bind error");
exit(1);
}
ret = listen(sock, 10);
if(ret < 0)
{
perror("listen error");
exit(1);
}
//以上可忽略
//将监听套接字的读事件注册在内核事件表中,等待新的连接
struct epoll_event events[MAX_EVENT];
int epollfd = epoll_create(5);
if(epollfd == -1)
{
perror("listen error");
exit(1);
}
addfd(epollfd, sock, true);
while(1)
{
//等待epoll的返回
ret = epoll_wait(epollfd, events, MAX_EVENT, -1);
if(ret < 0)
{
perror("listen error");
exit(1);
}
lt(events, ret, epollfd, sock); //使用LT模式
//et(events, ret, epollfd, sock); //使用LT模式
}
close(sock);
return 0;
}
//设置描述符为非阻塞
void setnonblocking(int fd)
{
int op = fcntl(fd, F_GETFL);
op = op | O_NONBLOCK;
fcntl(fd, F_SETFL, op);
}
//将事件注册进内核事件表
void addfd(int epollfd, int fd, bool enable_et)
{
struct epoll_event event;
event.events = EPOLLIN;
event.data.fd = fd;
if(enable_et)
{
event.events |= EPOLLET;
}
epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
setnonblocking(fd);
}
//LT模式
//当epoll_wait侦测到其上有事件发生并将此事件通知应用进程后,应用程序可以不立即处理该事件。这样,应用程序下次调用epoll_wait时,epoll_wait还是会再次向应用程序通告此事件,直到该时间被处理
//注意,这里已经使用非阻塞
void lt(struct epoll_event *events, int number, int epollfd, int listenfd)
{
int i;
char buf[BUFFER_SIZE];
for(i=0; i<number; i++)
{
int sock = events[i].data.fd;
//如果是监听套接字有就绪事件
if(sock == listenfd)
{
int connfd = accept(listenfd, NULL, NULL);
if(connfd < 0)
{
perror("accept error");
exit(1);
}
addfd(epollfd, connfd, false);
}
//连接套接字有就绪事件
else if(events[i].events & EPOLLIN)
{
//这里我们只读一次,因为如果我们没有全部读完,这个描述符会出现在下一次的epoll_wait里,我依旧可以继续读
printf("lt :event trigger once \n");
memset(buf, '\0', BUFFER_SIZE);
int ret = recv(sock, buf, BUFFER_SIZE-1, 0);
if(ret <= 0)
{
close(sock);
continue;
}
printf("get %d bytes from the side\n", ret);
if(ret < BUFFER_SIZE-1) //表示读完了
close(sock);
}
else
{
printf("something else happened\n");
}
}
}
//ET模式
//对于ET模式,当epoll_wait侦测到其上有事件发生并将此事件通知应用进程后,应用程序必须立刻处理事件,后续调用epoll_wait将不再通知此事件
void et(struct epoll_event *events, int number, int epollfd, int listenfd)
{
char buf[BUFFER_SIZE];
int ret;
int i;
for(i=0; i<number; i++)
{
int sock = events[i].data.fd;
if(sock == listenfd)
{
int connfd = accept(listenfd, NULL, NULL);
if(connfd < 0)
{
perror("accept error");
exit(1);
}
addfd(epollfd, connfd, false);
}
else if(events[i].events & EPOLLIN)
{
printf("et :event trigger once\n");
//这里我们使用循环读,是因为事件只通知一次,我们必须一次就把所有数据都读完
while(1)
{
ret = recv(sock, buf, BUFFER_SIZE-1, 0);
//非阻塞读什么时候会报错呢?当没有数据可读的时候,非阻塞读就会报错,但是根据epoll返回,说明有数据可读,返回错误的话表示我们读完了
if(ret < 0)
{
if(errno == EAGAIN || errno == EWOULDBLOCK)
{
close(sock);
break;
}
}
else if(ret == 0)
{
close(sock);
}
else
printf("get %d bytes form the side\n", ret);
}
}
else
{
printf("something else happened\n");
}
}
}
首先,使用默认的LT模式得到的结果:
客户端:
$ telnet xxx.xxx.xxx.xxx 13000
Trying xxx.xxx.xxx.xxx...
Connected to Ben.
Escape character is '^]'.
jasndfknasfnsla
Connection closed by foreign host.
服务器端:
lt :event trigger once
get 9 bytes from the side
lt :event trigger once
get 8 bytes from the side
然后使用ET模式得到的结果为:
客户端为:
$ telnet xxx.xxx.xxx.xxx 13000
Trying xxx.xxx.xxx.xxx...
Connected to Ben.
Escape character is '^]'.
asfnaslfnaklnf
Connection closed by foreign host.
服务器端为:
et :event trigger once
get 9 bytes form the side
get 7 bytes form the side
可以很明显的看出差别
EPOLLONESHOT事件
现在我们仔细看ET模式,虽然ET模式指明下一次epoll_wait调用不会因为这次文件描述符没有被读完而再次被通知,但是如果进程在读取完某个socket上的数据后在处理这些数据时,该socket上又有新的数据可读(EPOLLIN再次被触发),在并发处理的情况下,另一个线程(进程)被唤醒来读取这些数据 ,于是就同时有两个线程在操作一个socket的局面,这当然不是我们锁期望的。 我们期望的是任何时刻一个socket只被一个线程处理。
这里就用到了EPOLLONESHOT。
以下为使用线程情况下需要使用EPOLLONESHOT的主要代码部分(都是非阻塞的):
//主函数中
//在epoll_wait的通知事件中,监听socket被通知
connfd = accept(...);
event.data.fd = connfd;
event.events = EPOLLIN | EPOLLET | EPOLLONESHOT;
//在epoll_wait的通知事件中,某个连接socket的EPOLLIN事件被通知
pthread_create(...,worker,..);
//worker函数主要部分
while(1)
{
ret = recv(....);
if(ret == 0)
{
//foreign closet the socket,对端终止连接
close(fd);
break;
}
else if(ret < 0)
{
if(errno == EAGAIN)
{
//已无数据可读
//重置已注册的这个事件,以确保下一次这个socket可读时,能被再次触发,被某个空闲线程处理
reset_oneshot(epollfd, fd);
printf("read later\n")
break;
}
}
else
{
printf("get content %s\n",buf);
//模拟处理数据的操作,假设需要花5秒,保证我们在处理这个socket上的数据时是独占着这个socket的
sleep(5);
}
}
这样保证了一个socket同一时刻只有一个线程在处理,在不同时刻可能被不同线程处理。
最后,进行select, poll, epoll的简单总结性比较
select的参数类型fd_set没有将文件描述符和事件绑定,仅仅是一个文件描述符集合,因此,select提供了三个参数分别指示读、写、异常,使得select不能处理更多类型事件。
另一方面由于内核对描述符集合的修改,下次调用select不得不重置这3个集合
poll相对聪明些,文件描述符和事件绑定起来,任何事件被统一处理,编程接口简洁。且无需像select每次调用都要重置,内核只修改revents成员。
但是,select和poll一样,返回的是整个用户注册的事件集合,所以时间复杂度为O(n)
epoll则不同。
epoll在内核中维护一个事件表,每次epoll_wait都是从该内核事件表中取得用户注册的事件,而无需反复从用户空间读入这些事件。
epoll_wait系统调用的events参数仅用来返回就绪事件,这使得应用程序索引就绪文件描述符的复杂度为O(1)
其次,select和poll都只能工作在LT模式,而epoll则可以使用ET模式
从实现原理上看,select和poll采用的都是轮询方式,每次调用都要扫描整个注册文件描述符集合,并将其中就绪的文件描述符返回给用户程序,因此它们检测事件的复杂度为O(N)
epoll则采用回调方式,内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符上对应的事件插入内核就绪事件队列,内核最后在适当的时机将该就绪事件队列的内容拷贝到用户空间。因此,epoll无需轮询整个描述符集合,复杂度为O(1)
但是,如果在活动连接较多时,回调函数触发过于频繁,效率就不见得高
因此, epoll适用于连接数量多,但活动连接较少的情况。