epoll,poll,select是Linux中三种常见的I/O多路复用技术,是为解决程序在进行大量I/O操作时的阻塞问题,使用户在I/O可用时得到通知,而不必一直阻塞等待每一个I/O操作。
Select单个进程可监视的fd数量受到限制,epoll支持水平触发和边沿触发两种模式,epoll和select都可实现同时监听多个I/O事件的状态,poll和select基于轮询,时间复杂度O(n),poll没有最大连接限制(底层采用链表),epoll基于操作系统支持的I/O通知机制,时间复杂度O(1)
epoll底层是红黑树(二叉搜索树),红与黑是实现者关心的内容,对于使用者来说不用关心
epoll是linux下高性能网络服务器的必备,像nginx、redis、skynet和大部分游戏服务器都使用这一多路复用技术,其核心诉求:1、让线程可以注册自己关心的消息类型;2、当FD=123的socket发生变化时,能快速判断哪个线程需要知道这个消息
目前支持I/O多路复用的系统调用有 select,poll,epoll,I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的。而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
I/O多路复用优势和适用场景:
I/O多路复用的优势在于,当处理的消耗对比IO几乎可以忽略不计时,可以处理大量的并发IO,而不用消耗太多CPU/内存。这就像是一个工作很高效的人,手上一个todo list,他高效的依次处理每个任务。这比每个任务单独安排一个人要节省。典型的例子是nginx做代理,代理的转发逻辑相对比较简单直接,那么IO多路复用很适合。相反,如果是一个做复杂计算的场景,计算本身可能是个 指数复杂度的东西,IO不是瓶颈。那么怎么充分利用CPU或者显卡的核心多干活才是关键。
此外,IO多路复用适合处理很多闲置的IO,因为IO socket的数量的增加并不会带来进(线)程数的增加,也就不会带来stack内存,内核对象,切换时间的损耗。因此像长链接做通知的场景非常适合。
select
int
select
(int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
select 函数监视的文件描述符分3类,分别是readfds、writefds、和exceptfds。调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以 通过遍历fdset,来找到就绪的描述符。
优点:
select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。
缺点:
单个进程能够监视的文件描述符的数量存在最大限制,它由FD_SETSIZE设置,默认值是1024。
可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但 是这样也会造成效率的降低。
一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。
32位机默认是1024个。64位机默认是2048.
fd集合在内核被置位过,与传入的fd集合不同,不可重用。
重复进行FD_ZERO(&rset); FD_SET(fds[i],&rset);操作
每次调⽤用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大。
同时每次调用select都需要在内核遍历传递进来的所有fd标志位,O(n)的时间复杂度,这个开销在fd很多时也很大。
poll
int
poll
(struct pollfd *fds, unsigned int nfds, int timeout);
不同与select使用三个位图bitmap来表示三个fdset的方式,poll使用一个 pollfd的指针实现。
struct pollfd {
int fd;
/* file descriptor */
//读 POLLIN; 写POLLOUT;
short events;
/* requested events to watch 要监视的event*/
short revents;
/* returned events witnessed 发生的event*/
};
pollfd结构包含了要监视的event和发生的event,不再使用select “参数-值” 传递的方式。同时,pollfd并没有最大数量限制(但是数量过大后性能也是会下降)。 和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。
优点:
poll用pollfd数组代替了bitmap,没有最大数量限制。(解决select缺点1)
利用结构体pollfd,每次置位revents字段,每次只需恢复revents即可。pollfd可重用。(解决select缺点2)
缺点:
每次调⽤用poll,都需要把pollfd数组从用户态拷贝到内核态,这个开销在fd很多时会很大。(同select缺点3)
和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。(同select缺点4)
epoll
epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
1. epoll操作过程
epoll操作过程需要三个接口,分别如下:
int
epoll_create(int size);
//创建一个epoll的句柄,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. int epoll_create(int size);
创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。
当创建好epoll句柄后,它就会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。
eventpoll结构体如下所示:
struct eventpoll{
...
// 红黑树的根节点,这棵树中存储着所有添加到epoll中的需要监控的事件
struct rb_root rbr;
// 双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件
struct list_head rdlist;
...
}
每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)。
而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。
2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
函数是对指定描述符fd执行op操作。
用于向内核注册新的描述符或者是改变某个文件描述符的状态。已注册的描述符在内核中会被维护在一棵红黑树上
- epfd:是epoll_create()的返回值。
- op:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件。
- fd:是需要监听的fd(文件描述符)
- epoll_event:是告诉内核需要监听什么事,struct epoll_event结构如下:
struct epoll_event {
__uint32_t events;
/* Epoll events */
epoll_data_t data;
/* User data variable */
};
//events可以是以下几个宏的集合:
EPOLLIN
:表示对应的文件描述符可以读(包括对端
SOCKET
正常关闭);
EPOLLOUT
:表示对应的文件描述符可以写
EPOLLPRI
:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR
:表示对应的文件描述符发生错误
EPOLLHUP
:表示对应的文件描述符被挂断
EPOLLET
:
将
EPOLL
设为边缘触发
(Edge Triggered)
模式,这是相对于水平触发
(Level Triggered)
来说的。
EPOLLONESHOT
:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个
socket
的话,需要再次把这个
socket
加入到
EPOLL
队列里
在epoll中,对于每一个事件,都会建立一个epitem结构体,如下所示 :
struct epitem{
struct rb_node rbn;
//红黑树节点
struct list_head rdllink;
//双向链表节点
struct wpoll_filefd ffd;
//事件句柄信息
struct evntpoll *ep;
//指向其所属的eventpoll对象
struct epoll_event event;
//期待发生的事件类型
}
当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户 。
3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待epfd上的io事件,最多返回maxevents个事件。
通过回调函数内核会将 I/O 准备好的描述符添加到rdlist双链表管理,进程调用 epoll_wait() 便可以得到事件完成的描述符。
参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。
2.工作模式
epoll对文件描述符的操作有两种模式:LT (level trigger)(默认)和ET (edge trigger)。LT模式是默认模式。
LT模式与ET模式的区别如下:
LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
1. LT模式
LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。
2. ET模式
ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。