epoll是实现IO多路转接的另一种方式。它与select,poll的作用相同,都是一次等待多个文件描述符。但是,它在接口的使用及原理上与前两者有很大的不同。它改进了select和poll的缺点,使IO操作效率更高。因此它被公认为LInux2.6下性能最好的IO多路转接就绪通知方法。
下面具体介绍epoll的相关概念。
epoll
与select,poll不同的是,epoll在使用上有三个相关的系统调用。
1. epoll_create
函数原型
#include <sys/epoll.h>
int epoll_create(int size);
该函数的功能是创建一个epoll模型,然后返回一个epoll句柄(相当于文件描述符)。
参数size的作用是告诉内核关心的文件描述符数目有多大。但是从Liunx2.6.8之后,size参数是被忽略的。所以,这里不进行讨论。
该函数在调用时,会创建一个struct eventpoll结构体。在该结构体中有两个成员比较重要:
除了创建上述结构体,还会创建一个回调机制。回调机制的作用是当内核中有数据来时(满足就绪条件时),通知操作系统,这样,操作系统就不用再遍历等待满足就绪条件的文件描述符。
总结一下,在该函数内部实现以下功能(这些结构如何使用,后续进行说明):
(1)建立回调映射机制
(2)创建一棵空的红黑树;
(3)创建一个空的就绪队列;
2. 注册函数epoll_ctl
函数原型
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数说明:
epfd:上述epoll模型的句柄
op:表示采取的操作,该变量的取值有如下几种:
EPOLL_CTL_ADD:将fd注册到epoll模型中
EPOLL_CTL_MOD:修改已经注册到epoll模型的的事件类型
EPOLL_CTL_DEL:删除已经注册到epoll模型中的fd。
fd:对哪个文件描述符进行上述操作
event:表示监听的文件描述符fd上的相关事件信息。该结构体定义如下:
struct epoll_event {__uint32_t events; /* Epoll events */epoll_data_t data; /* User data variable */};
第一个成员变量表示事件类型,该变量与poll中的events和revents的含义类似。取值都是一个宏,不同的宏表示不同的事件类型,常见的宏为:EPOLLIN(读事件),EPOLLOUT(写事件),EPOLLET(将EPOLL设置为边缘触发,具体使用后面再详细说明)。
第二个成员变量为一枚举类型变量,结构定义如下:
typedef union epoll_data {void *ptr;int fd;__uint32_t u32;__uint64_t u64;} epoll_data_t;
因为是枚举类型,所以每次在使用时都只能取其中的一个成员变量。fd表示关心的文件描述符,用于存放一个int类型的数据。后两个变量分别用于存放32和64位的数据,具体存放什么数据在实际使用的时候在具体讨论。ptr为一指针,可以接受任意类型的数据,如果关心的文件描述符上的事件除了需要存放fd外,还需要存放其他的变量,此时就可以使用该变量(具体使用后面说明)。
该函数用于注册监听文件描述符上的事件。
1. 如果是添加事件,该函数的功能是:对该文件描述符上的事件建立回调映射关系;将该文件描述符上的事件添加进epoll模型的红黑树中;如果该文件描述符上的事件就绪了,则将文件描述符上的事件添加进epoll模型中的就绪队列。(注:红黑树的结点为fd和event构成的键值对。红黑树的查找效率比较高为logn,在查找时,只需根据fd即可找到对应的结点)
2. 如果是修改事件,只需根据fd在红黑树中找到对应的结点,将其事件进行修改即可;
3. 如果是删除事件,也是根据fd在红黑树中找到对应的结点,将该结点删除,并取消该文件描述符的回调映射关系。
select在获取就绪事件时才将关心的事件拷贝到内核,然后内核开始等待,待事件就绪后,才返回。而epoll是在获取就绪事件之前就将事件先注册到内核,此时内核就开始等待,然后将就绪事件准备好,待epoll要获取就绪事件时直接将内核准备好的就绪事件拷贝走即可。而不必再等待。
3. 等待函数epoll_wait
函数原型:
#include <sys/epoll.h>int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
参数说明:
epfd:上述所说的epol模型的句柄
events:该变量与上述epoll_ctl最后一个参数的类型相同,在epoll_ctl中该类型的变量是作为输入型参数,告诉操作系统内核关心哪些文件描述符上的哪些事件。而在该函数中,该变量是一个指针,指向的实际是一个数组,数组是由调用者提供,存放操作系统告诉调用者哪些文件描述符上的事件发生了。因此该变量是一个输出型参数。
maxevents:该变量即表示的是上述数组的大小
timeout:表示超时时间的设定,与poll中的取值相同。0表示非阻塞,-1表示阻塞等待,特定的时间值用于设定阻塞的时间(单位为毫秒)。
返回值:与select和poll相同,大于0表示满足就绪条件的文件描述符的个数,等于0表示超时返回,小于0表示函数调用出错。
该函数在内部实现时,如果上述的就绪队列不为空,则将就绪队列上的就绪事件拷贝到函数提供的数组中,否则进行阻塞等待(如果设置为阻塞等待的话。如果设置为非阻塞等待或者特定的时间值,需要循环式调用该函数进行轮询)。
该函数的作用与select和poll的功能相同,都是一次等待多个文件描述符上的就绪事件发生,只是该函数与前两者底层的运行原理不同,在调用该函数之前,需要先调用上述两个函数对关心的文件描述符上的事件进行注册。
4. epoll的运行过程
综上所述,epoll的函数调用执行过程如下:
(1)调用epoll_create创建epoll模型,获得epoll模型的句柄。在调用该函数时,系统底层做三件事:一是建立回调映射机制;而是创建一棵空的红黑树;三是创建一个空格就绪队列;
(2)调用epoll_ctl进行事件的注册。将关心的文件描述符上的事件注册到epoll模型中。主要是对关心的文件描述符上的事件建立回调映射关系;将关心的事件挂在红黑树上;如果关心的事件就绪了,则将该事件插入到就绪队列中。(如果是修改和删除事件操作,则进行上述2中的而操作)。
(3)调用epoll_wait来等待获取就绪事件。如果底层的就绪队列不为空,则将就绪队列上的就绪事件拷贝到该函数提供的数组中。如果就绪队列为空,则进行阻塞等待。(该函数的功能与select和poll的功能相同)。
5. epoll的优点
通过上述对epoll的执行过程介绍,可以发现epoll有如下优点:
(1)文件描述符无上限:在epoll_ctl中对文件描述符进行注册,在内核中利用红黑树的结构来保存文件描述符及相应的事件;
该操作相对于select和poll都比较方便。因为在select中,文件描述符的数量是有上限的;在poll中文件描述符虽然也没有上限,但是需要调用者手动设置数组来保存关心的文件描述符,操作起来不方便;而在epoll中保存文件描述符的任务有内核来完成,这样更加方便。
(2)基于事件的就绪通知方式:当关心的文件描述符上的就绪条件满足时,此时采用回调机制告诉操作系统。
在select和poll中都是操作系统去主动的循环遍历关心的文件描述符集,查看满足就绪条件的文件描述符,这样做使操作系统的开销非常大;而在epoll中使用回调机制,操作系统有主动变被动,当就绪条件满足时,有回调机制告知操作系统,操作系统再进行操作。
(3)创建就绪队列:操作系统将满足就绪条件的文件描述符插入到就绪队列中,这样在调用epoll_wait时,直接拷贝就绪队列中的文件描述符即可,此时操作的时间复杂度为O(1)。
在调用select和poll时,才开始将关心的文件描述符拷贝到内核,内核再循环等待就绪事件的发生,最后将就绪的事件拷贝到用户空间。
6. epoll的工作方式
epoll有两种工作方式:水平触发(LT)和边缘触发(ET)。
假设有这样一个场景:某个连接的客户端一次发送100K的数据给服务器。当服务器程序调用epoll_wait时,发现读就绪事件满足,此时就开始调用read函数对数据进行读取。而read函数一次读取了50K的数据后就返回了。也就是说还有50K的数据存在于内核中。则下一次在调用epoll_wait时,如果该文件描述符上没有新的数据达到,就是内核中还是只有上次剩下的50K数据,此时会出现两种情形:
(1)因为内核中还有数据没有读取,此时epoll_wait的返回值大于0,告诉调用者再次调用read对内核中的数据进行读取;
(2)因为内核中没有新的数据到达,此时epoll_wait的返回值为0,调用者不能对上次剩余的数据进行再次读取。
上述的两种情况分别对应epoll的两种工作方式:
水平触发LT工作方式
当epoll检测到socket上有数据到达时,epoll_wait的返回值大于0。此时调用者可以不处理数据或者只处理其中的一部分数据。在下一次调用epoll_wait时,只要内核中还有数据(无论是上一次遗留的还是有新的数据到达),此时返回值都大于0。调用者可以对上一次遗留的数据继续进行读取。直到内核中没有数据时,epoll_wait才不会返回。对应于上述的情形(1),这种方式就称为水平触发方式。
只要内核中有数据,调用epoll_wait时都会返回(且返回值大于0)。水平触发方式支持阻塞读写和非阻塞读写两种方式。
边缘触发ET工作方式
当epoll检测到socket上有数据到达时,epoll_wait的返回值大于0。此时调用者如果不处理数据或者只处理一部分数据。在下一次调用epoll_wait时,该函数不会再返回,此时,上次遗留的数据就会丢失。就是说当epoll_wait返回值大于0时,只有一次处理机会,如果这次没有处理完,就没有下次机会进行处理了。所以,当epoll_wait返回时,必须一次将数据处理完。
对于边缘触发方式,当内核中的有变更的事件就绪时(数据从无到有或从有到多时),epoll_wait才会返回。返回后,必须一次处理完数据,不然下次就没机会了。边缘触发方式只支持非阻塞读写。
epoll_wait返回时,只有一次处理的机会,当调用read进行数据读取时,如果一次read没有将数据读完,为了不错失本次机会,就需要一直对文件描述符进行读取。因为Linux下socket默认都是阻塞的,当数据读完时,进程就会阻塞在read处,整个进程就会挂起,此时服务器就不能处理其他的客户端的数据请求了。为了不让进程阻塞在read处,需要将socket设置为非阻塞,当数据读完时,直接返回,不用阻塞在read处。
对于边缘触发方式来说为了在一次机会内将数据读取完,就需要循环调用read进行读取,当数据读完时,为了使进程不会阻塞,就需要将socket设置为非阻塞。因此边缘触发方式只支持非阻塞读写。
注意:
epoll可以支持LT和ET,但默认情况下支持的是LT方式。(如果要使用ET方式,在注册事件时需要对事件添加EPOLLET标记)。select和poll只支持LT工作方式。
ET的性能比LT性能更高,因为ET下epoll_wait的返回次数少了很多。对于同一分数据,ET返回一次就会处理完,LT可能要返回多次才会处理完。
但是,ET的高性能也是有使用场景的:(1)如果对于多连接中的只有一部分连接比较活跃时,此时比较适合epoll。(2)如果某个连接的执行体过大,因为服务器是单进程,此时当服务器就会将大量时间花费在该客户端处,而导致其他客户端的数据得不到处理。(3)如果连接数较少,此时epoll也是不适合的。所以要根据具体的使用场景来选择合适的IO模型。
7. 编写基于水平触发方式的epoll服务器
基于LT方式的服务器与select和poll服务器的编写类似,这里不再详说,具体程序代码见:基于LT方式的服务器和客户端程序代码
8. 编写基于边缘触发方式的epoll服务器(本服务器只关心读事件)
流程如下:
(1)首先绑定获取监听套接字;
(2)创建epoll模型;
(3)将监听套接字注册进epoll模型中。
因为监听套接字需要不断的接收新连接,所以它关心的是只读事件,并且将其设置为边缘触发方式。如果多个客户端同时发送连接请求时,监听套接字在调用accept时,可能一次处理不了这么多的连接。所以为了不错失本次机会,就要循环的accept,当连接接收完毕后,为了使进程不发生阻塞,此时就要将及监听套接字设置为非阻塞方式。
因此,将监听套接字的事件类型设置为EPOLLIN和EPOLLET,再将监听套接字设置为非阻塞后,最后在将其注册进epoll模型中。
(4)循环式的调用epoll_wait等待就绪事件。
(5)如果epoll_wait返回值小于0,说明函数调用出错;如果等于0,说明超时返回;如果大于0,说明关心的文件描述符上就绪条件满足了。此时,对满足就绪条件的文件描述符进行处理。
(6)如果满足就绪条件的是监听套接字,说明有客户端发来新的连接请求。此时服务器调用accept来接收新的连接。
因为同一时刻可能有多个连接请求,所以,为了在一次机会内处理完所有的连接请求,就需要循环的调用accept来接受新连接。
接收到一条新连接之后,将其注册进epoll模型。因为客户端有数据请求时才会发送连接请求,所以服务器accept产生的新的文件描述符此时必关心的是读事件(要从客户端读数据)。但是又不能立即调用read对其进行读取(否则进程可能会阻塞),因为客户端可能还没将数据发送过来。所以,将新的文件描述符的事件设置为只读和边缘触发方式,并将其设置为非阻塞,再将其添加进epoll模型中。
当所有连接接收完之后,accept不会阻塞,因为前面已经将监听套接字设置为非阻塞,此时直接跳出循环即可。
(7)如果满足就绪条件的是普通文件描述符,说明客户端有数据请求,此时服务器调用read来对文件描述符进行读取。
为了在一次机会内将所有数据读完,就需要循环非阻塞式的读。(对应NoBlockRead函数:在该函数中下一次的读取位置应接着上一次的尾部,这样只能数据就可以连续存放了)。
如果循环读取的数据大小大于0,说明读取成功;如果等于0,说明对端将连接关闭,此时服务器也应关闭该文件描述符,并将该文件描述符从epoll模型中删除;如果小于0,说明read调用失败,此时服务器也应关闭该文件描述符,并将该文件描述符从epoll模型中删除。
基于上述流程编写的完整的服务代码见:基于ET方式的服务器和客户端程序代码
运行结果如下:
服务器运行界面:
客户端1:
客户端2:
浏览器作客户端:
9. 编写基于边缘触发方式的epoll服务器
在本服务器中既关心读事件,又关心写事件,并且服务器与客户端之间是基于短连接的,即一读一写之后服务器主动断开连接。
假设客户端一次发来的数据是一个完整的数据报的一部分。所以必须将完整的数据报读取完之后,才能进行处理,否则就会造成粘包问题。也就是说在一次循环非阻塞式读取的数据可能只是一个完整数据报的一部分。所以,不能立即对该部分数据进行处理,而是先将该部分数据保存起来,等整个数据报读取完之后才能进行处理(对于基于HTTP协议的应用层数据报,可以通过判断是否读到空行来表示一个数据报是否到达末尾。在本程序中,只是采取了将多个数据拼接成一个完整的数据报的操作,在具体处理时还是读一次处理一次)。
要将该部分数据保存起来,就不能在栈区申请空间,因为本次读取完之后,栈区的数据会释放。所以要在堆区申请一片空间来进行保存。因为该部分数据属于该文件描述符,所以可以将这片内存由文件描述符的事件结构体struct epoll_event的data成员变量data的ptr指向。
因此,对上述的服务器进行修改。
(1)首先要定义一个结构体,该结构体中定义如下:
typedef struct epdata
{ int fd;//文件描述符int cap;//缓冲区buf的最大容量int size;//缓冲区buf的当前容量char buf[0];//柔性数组,充当缓冲区
}epdata_t,*epdata_p;
该结构体变量由某个文件描述符的事件结构体struct epoll_event中的成员变量data中的ptr指针指向。上述结构体中的fd表示关心的文件描述符,buf表示为该文件描述符申请的缓冲区,用于存放从该文件描述符中读取的数据。cap,size的含义上述已给出。
(2)其次,是在对文件描述符(无论是监听套接字还是普通套接字)进行注册时,需要动态申请一片空间,将该空间也注册进epoll中;
(3)当epoll_wait返回时,可能是读事件就绪,也可能是写事件就绪。如果是读事件就绪,判断是监听套接字就绪,还是普通文件描述符就绪。如果是写事件就绪,根据HTTP协议将数据发送给客户端之后,关闭文件描述符断开连接,并在epoll模型中删除该文件描述符(因为是基于短连接的)。
(4)在处理普通文件描述符上的读事件时,先通过循环非阻塞方式读取本次的数据之后,将其存入缓冲区中。(为方便操作,这里读取之后,直接进行处理,而不考虑粘包问题)。
如果非阻塞读取的返回值大于0,则将该文件描述符上的事件修改的写事件,以便下次向客户端发送数据。如果返回值等于0,说明对端关闭连接,此时服务器也应关闭文件描述符来关闭连接,并将文件描述符从epoll模型中删除。如果返回值小于0,说明read调用出错,此时服务器也应关闭文件描述符来关闭连接,并将文件描述符从epoll模型中删除。
基于上述描述,编写的程序代码见:基于ET和短连接的epoll服务器和客户端代码
运行结果如下:
服务器:
浏览器做客户端:
客户端程序运行界面: