1、引言 在我们之前对I/O复用技术中poll和select有一定的了解。
那我们假设这样一个场景:有100万用户同时与一个进程保持着TCP连接,而每一时刻只有几十个或几百个TCP连接是活跃的(接收TCP包),也就是说在每一时刻进程只需要处理这100万连接中的一小部分连接。那么,如何才能高效的处理这种场景呢?进程是否在每次询问操作系统收集有事件发生的TCP连接时,把这100万个连接告诉操作系统,然后由操作系统找出其中有事件发生的几百个连接呢?其实不然,进程收集有事件的连接时,其实这100万连接中的大部分都是没有事件发生的。因此如果每次收集事件时,都把100万连接的套接字传给操作系统,而由操作系统内核寻找这些连接上有没有处理的事件,将会是巨大的资源浪费。然而select和poll就是这样做的,因此他们最多只能处理几千个并发连接。
2、epoll概念 epoll是在2.6内核中提出的,是之前的select和poll的增强版本,是linux操作系统独有的I/O复用技术。相比于select和poll来说,epoll更加灵活。
epoll提供了一组方法。 1、创建一个内核事件表
int epoll_create(int size) 功能:该函数生成一个 epoll 专用的文件描述符。调用这个函数的时候,在内核cache里建立了红黑树用于存储以后epoll_ctl传来的socket,还建立了一个双向链表用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个双向链表里有没有数据即可,有数据就返回。size参现在并不起作用,只是给内核一个提示,告诉它事件表需要多大。注意!!!在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽返回值:成功返回epoll专用的文件描述符,失败返回-1.2、管理内核事件表
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 功能:在这里注册要监听的事件类型参数: epfd:epoll_create()的返回值op:表示动作,用三个宏来表示 EPOLL_CTL_ADD:注册新的 fd 到 epfd 中; EPOLL_CTL_MOD:修改已经注册的fd的监听事件; EPOLL_CTL_DEL:从 epfd 中删除一个 fd。 fd:需要监听的文件描述符events:保存事件类型,告诉内核要监听什么事件。调用它的时候才会产生一次拷贝,把用户的事件描述符拷贝到内核事件表中。struct epoll_event { Short events;//事件类型:在每一个poll的事件类型标识前加个‘E’ Union epoll_data_t data(联合体,用到其中的fd成员即文件描述符,其他的不用) } 返回值:成功返回0,失败返回-1【举个栗子】假如现在我们将监听文件描述符添加到内核事件表中:
struct epoll_event event; event.events=EPOLLIN;//监听读事件 event.data.fd=listenfd;//被监听的文件描述符 int res=epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&event);//将event结构体中存储的事件类型添加到内核事件表中。3、启动监听文件描述符上的事件
int epoll_wait(int epfd,struct epoll_event events[],int maxevents,int timeout) 功能:监听内核事件表成功返回就绪个数参数 epfd:epoll专用的文件描述符,epoll_create()的返回值;events:由内核在epoll_wait返回时填充的(有事件就绪的文件描述符和就绪的事件类型)maxevents:数组长度(表示一次epoll_wait最多返回多少个就绪的文件描述符)timeout:监听时间,-1表示永久阻塞总结 还是以刚刚我们假设的这个例子来加以说明。 1、调用epoll_create建立一个epoll对象 2、调用epoll_ctl向epoll对象中添加这100万个连接的套接字 3、调用epoll_wait收集发生事件的连接 这样,我们只需要在进程启动的时候建立一个epoll对象,并在需要的时候向它添加或删除连接就可以了。因此,在实际收集事件时,epoll_wait的效率就会非常高,因为调用epoll_wait时并没有向它传递100万个连接,内核也不需要去遍历全部连接
速度快 因为不论是select中使用的fd_set还是poll里面使用的struct pollfd fds[]。他们都是用户创建的,在用户态里面存在。每调用一次都会存在在用户态和内核态的数据拷贝,而且这种拷贝不止一次,一次是在调用时,一次是在返回时。而epoll直接就在内核中记录,在监听时不需要进行拷贝,速度比较快
用户检索就绪事件时间复杂度低 在select和poll中都是以轮询的方式来检测就绪事件,时间复杂度是O(n)。但是在epoll中就直接返回就绪事件数组,所以每次都是就绪的,不用花费时间去寻找,时间复杂度就是O(1)
采用回调的方式检测检测文件描述符 在select 和poll里面都是采用轮询的方式检测是否有事件发生。适合于关注的文件描述符每次都有很大的几率就绪的情况。 而epoll采用回调的方式。假设1000个,把文件描述符添加到事件表上,文件描述符在红黑树上挂载,除了值,事件类型,还带有一个回调函数。回调函数的作用就是有事件就绪时就把文件描述符添加到双向链表中,返回时把链表的值拷贝到用户态。1000个一个事件发生只会触发一次回调,不用多次循环。适合很多文件描述符,就绪事件描述符少。
1、原理
epoll 在初始化时向内核注册了一个特殊的文件系统,用来存储被监控的 socket,同时还会开辟出 epoll 自己的高速 cache 区,用于安置所监控的 fd,这些文件描述符以红黑树的形式存储在 cache 中,便于快速的插入、修改和删除。分配好想要的内存对象大小,每次使用时直接使用空闲的已分配好的对象就可以了。epoll_create 函数首先会在虚拟的 epoll 文件系统中申请一个 file 结点,在内核 cache 中建立 RBTree,通过 epoll_ctl 将添加进来的 fd 挂载到 RBTree 上,其实这些 fd 已经是在内核态了,那么我们调用 epoll_wait 时并不需要再拷贝进内核态。同时会 建立 list 来存储我们已就绪的文件描述符,那么调用 epoll_wait 时,我们会检查该 list 是否为空,不为空表示有就绪事件,返回;为空,等待指定事件然后返回这个 list 链表如何维护?当我们调用 epoll_ctl 将 fd 添加进内核事件表时,内核还会注册一个针对该 fd 的回调函数,当其就绪时,那么内核调用该回调函数把该 fd 存入 list 中。2、epoll事件类型
表示含义EPOLLIN读事件EPOLLOUT写事件EPOLLERR错误事件EPOLLONESHORT只触发一次,事件自动被删除EOPLLET设置为边沿触发这里的epoll代码,和前面两个的实现相比主要有以下的区别:
不用每次调用epoll_wait前初始化内核事件表。struct epoll_event *event结构体存储用户填写的事件描述符和事件类型,struct epoll_event events[]保存内核填写的就绪事件描述符和事件类型。数据不会影响。不用自己写删除,添加,修改函数,直接调用epoll_ctl即可在有新的连接就把标志位设为添加,每次关闭一个就删除一个。在处理数据时,不用循环遍历struct epoll_event events[]找就绪事件具体的程序流程图参考博文epoll详解
#define _GNU_SOURCE #include<stdio.h> #include<stdlib.h> #include<unistd.h> #include<string.h> #include<assert.h> #include <sys/types.h> #include <sys/socket.h> #include <arpa/inet.h> #include <netinet/in.h> #include <sys/epoll.h> #define SIZE 100 int CreateSocket() { int listenfd = socket(AF_INET, SOCK_STREAM, 0); if(listenfd == -1) return -1; struct sockaddr_in ser_addr; memset(&ser_addr, 0, sizeof(ser_addr)); ser_addr.sin_family = AF_INET; ser_addr.sin_port = htons(6000); ser_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); int res = bind(listenfd, (struct sockaddr*)&ser_addr, sizeof(ser_addr)); if(res == -1) return -1; res = listen(listenfd, 5); if(res == -1) return -1; return listenfd; } void GetNewLink(int fd, int epfd) { struct sockaddr_in cli_addr; socklen_t len = sizeof(cli_addr); int c = accept(fd, (struct sockaddr*)&cli_addr, &len); assert(c != -1); struct epoll_event event; event.data.fd = c; event.events = EPOLLIN | EPOLLRDHUP; int res = epoll_ctl(epfd, EPOLL_CTL_ADD, c, &event); assert(res != -1); printf("%d link success\n", c); } void CloseClientLink(int fd, int epfd) { int res = epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); assert(res != -1); close(fd); } void DealOneClientData(int fd, int epfd) { char buff[128] = {0}; int n = recv(fd, buff, 127, 0); if(n <= 0) { CloseClientLink(fd, epfd); return; } printf("%d: %s\n", fd, buff); send(fd, "OK", 2, 0); } void DealReadyEvent(struct epoll_event *events, int n, int listenfd, int epfd) { int i = 0; for(; i < n; ++i) { int fd = events[i].data.fd; //因为是内核填充的,肯定是就绪的 if(fd == listenfd)//获取一个新的连接 { GetNewLink(fd, epfd); } Else { if(events[i].events & EPOLLRDHUP)//关注的事件类型,表示客户端断开连接 { CloseClientLink(fd, epfd); printf("one client unlink\n"); } else if(events[i].events & EPOLLIN)//处理一个客户端数据 { DealOneClientData(fd, epfd); } Else//表示触发的不是关注的事件类型 { printf("error\n"); } } } } int main() { int listenfd = CreateSocket(); assert(listenfd != -1); int epfd = epoll_create(5); assert(epfd != -1); //将事件添加到内核事件表当中 struct epoll_event event; event.events = EPOLLIN;//关注的事件类型 event.data.fd = listenfd; int res = epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &event); assert(res != -1); while(1) { struct epoll_event events[SIZE]; // 数组不用填充什么值,因为其作用又内核填充所有就绪的事件 int n = epoll_wait(epfd, events, SIZE, -1);//events数组都是按照位数去填充的,返回值就刚好是就绪的文件描述符的个数 assert(n != -1); if(n == 0) { printf("time out\n"); continue; } DealReadyEvent(events, n, listenfd, epfd);//传递lietsnfd是为了去判断是不是传递了listenfd进去,如果接收了新的连接就需要把其添加到epfd里面去 } close(listenfd); exit(0); }运行结果如下: