文章目录
- I/O多路转接之epoll
- epoll初识
- epoll的相关系统调用函数
- epoll_create
- epoll_ctl
- epoll_wait
- epoll工作原理
- epoll服务器-*
- epoll的优缺点
- epoll工作方式
- 对比LT和ET
I/O多路转接之epoll
epoll初识
epoll也是系统提供的一个多路转接的接口,epoll才是使用和面试的重点,在效率和易用性方面都有提升,
- 与select和poll的定位是一样的, epoll系统调用也可以让我们的程序同时监视多个文件描述符上的事件是否就绪,适用场景也相同
- epoll在命名上比poll多了一个e,这个e可以理解成是extend,epoll就是为了同时处理大量文件描述符而改进的poll
- epoll在2.5.44内核中被引进,它几乎具备了select和poll的所有优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法
epoll的相关系统调用函数
epoll有三个相关的系统调用,分别是epoll_create、epoll_ctl和epoll_wait,
epoll_create
epoll_create函数用于创建一个epoll模型
#include <sys/epoll.h>
int epoll_create(int size);
参数说明
size:自从Linux2.6.8之后,size参数是被忽略的,但为了向前兼容,大多写成128或256, 但size的值必须设置为大于0的值
返回值说明
返回的是epoll句柄, 也就是对应的文件描述符,对应创建一个epoll模型, 否则返回-1,同时错误码会被设置
注意: 当不再使用该epoll模型时,必须调用close函数关闭epoll模型对应的文件描述符,当所有引用epoll实例的文件描述符都已关闭时,内核将销毁该实例并释放相关资源
如图所示,epoll_create 返回值本质是个文件描述符
epoll_ctl
不管是哪种多路转接方案,都要进行的工作步骤是:用户告诉内核和内核告诉用户,而epoll_ctl
负责的就是用户告诉内核的任务,告诉内核你需要帮我关心文件描述符上的什么事件
epoll_ctl函数用于向指定的epoll模型中注册事件,
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数说明
- epfd:指定的epoll模型 -> 也就是epoll_create函数的返回值
- op:表示具体的动作,用三个宏来表示
- op的取值有以下三种:
EPOLL_CTL_ADD
:注册新的文件描述符到指定的epoll模型中,EPOLL_CTL_MOD
:修改已经注册的文件描述符的监听事件,EPOLL_CTL_DEL
:从epoll模型中删除指定的文件描述符,
- op的取值有以下三种:
- fd:需要监视的文件描述符, ( 用来指定关心的文件描述符)
- event:需要监视该文件描述符上的哪些事件
关于
struct epoll_event
结构体
struct epoll_event结构中有两个成员:第一个成员events表示的是需要监视的事件,第二个成员data是一个联合体结构,一般选择使用该结构当中的fd,表示需要OS帮我们关心的文件描述符
关于events的取值:
宏 | 解释 |
---|---|
EPOLLIN | 表示对应的文件描述符可以读(包括对端SOCKET正常关闭) |
EPOLLOUT | 表示对应的文件描述符可以写 |
EPOLLPRI | 表示对应的文件描述符有紧急的数据可读(带外数据) |
EPOLLERR | 表示对应的文件描述符发生错误 |
EPOLLHUP | 表示对应的文件描述符被挂断 |
EPOLLET | 将EPOLL设为边缘触发(Edge Triggered)模式 |
EPOLLONESHOT | 只监听一次事件,本次之后自动将该fd删去 |
同样的,这些都是以宏的方式进行定义的,其二进制序列当中有且只有一个比特位是1,且为1的比特位是各不相同的
返回值说明
函数调用成功返回0,调用失败返回-1,同时错误码会被设置,
注意:和 select 和 poll 不同,epoll_ctl 函数向内核提供用户所关心的fd和事件时只用提供一次,后序OS都会记得, 如果要删除或修改就再调用时修改下op
epoll_wait
epoll_wait
负责的就是内核告诉用户特定fd事件就绪的任务,
- epoll_ctl函数用于收集监视的文件描述符中已经就绪的事件
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
参数说明
- epfd:指定的epoll模型 -> 即:epoll_create的返回值
- events:内核会将已经就绪的事件拷贝到events数组当中(events不能是空指针,内核只负责将就绪事件拷贝到该数组中,不会帮我们在用户态中分配内存)
- maxevents:events数组中的元素个数,该值不能大于创建epoll模型时传入的size值
- timeout:表示epoll_wait函数的超时时间,单位是毫秒(ms)
关于参数timeout的取值
- -1:epoll_wait调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪
- 0:epoll_wait调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,epoll_wait检测后都会立即返回
- 特定的时间值:epoll_wait调用后在指定的时间内进行阻塞等待,如果被监视的文件描述符上一直没有事件就绪,则在该时间后epoll_wait进行超时返回
返回值说明
- 如果函数调用成功,则返回有事件就绪的文件描述符个数
- 如果timeout时间耗尽,则返回0
- 如果函数调用失败,则返回-1,同时错误码会被设置
- epoll_wait调用失败时,错误码可能被设置为:
EBADF
:传入的epoll模型对应的文件描述符无效EFAULT
:events指向的数组空间无法通过写入权限访问EINTR
:此调用被信号所中断EINVAL
:epfd不是一个epoll模型对应的文件描述符,或传入的maxevents值小于等于0
- epoll_wait调用失败时,错误码可能被设置为:
注意:和 select 和 poll 不同,epoll_wait 不需要遍历第三方数组或容器来检测哪些文件描述符上的事件已经就绪,epoll_wait 会将已经就绪的文件描述符上的事件其封装成 epoll_event 结构按顺序输出到缓冲区,具体个数就是返回值
epoll工作原理
之前poll和select的策略
数据从硬件拷贝到内核,操作系统在将其拷贝到进行相关的各种缓冲区中,此时数据的事件才算就绪,操作系统会顺便遍历检测下其他fd下的的数据是否就绪是否需要拷贝,然后CPU将进程从等待队列中唤醒,操作系统在通知上层哪些fd的那些事件已经就绪了,select和poll是这样的策略
epoll模型的: 红黑树和就绪队列
当某一进程调用epoll_create函数时,Linux内核会创建一个eventpoll结构体,也就是我们所说的epoll模型,eventpoll结构体当中的成员红黑树根节点rbr和就绪队列rdlist 与epoll的使用方式密切相关
struct eventpoll{
...
//红黑树的根节点,这棵树中存储着所有添加到epoll中的需要监视的事件
struct rb_root rbr;
//就绪队列中则存放着将要通过epoll_wait返回给用户的满足条件的事件
struct list_head rdlist;
...
}
- epoll模型当中的红黑树本质就是告诉内核,需要监视哪些文件描述符上的哪些事件,调用epll_ctl函数实际就是在对这颗红黑树进行对应的增删改操作,
- epoll模型当中的就绪队列本质就是告诉内核,哪些文件描述符上的哪些事件已经就绪了,调用epoll_wait函数实际就是在从就绪队列当中获取已经就绪的事件
在epoll中,对于每一个事件都会有一个对应的epitem结构体,红黑树和就绪队列当中的节点分别是基于epitem结构中的rbn成员和rdllink成员的,epitem结构当中的成员ffd记录的是指定的文件描述符值,event成员记录的就是该文件描述符对应的事件
struct epitem{
struct rb_node rbn; //红黑树节点
struct list_head rdllink; //双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
- 对于epitem结构当中rbn成员来说,ffd与event的含义是,需要监视ffd上的event事件是否就绪
- 对于epitem结构当中的rdlink成员来说,ffd与event的含义是,ffd上的event事件已经就绪了
说明:
- 红黑树是一种二叉搜索树,因此必须有键值key,而这里的文件描述符就天然的可以作为红黑树的key值,
- 调用epoll_ctl向红黑树当中新增节点时,如果设置了
EPOLLONESHOT
选项,当监听完这次事件后,如果还需要继续监听该文件描述符则需要重新将其添加到epoll模型中,本质就是当设置了EPOLLONESHOT
选项的事件就绪时,操作系统会自动将其从红黑树当中删除, - 而如果调用epoll_ctl向红黑树当中新增节点时没有设置
EPOLLONESHOT
,那么该节点插入红黑树后就会一直存在,除非用户调用epoll_ctl将该节点从红黑树当中删除,
回调机制
所有添加到红黑树当中的事件,都会与设备(网卡)驱动程序建立回调方法
- 对于select和poll来说,操作系统在监视多个文件描述符上的事件是否就绪时,需要让操作系统主动对这多个文件描述符进行轮询检测,这一定会增加操作系统的负担
- 而对于epoll来说,操作系统不需要主动进行事件的检测,当红黑树中监视的事件就绪时,会自动调用对应的回调方法,将就绪的事件添加到就绪队列当中
- 当用户调用epoll_wait函数获取就绪事件时,只需要关注底层就绪队列是否为空,如果不为空则将就绪队列当中的就绪事件拷贝给用户即可
采用回调机制最大的好处,就是不再需要操作系统主动对就绪事件进行检测了,当事件就绪时会自动调用对应的回调函数进行处理
具体过程:
在操作系统将数据拷贝到缓冲区中后,执行回调机制:拿缓冲区内容和对应fd在就绪队列中新增一个节点,然后再唤醒进程,epoll_wait 就会检测这个就绪队列,再向上层通知就绪情况
说明一下:
- 只有添加到红黑树当中的事件才会与底层建立回调方法,因此只有当红黑树当中对应的事件就绪时,才会执行对应的回调方法将其添加到就绪队列当中,
- 当不断有监视的事件就绪时,会不断调用回调方法向就绪队列当中插入节点,而上层也会不断调用epoll_wait函数从就绪队列当中获取节点,这是典型的生产者消费者模型,
- **由于就绪队列可能会被多个执行流同时访问,因此必须要使用互斥锁对其进行保护,**eventpoll结构当中的lock和mtx就是用于保护临界资源的,因此epoll本身是线程安全的,
- eventpoll结构当中的wq(wait queue)就是等待队列,当多个执行流想要同时访问同一个epoll模型时,就需要在该等待队列下进行等待
简述分析:
调用epoll_create
一定是一个进程,而进程会有一个关联的文件描述符数组,当我们创建一个 epoll 模型时,在模型中内核为我们维护一棵红黑树和就绪队列,红黑树节点中存储是用户让内核关注的fd和相关事件并携带其他的一些信息,
比如:
struct rb_tree_node {
int fd;
uint32_t events;
struct rb_tree* left;
struct rb_tree* right;
enum color;
//...
}
在用户向内核中注册fd和相关事件的时候,epoll 会触发底层相应的回调机制:维护一个就绪队列,队列节点中保存fd和就绪的事件,在操作系统将数据拷贝到缓冲区中后,执行回调机制:拿缓冲区内容和对应fd在就绪队列中新增一个节点,然后再唤醒进程,epoll_wait 就会检测这个就绪队列,再向上层通知就绪情况
注意:红黑树、回调机制、就绪队列都是以文件的形式链接在进程相关文件结构中的,上层可直接用fd找到创建的 epoll 模型
epoll三部曲
总结一下,epoll的使用过程就是三部曲:
- 调用epoll_create创建一个epoll模型
- 也就是存储组注册fd及其事件的红黑树、底层缓冲区内有数据后内核触发的回调机制以及保存就绪fd及其事件的就绪队列
- 调用epoll_ctl,将要监控的文件描述符进行注册
- 在红黑树中新增一个节点,对文件描述符及其事件进行增删改
- 其实是对红黑树中的节点进行相应的增删改操作
- 建立该新增描述符的回调策略,当底层有事件就绪的时候,这个回调方法会将发生的事件添加到就绪队列中
- 调用epoll_wait,等待文件描述符就绪
- 以O(1)的事件复杂度,检测是否有事件就绪
- 检查是否有事件发生时:只需要检查队列是否有元素即可,如果队列不为空,则把就绪事件复制到用户态,同时将事件数量返回给用户
- 以O(1)的事件复杂度,检测是否有事件就绪
epoll服务器-*
这里我们实现一个简单的epoll服务器,该服务器也只是读取客户端发来的数据并进行打印,为了方便我们将套接字的接口封装在一个文件当中Sock.hpp
1)首先:我们需要指明epoll服务器的端口号,所以最好是使用命令行参数, 然后调用依次进行套接字的创建、绑定和监听,然后创建epoll模型
- epoll服务器进行事件循环之前,需要先调用epoll_ctl将监听套接字添加到epoll模型当中,服务器刚开始运行时只需要监视监听套接字的读事件是否就绪(即:是否有链接到来)
2)进行事件循环,而epoll服务器要做的就是不断调用epoll_wait函数,从就绪队列当中获取就绪事件进行处理即可,假设epoll_wait函数的返回值为n
- n > 0:说明已经有文件描述符的读事件就绪,并且epoll_wait函数的返回值代表的就是有事件就绪的文件描述符个数,接下来就应该对就绪事件进行处理
- n == 0: 则说明timeout时间耗尽,此时直接准备进行下一次epoll_wait调用即可
- n == -1 : 说明epoll_wait函数出错了,此时也让服务器准备进行下一次epoll_wait调用
- 但实际应该进一步判断错误码,根据错误码来判断是否应该继续调用epoll_wait函数
#include"Sock.hpp"
#include<sys/epoll.h>
#include<iostream>
#include<string>
#define SIZE 128
#define NUM 64
static void Usage(std::string proc)
{
std::cout << "Usage"<<proc<<"port" <<std::endl;
}
//之后我们是这样启动程序的: ./epoll_server port
int main(int argc,char* argv[])
{
if(argc!=2)
{
Usage(argv[0]);
exit(1);
}
//建立套接字,完成绑定监听的操作
uint16_t port = atoi(argv[1]);
int listen_sock = Sock::Socket();
Sock::Bind(listen_sock,port);
Sock::Listen(listen_sock);
//创建epoll模型
int epfd = epoll_create(SIZE);
//将监听套接字添加到epoll模型中,并关心其读事件
struct epoll_event ev;
ev.data.fd = listen_sock;
ev.events = EPOLLIN;//我们关心的是读取事件是否就绪
epoll_ctl(epfd,EPOLL_CTL_ADD,listen_sock,&ev);//向epoll模型当中注册关于listen_sock的事件
//事件循环
struct epoll_event revs[NUM];
for(;;)
{
int timeout = -1;
int n = epoll_wait(epfd,revs,NUM,timeout);
switch(n)
{
case 0:
std::cout << "time out ..." << std::endl;
break;
case -1:
std::cerr << "epoll error ..." << std::endl;
break;
default:
std::cout << "有事件就绪啦!" << std::endl;
break;
}
}
//最后不要忘记关闭监听套接字和epoll模型
close(listen_sock);
close(epfd);
return 0;
}
注意:
1)默认情况下,只要底层有就绪事件,但是我们没有处理,参数epoll会一直通知用户,也就是调用epoll_wait会一直成功返回,并将就绪的事件拷贝到我们传入的数组当中
2)所谓的事件处理并不是调用epoll_wait将底层就绪队列中的就绪事件拷贝到用户层,比如当这里的读事件就绪后,我们应该调用accept获取底层建立好的连接,或调用recv读取客户端发来的数据,这才算是将事件处理了
3)如果我们仅仅是调用epoll_wait将底层就绪队列当中的事件拷贝到应用层,那么这些就绪事件实际并没有被处理掉,底层注册的回调函数会被再次调用,将就绪的事件重新添加到就绪队列当中,本质原因就是我们实际并没有对底层就绪的数据进行读取
事件处理
如果底层就绪队列当中有就绪事件,那么调用epoll_wait函数时就会将底层就绪队列中的事件拷贝到用户提供的revs数组当中,接下来epoll服务器就应该对就绪事件进行处理了,事件处理过程如下:
- 根据epoll_wait时得到的返回值n: 来判断操作系统向我们传入的revs数组中拷贝了多少个struct epoll_event结构,进而对这些文件描述符上的事件进行处理
- 对于每一个拷贝上来的struct epoll_event结构,如果该结构当中的events当中包含读事件,则说明该文件描述符对应的读事件就绪,但接下来还需要进一步判断该文件描述符是监听套接字还是与客户端建立的套接字
- 如果是监听套接字的读事件就绪,则调用accept函数将底层建立好的连接获取上来,并调用epoll_ctl函数将获取到的套接字添加到epoll模型当中,表示下一次调用epoll_wait函数时需要监视该套接字的读事件
- 如果是与客户端建立的连接对应的读事件就绪,则调用recv函数读取客户端发来的数据,并将读取到的数据在服务器端进行打印
- 如果在调用recv函数时发现客户端将连接关闭或recv函数调用失败,则epoll服务器也直接关闭对应的连接
- 并调用epoll_ctl函数将该连接对应的文件描述符从epoll模型中删除,表示下一次调用epoll_wait函数时无需再监视该套接字的读事件
- 客户端进行了退出,服务器也需要关闭与这个客户端链接的文件描述符,因为曾经在内核当中注册了要关心这个文件描述符上的某些事件,所以还要在epoll模型当中把对这个文件描述符的关心的事项去掉,本质就是删除红黑树中对应的节点,并且去掉该文件描述符上底层建立好的回调机制,因为你曾经打开过链接,所以必须关闭,因为曾经添加过文件描述符,所以必须去掉它
#include"Sock.hpp"
#include<sys/epoll.h>
#include<iostream>
#include<string>
#define SIZE 128
#define NUM 64
static void Usage(std::string proc)
{
std::cout << "Usage "<<proc<<" port " <<std::endl;
}
//之后我们是这样启动程序的: ./epoll_server port
int main(int argc,char* argv[])
{
if(argc!=2)
{
Usage(argv[0]);
exit(1);
}
//建立套接字,完成绑定监听的操作
uint16_t port = atoi(argv[1]);
int listen_sock = Sock::Socket();
Sock::Bind(listen_sock,port);
Sock::Listen(listen_sock);
//创建epoll模型
int epfd = epoll_create(SIZE);
//将监听套接字添加到epoll模型中,并关心其读事件
struct epoll_event ev;
ev.data.fd = listen_sock;
ev.events = EPOLLIN;//我们关心的是读取事件是否就绪
epoll_ctl(epfd,EPOLL_CTL_ADD,listen_sock,&ev);//向epoll模型当中注册关于listen_sock的事件
//事件循环
struct epoll_event revs[NUM]; //用于获取内核告诉用户已经就绪的事件
for(;;)
{
int timeout = -1; //表示
//这里传入的数组revs,仅仅是从内核中拿回来已经就绪的事件
int n = epoll_wait(epfd,revs,NUM,timeout);
switch(n)
{
case 0:
std::cout << "time out ..." << std::endl;
break;
case -1:
std::cerr << "epoll error ..." << std::endl;
break;
default:
std::cout << "有事件就绪啦!" << std::endl;
//因为已经把就绪事件按顺序整合在数组中,并且n就是已经就绪的事件个数
for(int i = 0;i<n;i++)
{
int sock = revs[i].data.fd;//获取已经就绪的文件描述符
std::cout << "文件描述符: " << sock << " 上面有事件就绪啦" << std::endl;
if(revs[i].events & EPOLLIN) //读取事件就绪
{
std::cout << "文件描述符: " << sock << " 读事件就绪" << std::endl;
//判断是监听套接字还是普通文件描述符上的读事件就绪
if(sock == listen_sock) //连接事件就绪
{
std::cout << "文件描述符: " << sock << " 链接数据就绪" << std::endl;
//处理链接事件
int fd = Sock::Accept(listen_sock);
if(fd>=0)
{
std::cout << "获取新链接成功啦: " << fd << std::endl;
//此时不能立即读取,因为有链接到来,并不代表该链接上有数据
//如果一旦读取,但是数据不就绪,此时就要被阻塞了进程就被挂起了
//做法:将当前新链接的文件描述符添加到epoll模型当中
struct epoll_event _ev;
_ev.events = EPOLLIN; //只关心读 如果想关心读和写: EPOLLIN | EPOLLOUT
_ev.data.fd = fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &_ev); //新的fd托管给了epoll!
std::cout << "已经将" << fd << " 托管给epoll啦" << std::endl;
}
else
{
std::cout <<"获取新的链接失败"<<std::endl;
continue;
}
}
else //普通文件描述符上的读取事件就绪
{
std::cout << "文件描述符: " << sock << "正常数据就绪" << std::endl;
char buffer[1024] = {0};
//最后一个参数是0表示阻塞读取
//但是此时不会被阻塞,因为现在这个文件描述符上的读事件是就绪的
//少读取一个字符,因为我们把读到的内容当成字符串,最后位置放\0
ssize_t s = recv(sock,buffer,sizeof(buffer)-1,0);
if(s>0)
{
buffer[s] = '\0';
std::cout << "client [" << sock << "]# " << buffer << std::endl;
}
else if(s == 0) //对端关闭链接
{
std::cout << "client quit " << sock << std::endl;
close(sock);
//将当前文件描述符从epoll模型中删除
epoll_ctl(epfd, EPOLL_CTL_DEL, sock, nullptr);
std::cout << "sock: " << sock << "delete from epoll success" << std::endl;
}
else //读取失败
{
std::cout << "recv error" << std::endl;
//将当前文件描述符从epoll模型中删除
close(sock);
epoll_ctl(epfd, EPOLL_CTL_DEL, sock, nullptr);
std::cout << "sock: " << sock << "delete from epoll success" << std::endl;
}
}
}
else if(revs[i].events & EPOLLOUT) //写事件就绪
{}
else //...
{ }
}
break;
}
}
//最后不要忘记关闭监听套接字和epoll模型
close(listen_sock);
close(epfd);
return 0;
}
上述代码中:如果我们读完之后想处理写事件: 将我们的关心的事件更改成为``EPOLLOUT`
ssize_t s = recv(sock, buffer, sizeof(buffer) - 1, 0);
if (s > 0)
{
buffer[s] = 0;
std::cout << "client [" << sock << "]# " << buffer << std::endl;
//假设读完之后想处理写事件: 将我们的关心时间更改成为EPOLLOUT
//将当前新链接的文件描述符重新添加到epoll模型当中(相当于是修改)
struct epoll_event _ev;
_ev.events = EPOLLOUT;
_ev.data.fd = sock;
epoll_ctl(epfd, EPOLL_CTL_MOD, sock, &_ev);
}
服务器测试:
因为编写epoll服务器在调用epoll_wait函数时,我们将timeout的值设置成了-1,因此运行服务器后如果没有客户端发来连接请求,那么服务器就会在调用epoll_wait函数后进行阻塞等待
当我们用telnet工具连接epoll服务器后,epoll服务器调用的epoll_wait函数在检测到监听套接字的读事件就绪后就会调用accept获取建立好的连接,然后把当前新连接的文件描述符放到epoll模型中
下次循环时,当客户端发来的数据, epoll就能告知我们这个新连接的文件描述符读取事件就绪了, 也能够成功被epoll服务器收到并进行打印输出
关于为什么新链接的文件描述符的值是5:
因为监听套接字的文件描述符值是3,然后epoll模型又占了一个位置,其值是4,所以现在最小的没有被使用的文件描述符值就是5
此外,我们这里编写的也是一个单进程的epoll服务器,但是它可以同时为多个客户端提供服务
我们可以用ls /proc/PID/fd
命令,查看当前epoll服务器的文件描述符的使用情况,其中文件描述符0、1、2是默认打开的,分别对应的是标准输入、标准输出和标准错误,3号文件描述符对应的是监听套接字,4号文件描述符对应的是服务器创建的epoll模型,5号和6号文件描述符对应的分别是正在访问服务器的两个客户端
当服务器端检测到客户端退出后,也会关闭对应的连接,此时epoll服务器对应的5号和6号文件描述符就关闭了
epoll的优缺点
优点
1)接口使用方便:接口分离解耦,更方便高效,不需要重新设置fd及事件集合,做到输入输出参数分离
2)数据拷贝轻量:只在新增监视事件的时候调用epoll_ctl将数据从用户拷贝到内核,而select和poll每次都需要重新将需要监视的事件从用户拷贝到内核,此外,调用epoll_wait获取就绪事件时,只会拷贝就绪的事件,不会进行不必要的拷贝操作
3)事件回调机制:避免操作系统主动轮询检测事件就绪,而是采用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中,调用epoll_wait时直接访问就绪队列就知道哪些文件描述符已经就绪,检测是否有文件描述符就绪的时间复杂度是O(1)
4)没有数量限制:监视的文件描述符数目无上限,只要内存允许,就可以一直向红黑树当中新增节点
注意: 有人说epoll中使用了内存映射机制,内核可以直接将底层就绪队列通过mmap的方式映射到用户态,此时用户就可以直接读取到内核中就绪队列当中的数据,避免了内存拷贝的额外性能开销,这种说法是否正确?
- 这种说法是错误的,实际操作系统并没有做任何映射机制,因为操作系统是不相信任何人的,操作系统不会让用户进程直接访问到内核的数据的,用户只能通过系统调用来获取内核的数据
- 因此用户要获取内核当中的数据,势必还是需要将内核的数据拷贝到用户空间
与select和poll的不同之处
1)需要明确得是:在使用select和poll时,都需要借助第三方数组来维护历史上的文件描述符以及需要监视的事件,这个第三方数组是由用户自己维护的,对该数组的增删改操作都需要用户自己来进行
而使用epoll时,不需要用户自己维护所谓的第三方数组,epoll底层的红黑树就充当了这个第三方数组的功能,并且该红黑树的增删改操作都是由内核维护的,用户只需要调用epoll_ctl让内核对该红黑树进行对应的操作即可
2)在使用多路转接接口时,数据流都有两个方向,一个是用户告知内核,一个是内核告知用户,select和poll将这两件事情都交给了同一个函数来完成,而epoll在接口层面上就将这两件事进行了分离,epoll通过调用epoll_ctl完成用户告知内核,通过调用epoll_wait完成内核告知用户
epoll工作方式
epoll的工作方式有两种,分别是LT方式(水平触发)和ET方式(边缘触发)这是epoll特有的模式概念,select和poll没有
水平触发(LT,Level Triggered)
特点:
- 底层只要有事件就绪,只要不被取走就一直通知上层
- 像数字电路当中的高电平触发一样,只要一直处于高电平,则会一直触发
1)由于在LT工作模式下,只要底层有事件就绪就会一直通知用户,因此当epoll检测到底层读事件就绪时,可以不立即进行处理,或者只处理一部分,因为只要底层数据没有处理完,下一次epoll还会通知用户事件就绪
- 所以:当epoll_wait返回后,可以不立刻进行处理或者只处理就绪事件的一部分,之后仍会通知事件就绪,然后再调用epoll_wait,直到缓冲区的所有数据都被处理,
2)LT模式支持阻塞读写和非阻塞读写 select,poll,epoll的状态默认就是LT模式
边缘触发(ET,Edge Triggered)
特点:
- 只有底层就绪事件数量由无到有或由有到多发生变化的时候,epoll才会通知用户,并且只通知这一次
- 就像数字电路当中的上升沿触发一样,只有当电平由低变高的那一瞬间才会触发
因为epoll的状态默认就是LT模式,如果想要将epoll改为ET工作模式:
- 则需要在添加事件时设置
EPOLLET
选项,
1)由于在ET工作模式下,只有底层就绪事件无到有或由有到多发生变化的时候才会通知用户,因此当epoll检测到底层读事件就绪时,必须立即进行处理,而且必须全部处理完毕,因为有可能此后底层再也没有事件就绪,那么epoll就再也不会通知用户进行事件处理,此时没有处理完的数据就相当于丢失了
2)ET工作模式下epoll通知用户的次数一般比LT少,因此ET的性能一般比LT性能更高,Nginx就是默认采用ET模式使用epoll的
3)ET模式必须非阻塞的读写
ET工作模式下应该如何进行读写
在ET工作模式下,只有底层就绪事件无到有或由有到多发生变化的时候才会通知用户,这就倒逼用户当读事件就绪时必须一次性将数据全部读取完毕,当写事件就绪时必须一次性将发送缓冲区写满,否则可能再也没有机会进行读写了
因此读数据时必须循环调用recv函数进行读取,写数据时必须循环调用send函数进行写入
- 当底层读事件就绪时,循环调用recv函数进行读取,直到某次调用recv读取时,实际读取到的字节数小于期望读取的字节数,则说明本次底层数据已经读取完毕了
- 但有可能最后一次调用recv读取时,刚好实际读取的字节数和期望读取的字节数相等,但此时底层数据也恰好读取完毕了,但是我们并不知道数据已经读取完毕了,所以还会继续进行读取,此时我们再调用recv函数进行读取,那么recv就会因为底层没有数据而被阻塞住
- 而这里的阻塞是非常严重的,就比如我们这里写的服务器都是单进程的服务器,如果recv被阻塞住,并且此后该数据再也不就绪,那么就相当于我们的服务器挂掉了,因此在ET工作模式下循环调用recv函数进行读取时,必须将对应的文件描述符设置为非阻塞状态
- 调用send函数写数据时也是同样的道理,需要循环调用send函数进行数据的写入,并且必须将对应的文件描述符设置为非阻塞状态
强调: ET工作模式下,recv和send操作的文件描述符必须设置为非阻塞状态,这是必须的,不是可选的
对比LT和ET
- 在ET模式下**,一个文件描述符就绪之后,用户不会反复收到通知,看起来比LT更高效**
- 但如果在LT模式下能够做到每次都将就绪的文件描述符立即全部处理,不让操作系统反复通知用户的话,其实LT和ET的性能也是一样的
- 此外,ET的编程难度比LT更高
水平触发LT像是一个尽职的快递员,只要你不去取就会一直在提醒你,边缘触发ET干活很随便,只为了完成任务通知一次,之后爱来不来爱取不取