I/O复用基本概念
IO多路复用是指内核一旦发现进程指定的一个或者多个IO条件准备读取,它就通知该进程。
IO多路复用适用如下场合:
- (1)当客户处理多个描述字时(一般是交互式输入和网络套接口),必须使用I/O复用。
- (2)当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现。
- (3)如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到I/O复用。
- (4)如果一个服务器即要处理TCP,又要处理UDP,一般要使用I/O复用。
- (5)如果一个服务器要处理多个服务或多个协议,一般要使用I/O复用。
与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。
select接口与说明
#include <sys/select.h>
#include <sys/time.h>
/*
@detail select模型
@param fdsp1:最大描述符值 + 1
@param *readfds:对可读感兴趣的描述符集
@param *writefds:对可写感兴趣的描述符集
@param *errorfds:对出错感兴趣的描述符集
@param *timeout:超时时间
@return 返回发生变化的描述符总数,返回0意味着超时。失败则返回-1并设置errno
可能出现的错误有:
EBADF(无效描述符);
EINTR(因终端而返回);
EINVAL(nfds或timeout取值错误);
*/
int select(int fdsp1,
fd_set *readfds,
fd_set *writefds,
fd_set *errorfds,
const struct timeval *timeout);
/* 设置描述符集合通常用如下几个宏定义: */
FD_ZERO(fd_set *fdset); /* clear all bits in fdset */
FD_SET(int fd, fd_set *fdset); /* turn on the bit for fd in fd_set */
FD_CLR(int fd, fd_set *fdset); /* turn off the bit for fd in fd_set */
int FD_ISSET(int fd, fd_set *fdset); /* is the bit for fd on in fdset? */
// 当select返回时,描述符集合将被修改以指示哪些个描述符正处于可读、可写或有错误状态。
// 可以用FD_ISSET宏对描述符进行测试以找到状态变化的描述符。
// 如果select因为超时而返回的话,所有的描述符集合都将被清空。
函数参数介绍如下:
(1)第一个参数maxfdp1指定待测试的描述字个数,它的值是待测试的最大描述字加1(因此把该参数命名为maxfdp1)描述字0、1、2...maxfdp1-1均将被测试。因为文件描述符是从0开始的。
(2)中间的三个参数readset、writeset和exceptset指定我们要让内核测试读、写和异常条件的描述字。如果对某一个的条件不感兴趣,就可以把它设为空指针。struct fd_set可以理解为一个集合,这个集合中存放的是文件描述符,可通过以下四个宏进行设置:
void FD_ZERO(fd_set *fdset); //清空集合
void FD_SET(int fd, fd_set *fdset); //将一个给定的文件描述符加入集合之中
void FD_CLR(int fd, fd_set *fdset); //将一个给定的文件描述符从集合中删除
int FD_ISSET(int fd, fd_set *fdset); // 检查集合中指定的文件描述符是否可以读写
(3)timeout告知内核等待所指定描述字中的任何一个就绪可花多少时间。其timeval结构用于指定这段时间的秒数和微秒数。
struct timeval {
long tv_sec; //秒
long tv_usec; //微妙
};
这个参数有三种可能:
- 1)永远等待下去:仅在有一个描述字准备好I/O时才返回。为此,把该参数设置为空指针NULL。
- 2)等待一段固定时间:在有一个描述字准备好I/O时返回,但是不超过由该参数所指向的timeval结构中指定的秒数和微秒数。
- 3)根本不等待:检查描述字后立即返回,这称为轮询。为此,该参数必须指向一个timeval结构,而且其中的定时器值必须为0。
select函数会在发生以下情况时返回:
- readfds集合中有描述符可读
- writefds集合中有描述符可写
- errorfds集合中有描述符遇到错误条件
- 指定的超时时间timeout到了
select的几大缺点:
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
- 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
- select支持的文件描述符数量太小了,默认是1024
poll接口与说明
#include <poll.h>
/*
@detail poll模型
@param fds struct pollfd结构体,定义如下:
struct pollfd {
int fd; // 文件描述符
short events; // 注册事件
short revents; // 实际发生的事件 由内核填充
};
// event 常用事件:
POLLIN 读事件
POLLOUT 写事件
POLLERR 错误事件
POLLRDHUP 对方关闭事件
@param nfds 指定被监听的事件集合fds的大小
@param timeout 指定等待的毫秒数,无论I/O是否准备好,poll都会返回
timeout < 0表示无限超时,使poll()一直挂起直到一个指定事件发生;
timeout = 0指示poll调用立即返回并列出准备好I/O的文件描述符.
*/
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
基本原理
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态;
如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时;
被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历
它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点
1)大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
2)poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
注意:从上面看,select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
epoll接口与说明
/*
@detail 创建一个epoll的句柄
@param size 自从linux2.6.8之后,size参数是被忽略的
@return 返回就绪描述符的个数
*/
int epoll_create(int size);
/*
@detail epoll的事件注册函数
@param epfd epoll_create()的返回值
@param op 动作,用三个宏来表示:
EPOLL_CTL_ADD:注册新的fd到epfd中;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
EPOLL_CTL_DEL:从epfd中删除一个fd;
@param fd 需要监听的fd
@param event 告诉内核需要监听什么事, 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_data_t data;
};
// events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
*/
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/*
@detail 收集在epoll监控的事件中已经发送的事件
@param epfd epoll_create()的返回值
@param events 分配好的epoll_event结构体数组
@param maxevents 告之内核这个events有多大
@param timeout 超时时间(毫秒, 0会立即返回,-1是阻塞)
@return 返回对应I/O上已准备好的文件描述符数目,如返回0表示已超时
*/
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
EPOLL工作模式
epoll有两个工作模式,ET和LT,其中ET模式是高速模式,叫做边缘触发模式,LT模式是默认模式,叫做水平触发模式。
这两种工作模式的区别在于:
当工作在ET模式下,如果一个描述符上有数据到达,然后读取这个描述符上的数据如果没有将数据全部读完的话,当下次epoll_wait返回的时候这个描述符里的数据就再也读取不到了,因为这个描述符不会再次触发返回,也就没法去读取,所以对于这种模式下对一个描述符的数据的正确读取方式是用一个死循环一直读,读到么有数据可读的情况下才可以认为是读取结束。
而工作在LT模式下,这种情况就不会发生,如果对一个描述符的数据没有读取完成,那么下次当epoll_wait返回的时候会继续触发,也就可以继续获取到这个描述符,从而能够接着读。
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
epoll实现
红黑树 rdlist链表rdlist(就绪描述符链表)
红黑树是用来存储这些描述符的,因为红黑树的特性,就是良好的插入,查找,删除性能O(lgN)。
当内核初始化epoll的时候(当调用epoll_create的时候内核也是个epoll描述符创建了一个文件,
毕竟在Linux中一切都是文件,而epoll面对的是一个特殊的文件,和普通文件不同)
会开辟出一块内核高速cache区,这块区域用来存储我们要监管的所有的socket描述符,
当然在这里面存储一定有一个数据结构,这就是红黑树,
由于红黑树的接近平衡的查找,插入,删除能力,在这里显著的提高了对描述符的管理。
rdlist 就绪描述符链表这是一个双链表,epoll_wait()函数返回的也是这个就绪链表。
> 当内核创建了红黑树之后,同时也会建立一个双向链表rdlist,用于存储准备就绪的描述符
当调用epoll_wait的时候在timeout时间内,只是简单的去管理这个rdlist中是否有数据
如果没有则睡眠至超时
如果有数据则立即返回并将链表中的数据赋值到events数组中。
这样就能够高效的管理就绪的描述符,而不用去轮询所有的描述符。
所以当管理的描述符很多但是就绪的描述符数量很少的情况下如果用select来实现的话效率可想而知,很低
但是epoll的话确实是非常适合这个时候使用。
> 对与rdlist的维护:当执行epoll_ctl时除了把socket描述符放入到红黑树中之外,还会给内核中断处理程序注册一个回调函数
告诉内核,当这个描述符上有事件到达(或者说中断了)的时候就调用这个回调函数。
这个回调函数的作用就是将描述符放入到rdlist中,所以当一个socket上的数据到达的时候内核就会把网卡上的数据复制到内核
然后把socket描述符插入就绪链表rdlist中。
epoll、poll和select的区别
1、select的几大缺点:
- (1)每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
- (2)同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
- (3)select支持的文件描述符数量太小了,默认是1024。
2、poll的实现和select非常相似
只是描述fd集合的方式不同,poll使用pollfd结构而不是select的fd_set结构,其他的都差不多。
3、epoll
epoll既然是对select和poll的改进,就应该能避免上述的三个缺点。那epoll都是怎么解决的呢?
在此之前,我们先看一下epoll和select和poll的调用接口上的不同,select和poll都只提供了一个函数——select或者poll函数。
而epoll提供了三个函数,epoll_create,epoll_ctl和epoll_wait,
- epoll_create是创建一个epoll句柄;
- epoll_ctl是注册要监听的事件类型;
- epoll_wait则是等待事件的产生。
对于第一个缺点
epoll的解决方案在epoll_ctl函数中。每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次。
对于第二个缺点
epoll的解决方案不像select或poll一样每次都把current轮流加入fd对应的设备等待队列中,而只在epoll_ctl时把current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表)。
epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd
对于第三个缺点
epoll没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。
总结:
(1)select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。
(2)select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。