一、epoll 系列函数简介
#include
int epoll_create(int size);
int epoll_create1(int flags);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
1. int epoll_create(int size)
创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
2.epoll_create1 产生一个epoll 实例,返回的是实例的句柄。flag 可以设置为0 或者EPOLL_CLOEXEC,为0时函数表现与epoll_create一致,EPOLL_CLOEXEC标志与open 时的O_CLOEXEC 标志类似,即进程被替换时会关闭打开的文件描述符。3.. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll的事件注册函数,它不同与select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。
第一个参数是epoll_create()的返回值,
第二个参数表示动作,用三个宏来表示:
EPOLL_CTL_ADD:注册新的fd到epfd中;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
EPOLL_CTL_DEL:从epfd中删除一个fd;
第三个参数是需要监听的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 */
};
events可以是以下几个宏的集合:
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
关于ET、LT两种工作模式:
epoll 的EPOLLLT (电平触发,默认)和 EPOLLET(边沿触发)模式的区别
1、EPOLLLT:完全靠kernel epoll驱动,应用程序只需要处理从epoll_wait返回的fds,这些fds我们认为它们处于就绪状态。此时epoll可以认为是更快速的poll。LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表.
2、EPOLLET:此模式下,系统仅仅通知应用程序哪些fds变成了就绪状态,一旦fd变成就绪状态,epoll将不再关注这个fd的任何状态信息,(从epoll队列移除)直到应用程序通过读写操作(非阻塞)触发EAGAIN状态,epoll认为这个fd又变为空闲状态,那么epoll又重新关注这个fd的状态变化(重新加入epoll队列)。随着epoll_wait的返回,队列中的fds是在减少的,所以在大并发的系统中,EPOLLET更有优势,但是对程序员的要求也更高,因为有可能会出现数据读取不完整的问题。
ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)
举例如下:
假设现在对方发送了2k的数据,而我们先读取了1k,然后这时调用了epoll_wait,如果是边沿触发,那么这个fd变成就绪状态就会从epoll 队列移除,很可能epoll_wait 会一直阻塞,忽略尚未读取的1k数据,与此同时对方还在等待着我们发送一个回复ack,表示已经接收到数据;如果是电平触发,那么epoll_wait 还会检测到可读事件而返回,我们可以继续读取剩下的1k 数据。
3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待事件的产生,类似于select()调用。参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。
下面为用epoll写的C++程序:
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include "sysutil.h"
typedef std::vector EventList;//定义新类型,内部装着epoll_event结构体的容器
/* 相比于select与poll,epoll最大的好处是不会随着关心的fd数目的增多而降低效率 */
int main(void)
{
int count = 0;
int listenfd;
if ((listenfd = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0)
ERR_EXIT("socket");
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(5188);
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
int on = 1;
if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) < 0)
ERR_EXIT("setsockopt");
if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0)
ERR_EXIT("bind");
if (listen(listenfd, SOMAXCONN) < 0)
ERR_EXIT("listen");
std::vector clients;//创建一个int类型的vector对象即clients
int epollfd;
epollfd = epoll_create1(EPOLL_CLOEXEC); //epoll实例句柄
struct epoll_event event;//临时保存需要监听的fd等待其加入
event.data.fd = listenfd;//将监听套接字listenfd 加入关心的套接字序列
event.events = EPOLLIN | EPOLLET; //边沿触发
epoll_ctl(epollfd, EPOLL_CTL_ADD, listenfd, &event);/*第一个参数为epoll实例句柄,第二个参数为对文件描述符的操作
第三个参数为需要操作的目标文件描述符,第四个参数告诉内核需
要监听什么事,即第三个参数fd的event
*/
EventList events(16);/*即初始化容器的大小为16,当返回的事件个数nready 已经等于16时,
需要增大容器的大小,使用events.resize 函数即可,容器可以动态增大,
这也是我们使用c++实现的其中一个原因
*/
struct sockaddr_in peeraddr;
socklen_t peerlen;
int conn;
int i;
int nready;
while (1)
{
nready = epoll_wait(epollfd, &*events.begin(), static_cast(events.size()), -1);
/*第一个参数为epoll句柄,第二个参数用来从内核得到事件的集合,这里将得到的事件保存在events容器中,
上面设置的初始大小为16第三个参数告诉内核这个事件集合有多大,第四个参数为等待I/O事件的超时值,
-1表示永不超时,返回值为需要处理的事件的个数。返回0表示已经超时
*/
if (nready == -1)
{
if (errno == EINTR)
continue;
ERR_EXIT("epoll_wait error");
}
if (nready == 0)
continue;
if ((size_t)nready == events.size())//当返回的事件个数nready 已经等于16时,需要增大容器的大小
events.resize(events.size() * 2);
for (i = 0; i < nready; i++)
{
if (events[i].data.fd == listenfd)//第一个为监听套接字
{
peerlen = sizeof(peeraddr);
conn = accept(listenfd, (struct sockaddr *)&peeraddr, &peerlen);
if (conn == -1)
ERR_EXIT("accept error");
printf("ip=%s port=%d\n", inet_ntoa(peeraddr.sin_addr), ntohs(peeraddr.sin_port));
printf("count = %d\n", ++count);
clients.push_back(conn);/*使用 std::vector clients;,来保存每次accept 返回的conn,
这里表示向容器中添加一个新的值
*/
activate_nonblock(conn);// 将conn 设置为非阻塞
event.data.fd = conn;
event.events = EPOLLIN | EPOLLET;//设置为边沿触发
epoll_ctl(epollfd, EPOLL_CTL_ADD, conn, &event);//使用epoll_ctl 函数将conn其加入关心的套接字序列
}
else if (events[i].events & EPOLLIN)
{
conn = events[i].data.fd;
if (conn < 0)
continue;
char recvbuf[1024] = {0};
int ret = read(conn, recvbuf, 1024);
if (ret == -1)
ERR_EXIT("read error");
if (ret == 0)
{
printf("client close\n");
close(conn);
event = events[i];//从容器中取出上面关闭的conn中的事件
epoll_ctl(epollfd, EPOLL_CTL_DEL, conn, &event);//使用epoll_ctl函数将其删除关心的套接字序列
clients.erase(std::remove(clients.begin(), clients.end(), conn), clients.end());
/*std:remove(first,last,val)返回一个迭代器,指向由begin到end区间上第一个要删除的元素,
这里是conn,然后在第一个要删除的元素(conn)到clients.end()的区间上调用erase(),从而删除
所有的要删除的元素,使得vector只包含未被删除的元素。
*/
}
fputs(recvbuf, stdout);
write(conn, recvbuf, strlen(recvbuf));
}
}
}
return 0;
}
在程序的最开始定义一个新类型EventList,内部装着struct epoll_event 结构体的容器。
接下面的socket,bind,listen 都跟以前说的一样,不述。接着使用epoll_create1 创建一个epoll 实例,再来看下面四行代码:
struct epoll_event event;
event.data.fd = listenfd;
event.events = EPOLLIN | EPOLLET; //边沿触发
epoll_ctl(epollfd, EPOLL_CTL_ADD, listenfd, &event);
根据前面的函数分析,这四句意思就是将监听套接字listenfd 加入关心的套接字序列。
在epoll_wait 函数中的第二个参数,其实events.begin() 是个迭代器,但其具体实现也是struct epoll_event* 类型,虽然 &*events.begin() 得到的也是struct epoll_event* ,但不能直接使用events.begin() 做参数,因为类型不匹配,编译会出错。
EventList events(16); 即初始化容器的大小为16,当返回的事件个数nready 已经等于16时,需要增大容器的大小,使用events.resize 函数即可,容器可以动态增大,这也是我们使用c++实现的其中一个原因。
当监听套接字有可读事件,accept 返回的conn也需要使用epoll_ctl 函数将其加入关心的套接字队列。
还需要调用 activate_nonblock(conn); 将conn 设置为非阻塞,man 7 epoll 里有这样一句话:
An application that employs the EPOLLET flag should use nonblocking file descriptors to avoid having a blocking read or
write starve a task that is handling multiple file descriptors.
当下次循环回来某个已连接套接字有可读事件,则读取数据,若read 返回0表示对方关闭,需要使用epoll_ctl 函数将conn 从队列中清除,我们使用 std::vector
先运行服务器程序,再运行客户端,输出如下:
客户端
解释:
为什么服务器端的count 只有1019呢,因为除去012,一个监听套接字还有一个epoll 实例句柄,所以1024 - 5 = 1019。
为什么客户端的错误提示跟这里的不一样呢?这正说明epoll 处理效率比poll和select 都高,因为处理得快,来一个连接就accept一个,当服务器端accept 完第1019个连接,再次accept 时会因为文件描述符总数超出限制,打印错误提示,而此时客户端虽然已经创建了第1020个sock,但在connect 过程中发现对等方已经退出了,故打印错误提示,连接被对等方重置。如果服务器端处理得慢的话,那么客户端会connect 成功1021个连接,然后在创建第1022个sock 的时候出错,打印错误提示:socket: Too many open files,当然因为文件描述符的限制,服务器端也只能从已完成连接队列中accept 成功1019个连接。
二、epoll与select、poll区别
1、相比于select与poll,epoll最大的好处在于它不会随着监听fd数目的增长而降低效率。内核中的select与poll的实现是采用轮询来处理的,轮询的fd数目越多,自然耗时越多。
2、epoll的实现是基于回调的,如果fd有期望的事件发生就通过回调函数将其加入epoll就绪队列中,也就是说它只关心“活跃”的fd,与fd数目无关。
3、内核 / 用户空间 内存拷贝问题,如何让内核把 fd消息通知给用户空间呢?在这个问题上select/poll采取了内存拷贝方法。而epoll采用了内核和用户空间共享内存的方式。
4、epoll不仅会告诉应用程序有I/0 事件到来,还会告诉应用程序相关的信息,这些信息是应用程序填充的,因此根据这些信息应用程序就能直接定位到事件,而不必遍历整个fd集合。
5、当已连接的套接字数量不太大,并且这些套接字都非常活跃,那么对于epoll 来说一直在调用callback 函数(epoll 内部的实现更复杂,更复杂的代码逻辑),可能性能没有poll 和 select 好,因为一次性遍历对活跃的文件描述符处理,在连接数量不大的情况下,性能更好,但在处理大量连接的情况时,epoll 明显占优。
补充:
select的特点:select 选择句柄的时候,是遍历所有句柄,也就是说句柄有事件响应时,select需要遍历所有句柄才能获取到哪些句柄有事件通知,因此效率是非常低。但是如果连接很少的情况下, select和epoll的LT触发模式相比, 性能上差别不大。
这里要多说一句,select支持的句柄数是有限制的, 同时只支持1024个,这个是句柄集合限制的,如果超过这个限制,很可能导致溢出,而且非常不容易发现问题, TAF就出现过这个问题, 调试了n天,才发现:)当然可以通过修改linux的socket内核调整这个参数。
epoll的特点:epoll对于句柄事件的选择不是遍历的,是事件响应的,就是句柄上事件来就马上选择出来,不需要遍历整个句柄链表,因此效率非常高,内核将句柄用红黑树保存的。
对于epoll而言还有ET和LT的区别,LT表示水平触发,ET表示边缘触发,两者在性能以及代码实现上差别也是非常大的。
EPOLL的LT与ET的深入说明:
LT:水平触发,效率会低于ET触发,尤其在大并发,大流量的情况下。但是LT对代码编写要求比较低,不容易出现问题。LT模式服务编写上的表现是:只要有数据没有被获取,内核就不断通知你,因此不用担心事件丢失的情况。
ET:边缘触发,效率非常高,在并发,大流量的情况下,会比LT少很多epoll的系统调用,因此效率高。但是对编程要求高,需要细致的处理每个请求,否则容易发生丢失事件的情况。
下面举一个列子来说明LT和ET的区别(都是非阻塞模式,阻塞就不说了,效率太低):
采用LT模式下, 如果accept调用有返回就可以马上建立当前这个连接了,再epoll_wait等待下次通知,和select一样。
但是对于ET而言,如果accpet调用有返回,除了建立当前这个连接外,不能马上就epoll_wait还需要继续循环accpet,直到返回-1,且errno==EAGAIN,TAF里面的示例代码:
if(ev.events& EPOLLIN)
{
do
{
struct sockaddr_in stSockAddr;
socklen_t iSockAddrSize = sizeof(sockaddr_in);
TC_Socket cs;
cs.setOwner(false);
//接收连接
TC_Socket s;
s.init(fd, false, AF_INET);
int iRetCode = s.accept(cs, (struct sockaddr*) &stSockAddr, iSockAddrSize);
if (iRetCode > 0)
{
...建立连接
}
else
{
//直到发生EAGAIN才不继续accept
if(errno == EAGAIN)
{
break;
}
}
}while(true);
}
同样,recv/send等函数, 都需要到errno==EAGAIN
读数据的时候需要考虑的是当recv()返回的大小如果等于请求的大小,那么很有可能是缓冲区还有数据未读完,也意味着该次事件还没有处理完,所以还需要再次读取:
while(rs)
{
buflen = recv(activeevents[i].data.fd, buf, sizeof(buf), 0);
if(buflen < 0)
{
// 由于是非阻塞的模式,所以当errno为EAGAIN时,表示当前缓冲区已无数据可读
// 在这里就当作是该次事件已处理处.
if(errno == EAGAIN)
break;
else
return;
}
else if(buflen == 0)
{
// 这里表示对端的socket已正常关闭.
}
if(buflen == sizeof(buf)
rs = 1; // 需要再次读取
else
rs = 0;
}
ssize_t socket_send(int sockfd, const char* buffer, size_t buflen)
{
ssize_t tmp;
size_t total = buflen;
const char *p = buffer;
while(1)
{
tmp = send(sockfd, p, total, 0);
if(tmp < 0)
{
// 当send收到信号时,可以继续写,但这里返回-1.
if(errno == EINTR)
return -1;
// 当socket是非阻塞时,如返回此错误,表示写缓冲队列已满,
// 在这里做延时后再重试.
if(errno == EAGAIN)
{
usleep(1000);
continue;
}
return -1;
}
if((size_t)tmp == total)
return buflen;
total -= tmp;
p += tmp;
}
return tmp;
}
从本质上讲:与LT相比,ET模型是通过减少系统调用来达到提高并行效率的。
EPOLL ET详解:
ET模型的逻辑:内核的读buffer有内核态主动变化时,内核会通知你, 无需再去mod。写事件是给用户使用的,最开始add之后,内核都不会通知你了,你可以强制写数据(直到EAGAIN或者实际字节数小于 需要写的字节数),当然你可以主动mod OUT,此时如果句柄可以写了(send buffer有空间),内核就通知你。
这里内核态主动的意思是:内核从网络接收了数据放入了读buffer(会通知用户IN事件,即用户可以recv数据)
并且这种通知只会通知一次,如果这次处理(recv)没有到刚才说的两种情况(EAGIN或者实际字节数小于 需要读写的字节数),则该事件会被丢弃,直到下次buffer发生变化。
与LT的差别就在这里体现,LT在这种情况下,事件不会丢弃,而是只要读buffer里面有数据可以让用户读,则不断的通知你。
另外对于ET而言,当然也不一定非send/recv到前面所述的结束条件才结束,用户可以自己随时控制,即用户可以在自己认为合适的时候去设置IN和OUT事件:
1 如果用户主动epoll_mod OUT事件,此时只要该句柄可以发送数据(发送buffer不满),则epoll
_wait就会响应(有时候采用该机制通知epoll_wai醒过来)。
2 如果用户主动epoll_mod IN事件,只要该句柄还有数据可以读,则epoll_wait会响应。
这种逻辑在普通的服务里面都不需要,可能在某些特殊的情况需要。 但是请注意,如果每次调用的时候都去epoll mod将显著降低效率。
因此采用et写服务框架的时候,最简单的处理就是:
建立连接的时候epoll_add IN和OUT事件, 后面就不需要管了
每次read/write的时候,到两种情况下结束:
1 发生EAGAIN
2 read/write的实际字节数小于 需要读写的字节数
对于第二点需要注意两点:
A:如果是UDP服务,处理就不完全是这样,必须要recv到发生EAGAIN为止,否则就丢失事件了
因为UDP和TCP不同,是有边界的,每次接收一定是一个完整的UDP包,当然recv的buffer需要至少大于一个UDP包的大小
随便再说一下,一个UDP包到底应该多大?
对于internet,由于MTU的限制,UDP包的大小不要超过576个字节,否则容易被分包,对于公司的IDC环境,建议不要超过1472,否则也比较容易分包。
B 如果发送方发送完数据以后,就close连接,这个时候如果recv到数据是实际字节数小于读写字节数,根据开始所述就认为到EAGIN了从而直接返回,等待下一次事件,这样是有问题的,close事件丢失了!
因此如果依赖这种关闭逻辑的服务,必须接收数据到EAGIN为止,例如lb。
按照我目前的了解,EPOLL模型似乎只有一种格式,所以大家只要参考我下面的代码,就能够对EPOLL有所了解了,代码的解释都已经在注释中:
while(TRUE)
{
int nfds = epoll_wait (m_epoll_fd, m_events, MAX_EVENTS, EPOLL_TIME_OUT);//等待EPOLL时间的发生,相当于监听,至于相关的端口,需要在初始化EPOLL的时候绑定。
if (nfds <= 0)
continue;
m_bOnTimeChecking = FALSE;
G_CurTime = time(NULL);
for (int i=0; i