一、基本知识
1.1 内核事件表
epoll 是Linux 特有的 I/O 多路复用函数,是在Linux 2.6内核版本中提出的,是之前的 select 和 poll 的增强版本。它在实现和使用上与 select、poll有很大的差异。首先,epoll 使用一组函数来完成任务,而不是单个函数。其次,epoll 更加灵活,没有描述符的限制,epoll 把用户关心的文件描述符上的事件放到内核里的一个事件表中,这样在用户空间和内核空间的copy只需一次,无须像 select 和 poll 那样每次调用select()函数、poll()函数时都要重新复制整个文件描述符或事件集到内核空间中。但是,epoll 需要使用一个额外的文件描述符(epfd),来唯一标识内核中的这个事件表,这个文件描述符使用 epoll_create() 函数来创建。
1.2 epoll 接口函数
#include <sys/epoll.h>
int epoll_create(int size);
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.2.1 epoll_create 系统调用
int epoll_create(int size);
该函数的功能是:创建一个epoll实例数据结构并返回一个文件描述符epfd,这个描述符就是epoll实例的句柄。参数 size 用来告诉内核监听事件的这个事件表有多大。这个参数不同于select()函数中的第一个参数,即最大监听的文件描述符数量加1。当创建好epoll实例后,函数返回一个文件描述符,该文件描述符将用作其他两个epoll 系统调用的第1个参数值,以标识要访问的内核事件表。
1.2.2 epoll_ctl 系统调用
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll_ctl() 函数是epoll 的事件注册函数,它不同于select() 是在监听事件时告诉内核要监听什么类型的事件,而是先注册要监听的事件类型。
【参数说明】
epfd:是epoll_create()函数返回值,用来唯一标识内核事件表的文件描述符。
op:参数fd对应的操作类型。操作类型有如下3种:
EPOLL_CTL_ADD:往事件表中注册fd描述符上的事件;
EPOLL_CTL_MOD:修改fd上的注册事件;
EPOLL_CTL_DEL:删除fd上的注册事件。fd:被监听的文件描述符。
event:告诉内核监听第3个参数fd描述符上的什么事件。如:读事件、写事件、异常事件等。它是一个 epoll_event 结构体指针类型。
epoll_event 结构体
struct epoll_event
{
uint32_t events; //epoll事件
epoll_data_t data; //用户数据
};
【结构体成员说明】
events:用来描述事件类型。epoll 支持的事件类型和poll基本相同。表示 epoll 事件类型的宏是在poll对应的宏前面加上 'E',比如 epoll 的数据可读事件是 EPOLLIN。但epoll有两个额外的事件类型:EPOLLET 和 EPOLLONESHOT。它们对于epoll的高效运行非常关键,我们将在后面讨论它们。events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符的可读事件(包括对端socket正常关闭);
EPOLLOUT:表示对应的文件描述符的可写事件;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读事件(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符有发生错误的事件;
EPOLLHUP:表示对应的文件描述符被挂断的事件;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。data:用于存储用户数据,其类型 epoll_data_t 的定义如下:
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
}epoll_data_t;epoll_data_t 是一个共用体(也称为联合体)类型,其4个成员中使用最多的是fd,它指定了事件所从属的目标文件描述符fd。ptr成员可用来指定与fd相关的用户数据。但由于 epoll_data_t 是一个共用体,我们不能同时使用其 ptr 成员和 fd 成员,因此,如果要将文件描述符和用户数据关联起来,以实现快速的数据访问,只能使用其他手段,比如放弃使用 epoll_data_t 中的 fd 成员,而在 ptr 指向的用户数据中包含 fd。
epoll_ctl 函数返回值:成功时,返回0;失败时,则返回-1,并设置 errno。
1.2.3 epoll_wait 系统调用
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll 系列系统调用的主要接口是 epoll_wait() 函数。它在一段超时时间内等待一组文件描述符上的事件发生。类似于select()函数的调用。
【参数说明】
epfd:是epoll_create()函数返回值,用来唯一标识内核事件表的文件描述符。
events:用来从内核得到的就绪事件的集合。
maxevents:指定最多监听多少个事件,它必须大于0。
timeout:该参数的含义与poll()函数中的timeout参数相同。指定事件的超时时间,单位是毫秒。当timeout=-1时,epoll_wait()函数调用将永远阻塞,直到某个事件发生;当timeout=0时,epoll_wait()函数调用将立即返回。
epoll_wait() 函数如果检测到有事件发生,就将所有就绪的事件从内核事件表(由epfd参数指定)中复制到第2参数events指针指向的数组中。这个数组只用于输出 epoll_wait 检测到的就绪事件,而不像select 和 poll 的数组参数那样既用于传入用户注册的事件,又用于输出内核检测到的就绪事件。这就极大提高了应用程序索引就绪文件描述符的效率。下面的代码清单就体现了poll 和 epoll 之间的这个区别:
//如何索引poll返回的就绪文件描述符
int ret = poll(fds, MAX_EVENT_NUMBER, -1);
//必须遍历所有已注册的文件描述符并找到其中的就绪者
for(int i=0; i<MAX_EVENT_NUMBER; i++){
if(fds[i].reevents & POLLIN) //判断第i个文件描述符是否已就绪
{
int sockfd = fds[i].fd; //如果已就绪,则处理对应文件描述符上的事件
//处理sockfd
......
}
}
//如何索引epoll返回的就绪文件描述符
int ret = epoll_wait(epfd, events, MAX_EVENT_NUMBER, -1);
//仅遍历已经就绪的ret个文件描述符
for(int i=0; i<ret; i++){
int sockfd = events[i].data.fd;
//sockfd 肯定就绪,不用进行判断,直接处理
......
}
epoll_wait函数返回值: 成功,返回已就绪事件的文件描述符个数;如果在timeout超时时间内没有就绪事件发生,返回0;失败时,则返回-1,并设置errno。
1.2.4 epoll工作模式
epoll 对文件描述符的操作有两种模式:LT(Lever Trigger,水平触发)模式和 ET(Edge Trigger,边缘触发)模式。其中,LT模式是默认模式,这种模式下 epoll 相当于一个效率较高的poll。当往epoll内核事件表中注册一个文件描述符上的 EPOLLET 事件时,epoll 将以ET模式来操作该文件描述符。ET模式是epoll 的高效工作模式。
- LT模式与ET模式的区别:
LT模式:当epoll_wait检测到某个文件描述符上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。这样,当应用程序下一次调用epoll_wait时,epoll_wait还会再次向应用程序通知此事件,直到该事件被处理。
ET模式:当epoll_wait检测到某个文件描述符上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件。因为如果不处理,下次调用epoll_wait时,epoll_wait将不会再向应用程序通知这一事件。也就是说epoll_wait只会通知你一次,直到该文件描述符上出现第二次该注册事件发生,才会再次通知应用程序。
由此可见,ET模式在很大程度上降低了同一个epoll事件被重复触发的次数,因此效率要比LT模式高。
<注意> 每个使用ET模式的文件描述符都应该是非阻塞的。如果文件描述符是阻塞的,那么读或写操作(read/write)将会因为没有读写事件的发生而导致进程/线程一直处于阻塞状态。
具体情况分析:
- LT触发的时机
1、对于读事件,只要读缓冲区内容不为空,每次调用epoll_wait,都会返回读就绪通知。
2、对于写事件,只要写缓冲区内容不为空,每次调用epoll_wait,都会返回写就绪通知。
当被监控的文件描述符上有可读/可写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait()时,它还会通知你在尚未读写完的文件描述符上继续读写,当然如果你一直不去读写,它会一直通知你。如果系统中有大量你不需要读写的就绪文件描述符,而它们每次都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率。
- ET触发的时机
【对于读事件】
(1)当读缓冲区由不可读变为可读时,即缓冲区内容由空变为有的时候。
(2)当有新数据到达时,即缓冲区的待读内容变多的时候。
(3)当读缓冲区有数据可读时,且应用进程对相应的文件描述符进行 EPOLL_CTL_MOD操作,修改EPOLLIN事件时。
【对于写事件】
(1)当写缓冲区由不可写变为可写时,即写缓冲区的内容有无变为有的时候。
(2)当有旧数据被发送出去时,即写缓冲区的内容变少的时候。
(3)当缓冲区有空间可写时,且应用进程对相应的文件描述符进行 EPOLL_CTL_MOD操作,修改EPOLLOUT事件时。
当被监控的文件描述符上有可读/可写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读/可写事件时才会通知你。这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。
以脉冲的高低电平为例来类比分析两者的区别:0表示无数据,1表示有数据。
水平触发模式:缓冲区有数据,则一直为1,则一直触发。
边缘触发模式:只有在由0—>1的上升沿才被触发。
实例1:LT 和 ET 在工作方式上的差异。代码如下:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdbool.h>
#include <unistd.h>
#include <fcntl.h>
#include <pthread.h>
#include <errno.h>
#include <assert.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <libgen.h>
#define MAX_EVENT_NUMBER 1024
#define BUFFER_SIZE 10
typedef struct epoll_event EP_EVENT;
//将文件描述符设置为非阻塞状态
int set_fd_nonblock(int fd)
{
int flags;
if((flags = fcntl(fd, F_GETFL, NULL)) < 0){
printf("set fd non-block error:%d %s\n", errno, strerror(errno));
return -1;
}
if(fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0){
printf("set fd non-block error:%d %s\n", errno, strerror(errno));
return -2;
}
return 0;
}
//将文件描述符fd上的EPOLLIN事件注册到epfd标识的epoll内核事件表中,参数enable_et指定是否对fd启用ET模式
void add_fd(int epfd, int fd, bool enable_et)
{
EP_EVENT event;
event.data.fd = fd;
event.events = EPOLLIN;
if(enable_et){
event.events |= EPOLLET; //添加EPOLLET事件
}
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
set_fd_nonblock(fd);
}
//LT模式的工作流程
void epoll_lt(int epfd, EP_EVENT *events, int number, int listen_fd)
{
char buf[BUFFER_SIZE]={0};
for(int i=0; i<number; i++){
int sockfd = events[i].data.fd;
if(sockfd == listen_fd){
struct sockaddr_in cli_addr;
socklen_t cli_addr_len = sizeof(cli_addr);
int conn_fd = accept(listen_fd, (struct sockaddr*)&cli_addr, &cli_addr_len);
add_fd(epfd, conn_fd, false); //注册conn_fd描述符的读事件,使用LT模式
}
else if(events[i].events & EPOLLIN){ //有读事件发生
//只要Socket读缓存中还有未读出的数据,这段代码就会被触发
printf("LT: event trigger once\n");
bzero(buf, BUFFER_SIZE);
int ret = recv(sockfd, buf, BUFFER_SIZE-1, 0);
if(ret <= 0){
close(sockfd);
continue;
}
printf("get %d bytes of content: %s\n", ret, buf);
}
else{
printf("something else happended\n");
}
}
}
//ET模式的工作流程
void epoll_et(int epfd, EP_EVENT *events, int number, int listen_fd)
{
char buf[BUFFER_SIZE]={0};
for(int i=0; i<number; i++){
int sockfd = events[i].data.fd;
if(sockfd == listen_fd){
struct sockaddr_in cli_addr;
socklen_t cli_addr_len = sizeof(cli_addr);
int conn_fd = accept(listen_fd, (struct sockaddr*)&cli_addr, &cli_addr_len);
add_fd(epfd, conn_fd, false); //注册conn_fd描述符的读事件,使用LT模式
}
else if(events[i].events & EPOLLIN){ //有读事件发生
//这段代码不会被重复触发,所以我们需要循环读取读缓存中的数据,以确保把读缓存中的数据全部读完
printf("ET: event trigger once\n");
while(true){
bzero(buf, BUFFER_SIZE);
int ret = recv(sockfd, buf, BUFFER_SIZE-1, 0);
if(ret <= 0){
//对于非阻塞IO,下面的条件成立表示数据已经全部读取完毕。此后,epoll就能再次触发sockfd上的EPOLLIN事件
//以触发下一次读操作(读缓存有无到有就会触发EPOLLIN事件)
if((errno == EAGAIN) || (errno==EWOULDBLOCK)){ //这个错误码表示读缓冲区没有数据了
printf("read later\n");
break;
}
close(sockfd);
break;
}
else if(ret == 0){
close(sockfd);
}
else
printf("get %d bytes of content: %s\n", ret, buf);
}
}
else{
printf("something else happended\n");
}
}
}
int main(int argc, char *argv[])
{
if(argc < 2){
printf("usage: %s ip port\n", basename(argv[0]));
return 1;
}
const char *ip = argv[1];
int port = atoi(argv[2]);
int ret = 0;
struct sockaddr_in svr_addr;
bzero(&svr_addr, sizeof(svr_addr));
svr_addr.sin_family = AF_INET;
inet_pton(AF_INET, ip, &svr_addr.sin_addr);
svr_addr.sin_port = htons(port);
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
assert(listen_fd >= 0);
ret = bind(listen_fd, (struct sockaddr*)&svr_addr, sizeof(svr_addr));
assert(ret != -1);
ret = listen(listen_fd, 5);
assert(ret != -1);
EP_EVENT events[MAX_EVENT_NUMBER]={0};
int epfd = epoll_create(5);
assert(epfd != -1);
add_fd(epfd, listen_fd, true); //注册服务器端的listen_fd文件描述符
while(true)
{
ret = epoll_wait(epfd, events, MAX_EVENT_NUMBER, -1);
if(ret < 0){
printf("epoll_wait failure: %d:%s\n", errno, strerror(errno));
break;
}
epoll_lt(epfd, events, ret, listen_fd); //使用LT模式
//epoll_et(epfd, events, ret, listen_fd); //使用ET模式
}
close(epfd);
close(listen_fd);
return 0;
}
编译命令:gcc epoll_lt_et.c -o epoll_lt -Wall gcc epoll_lt_et.c -o epoll_et -Wall (编译生成epoll_et执行文件时,需要注释LT模式的代码)
- LT模式运行结果:
telnet 172.16.32.4 8888
Trying 172.16.32.4...
Connected to 172.16.32.4.
Escape character is '^]'.
aaaabbbbccccdddd
aaaaabbbbbcccccdddddeeeeefffff./epoll_lt 172.16.32.4 8888
LT: event trigger once
get 9 bytes of content: aaaabbbbc
LT: event trigger once
get 9 bytes of content: cccddddLT: event trigger once
get 9 bytes of content: aaaaabbbb
LT: event trigger once
get 9 bytes of content: bcccccddd
LT: event trigger once
get 9 bytes of content: ddeeeeeff
LT: event trigger once
get 5 bytes of content: fff
- ET模式运行结果:
telnet 172.16.32.4 8888
Trying 172.16.32.4...
Connected to 172.16.32.4.
Escape character is '^]'.
aaaabbbbccccdddd
aaaaabbbbbcccccdddddeeeeefffff./epoll_et 172.16.32.4 8888
ET: event trigger once
get 9 bytes of content: aaaabbbbc
get 9 bytes of content: cccddddread later
ET: event trigger once
get 9 bytes of content: aaaaabbbb
get 9 bytes of content: bcccccddd
get 9 bytes of content: ddeeeeeff
get 5 bytes of content: fffread later
首先运行服务端程序,然后 telnet 到这个服务端程序,并一次传输超过10字节的数据,然后比较LT模式和ET模式的异同。可以发现,正如我们所预期的那样,ET模式下事件被触发的次数要比LT模式下少很多。
1.2.5 EPOLLONESHOT事件
即使我们使用ET边缘触发模式,一个socket上的某个时间还是可能被触发多次。这在并发程序中就会引起一个问题。比如一个线程(或进程,下同)在读取完某个socket上的数据后开始处理这些数据,而在数据处理过程中该socket上又有新数据可读(EPOLLIN事件再次被触发),此时另外一个线程被唤醒来读取这些新的数据。于是就出现了两个线程同时操作一个socket的局面。这当然不是我们期望的。我们期望的是一个socket连接在任一时刻都只被一个线程处理。这一点可以使用epoll的 EPOLLONESHOT 事件实现。
对于注册了 EPOLLONESHOT 事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常事件,且只触发一次,除非我们使用 epoll_ctl 函数重置该文件描述符上注册的EPOLLONESHOT 事件。这样,当一个线程在处理某个socket时,其他线程是不可能有机会操作这个socket的。但反过来想想,注册了 EPOLLONESHOT 事件的socket一旦被某个线程处理完毕,该线程就应该立即重置这个socket上的EPOLLONESHOT 事件,以确保这个socket下一次可读,其EPOLLIN事件仍然能被触发,进而让其他线程有机会继续处理这个socket。
实例2:一个 EPOLLONESHOT事件的使用例子。代码如下:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdbool.h>
#include <unistd.h>
#include <fcntl.h>
#include <pthread.h>
#include <errno.h>
#include <assert.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <libgen.h>
#define MAX_EVENT_NUMBER 1024
#define BUFFER_SIZE 10
typedef struct epoll_event EP_EVENT;
typedef struct fds{
int epollfd;
int sockfd;
}FDS_T;
//将文件描述符设置为非阻塞状态
int set_fd_nonblock(int fd)
{
int flags;
if((flags = fcntl(fd, F_GETFL, NULL)) < 0){
printf("set fd non-block error:%d %s\n", errno, strerror(errno));
return -1;
}
if(fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0){
printf("set fd non-block error:%d %s\n", errno, strerror(errno));
return -2;
}
return 0;
}
//将文件描述符fd上的EPOLLIN和EPOLLONESHOT事件注册到epfd标识的epoll内核事件表中
//参数oneshot指定是否注册fd上的EPOLLONESHOT事件
void add_fd(int epfd, int fd, bool oneshot)
{
EP_EVENT event;
event.data.fd = fd;
event.events = EPOLLIN | EPOLLET;
if(oneshot){
event.events |= EPOLLONESHOT; //添加EPOLLONESHOT事件
}
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
set_fd_nonblock(fd);
}
//重置fd上的事件。这样操作之后,尽管fd上的EPOLLONESHOT事件被注册,但是操作系统仍然会触发fd上的EPOLLIN事件,且只触发一次
void reset_oneshot(int epfd, int fd)
{
EP_EVENT event;
event.data.fd = fd;
event.events = EPOLLIN | EPOLLET | EPOLLONESHOT;
epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &event);
}
//工作线程
void* worker(void *arg)
{
FDS_T *fds = (FDS_T*)arg;
int epfd = fds->epollfd;
int sockfd = fds->sockfd;
printf("start new thread to receive data on fd: %d\n", sockfd);
char buf[BUFFER_SIZE]={0};
//循环读取sockfd上的数据,直到遇到EAGAIN错误(即读缓冲区内容为空)
while(1){
int ret = recv(sockfd, buf, BUFFER_SIZE-1, 0);
if(ret == 0){
close(sockfd);
printf("foreigner close the connection!\n");
break;
}
else if(ret < 0){
if(errno == EAGAIN){
reset_oneshot(epfd, sockfd);
printf("read data later\n");
break;
}
}
else{
printf("get content: %s\n", buf);
//休眠5s,模拟数据处理过程
sleep(5);
}
}
printf("thread exit! Receiving data on fd: %d\n", sockfd);
return NULL;
}
int main(int argc, char *argv[])
{
if(argc < 2){
printf("usage: %s ip port\n", basename(argv[0]));
return 1;
}
const char *ip = argv[1];
int port = atoi(argv[2]);
int ret = 0;
struct sockaddr_in svr_addr;
bzero(&svr_addr, sizeof(svr_addr));
svr_addr.sin_family = AF_INET;
inet_pton(AF_INET, ip, &svr_addr.sin_addr);
svr_addr.sin_port = htons(port);
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
assert(listen_fd >= 0);
ret = bind(listen_fd, (struct sockaddr*)&svr_addr, sizeof(svr_addr));
assert(ret != -1);
ret = listen(listen_fd, 5);
assert(ret != -1);
EP_EVENT events[MAX_EVENT_NUMBER]={0};
int epfd = epoll_create(5);
assert(epfd != -1);
/*
注意,监听socket listen_fd上是不能注册EPOLLONESHOT事件的,否则应用程序只能处理一个客户端连接!
因为后续客户端连接请求将不再触发listen_fd上的EPOLLIN事件
*/
add_fd(epfd, listen_fd, false); //注册服务器端的listen_fd文件描述符
while(1)
{
ret = epoll_wait(epfd, events, MAX_EVENT_NUMBER, -1);
if(ret < 0){
printf("epoll_wait failure: %d:%s\n", errno, strerror(errno));
break;
}
for(int i=0; i<ret; i++){
int sockfd = events[i].data.fd;
if(sockfd == listen_fd) //如果是listen_fd文件描述符上的就绪事件
{
struct sockaddr_in cli_addr;
socklen_t cli_addr_len = sizeof(cli_addr);
int connfd = accept(listen_fd, (struct sockaddr*)&cli_addr, &cli_addr_len);
//对每个非listen_fd文件描述符都注册EPOLLONESHOT事件
add_fd(epfd, connfd, true);
}
else if(events[i].events & EPOLLIN) //connfd文件描述符上的EPOLLIN事件
{
pthread_t pid;
FDS_T fds_for_new_worker;
fds_for_new_worker.epollfd = epfd;
fds_for_new_worker.sockfd = sockfd;
//启动一个工作线程为sockfd服务
pthread_create(&pid, NULL, worker, &fds_for_new_worker);
}
else
printf("something else happened\n");
}
}
close(epfd);
close(listen_fd);
return 0;
}
编译命令:gcc epoll_oneshot.c -o epoll_oneshot -lpthread -Wall
代码分析:从工作线程 worker 来看,如果一个工作线程处理完某个客户socket上的一次数据处理过程(我们用休眠5秒来模拟这一过程)之后,又接收到该客户socket上发送来的新数据,则该线程继续为这个客户socket服务,并且因为该socket上注册了EPOLLONESHOT事件,其他线程没有机会接触到这个socket,如果工作线程等待5秒后仍然没有收到该socket上的下一批客户数据,则它将放弃为该socket服务。同时,它调用reset_oneshot函数来重置该socket上的注册事件,这将使epoll有机会再次检测到该socket上的EPOLLIN事件,进而使得其他工作线程有机会为该socket服务。
由此可见,尽管一个socket在不同时间可能被不同的线程处理,但同一时刻肯定只有一个线程为它服务。这就保证了连接的完整性,从而避免了很多可能的竞态条件。
二、epoll的底层实现原理
epoll的底层实现的核心的数据结构是:红黑树+双链表。
红黑树:比较平衡的二叉搜索树,在平衡和旋转之间找一个平衡点,具有良好的插入,查找,删除性能,时间复杂度O(logn)。
epoll 使用红黑色作为索引结构,以便于快速的插入和删除要监视的文件描述符。
当执行 epoll_create 函数时,内核会为epoll开辟一个内核缓冲区,用于存放每一个我们想监控的文件描述符fd和与其关联的事件,即 epoll_event 结构体变量,这些描述符会以红黑树的形式保存在内核缓冲区中,以支持快速的查找、插入、删除操作,同时还会创建一个就绪描述符链表,用于存储准备就绪的描述符结点。
当每次执行 epoll_ctl 函数时,会将被监听的文件描述符添加到红黑树当中(ADD)、或从红黑树中删除(DEL)或者对监听事件进行修改(MOD),然后向内核注册回调函数,告诉内核如果这个描述符的中断到了,就把它放到准备就绪的链表中,即当一个文件描述符有事件发生时,内核会把该文件描述符结点插入到就绪链表中。
当每次执行 epoll_wait 函数时,只会遍历就绪链表中的描述符集合,并返回就绪描述符个数,而不是遍历整个内核事件表。如果就绪事件链表为空,它会等待一个timeout超时时间再返回。
epoll的高效之处在于,当我们调用epoll_ctl往里塞入百万个文件描述符时,epoll_wait仍然可以飞快的返回,并高效的将发生就绪事件的描述符返回给我们用户。这是由于我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核缓冲区里建了一颗红黑树用于存储后面epoll_ctl传来的文件描述符外,还会再建立一个双链表,用于存储准备就绪的描述符。当epoll_wait调用时,仅需要检测这个就绪链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到达后即使链表没数据也返回。所以,epoll_wait非常高效。
三、epoll的优势
epoll是一个高效的IO多路复用模型,相比于select和poll,具有更高的效率和易用性。select和poll的效率会因为文件描述符数量的线性递增而导致性能下降,而epoll的性能不会随文件描述符数量的递增而下降,epoll最大的优点就是它只管“活跃”的事件,而跟事件总数无关。因此,在实际的网络环境中,epoll的效率就会远远高于select和poll。
具体体现在下面几点:
1、epoll 接口使用方便,它将epoll实例的创建(epoll_create)、文件描述符上事件的注册(epoll_ctl)和等待事件的发生(epoll_wait)拆分开,等待操作循环进行,而创建epoll实例只在刚开始时调用一次。
2、select、poll 和 epoll 虽然都会返回就绪的文件描述符数量,但是select 和 poll 并不会明确指出是哪些文件描述符就绪,而epoll会,它会将就绪的文件描述符保存在就绪链表中,然后返回。造成的区别就是,系统调用返回后,调用select和poll的系统调用需要遍历监听的整个文件描述符找到是谁处于就绪,而epoll的epoll_wait由于只返回已就绪的文件描述符,因此它可以直接进行处理。
3、select、poll每次执行都需要将所有被监听的文件描述符的数据结构拷贝进内核区,最后再全部拷贝出来。而epoll创建的有关文件描述符的数据结构本身就存于内核区中,系统调用返回时,只需要将就绪链表中的描述符数据结构拷贝出来,而不是全部的,这就减少了复制时的系统开销。
4、select、poll采用轮询的方式来检查所有被监听的文件描述符是否处于就绪态,而epoll采用回调机制。造成的结果就是,随着被监听文件描述符数量的增加,select和poll的效率会线性降低,而epoll不会受到太大影响,除非活跃的文件描述符很多。
epoll事先通过epoll_ctl()来注册一个文件描述符,一旦某个文件描述符就绪时,内核会采用callback的回调机制,迅速激活这个文件描述符,当用户进程调用epoll_wait()时便得到通知。此处去掉了select、poll中需要遍历整个文件描述符的过程,而是通过监听回调的方式来通知文件描述符已就绪。
5、epoll 的边缘触发(ET)模式效率高,系统不会充斥大量不关心的就绪文件描述符。
需要注意的是,虽然epoll的性能最好,但是在连接数较少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。
四、epoll的应用场景
- 适合使用epoll的应用场景:
1、有大量连接请求,但是活跃的连接不多的情况下。
- 不适合使用epoll的应用场景
1、连接请求比较少时,优先考虑使用select或者是poll。
五、面试题
Q:使用Linux epoll模型,水平触发模式;当socket可写时,会不停的触发socket可写的事件,如何处理?
答:第一种方式,需要向socket写数据的时候,才把socket加入到epoll事件表中,等待可写事件。当触发可写事件后,调用write/send系统调用发送数据。当所有数据发送完成后,又将socket移出epoll事件表。
这种方式的缺点是,即使发送很少的数据(数据量<发送缓冲区),也要把socket加入到epoll,写完后再移出epoll,有一定的操作代价。
第二种方式,开始不把socket加入到epoll中,需要向socket写数据的时候,直接调用write/send系统调用发送数据。如果返回EAGAIN错误码(此时说明发送的数据量>socket发送缓冲区的大小,需要再次发送)时,再把socket加入到epoll中,然后在epoll的驱动下写数据,当全部数据发送完毕后,再移出epoll。
这种方式的优点是,发送的数据量不多的时候(数据量<socket发送缓冲区),可以避免epoll的事件处理,提供效率。
<说明> 对于非阻塞的socket,调用write/read这种阻塞模式下的系统调用时,返回EAGAIN错误码,不是一种错误,而是说明资源暂时不可用,可能需要等到下一次重试后可用。比如说上面的情况,发送缓冲区已满,无法接受更多的发送数据,需要等待发送缓冲区有空闲空间时,才能再次发送数据到发送缓冲区中。
EAGAIN的另一个名字叫EWOULDAGAIN,这两个宏定义的值是一样的。
参考
《Linux高性能服务器编程》第9章 - I/O复用:第9.3节 - epoll 系列系统调用