目录
select
poll
epoll
IO分两步:<1> 等 <2> 数据拷贝
- 高效IO:拷贝数据的比重越高 --> 大部分时间进行数据传输 --> IO越高效
- 低效IO:等待的比重越高 -->大部分时间在阻塞等待-->IO越低效
五种IO模型:(钓鱼例子 【前四种为同步IO,第五种是异步IO】)
- 阻塞IO: 在内核将数据准备好之前, 系统调用会⼀直等待,所有的套接字, 默认都是阻塞方式 (最常见的IO模型)
- 非阻塞IO: 如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回错误码 (常以轮询方式读写fd,浪费资源)
- 信号驱动IO: 内核将数据准备好的时候, 使用SIGIO信号通知应用程序进行数据拷贝操作
- IO多路转接: 与阻塞IO类似,实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态,有效减少等待时间
- 异步IO: 由内核在数据拷贝完成时, 通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据)
同步通信和异步通信:
- 同步:由调用者主动等待这个调用的结果
- 异步:调用在发出之后,这个调用就直接返回了,所以没有返回结果
I/O多路转接 也叫做 I/O多路复用:
IO多路复用是指内核一旦发现进程指定的一个或者多个IO条件准备读取,它就通知该进程。
IO多路复用适用如下场合:
与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。
接下来我们来了解I/O多路转接下的三种方式:
一、函数原型:
select只负责等,用户将需要监听的文件描述符集合通知给select,当有一个或多个文件描述符就位的时候,它就返回已就绪的文件描述符的数目。
#include
#include
int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)
返回值:就绪描述符的数目,超时返回0,出错返回-1
maxfdp1:一般设为需要监视的三组fd_set中所含的最大fd值+1(如在readset, writeset, exceptset中所含最大的fd为5,则nfds=6,因为fd是从0开始的)
readset、writeset、exceptset:分别对应于需要检测的可读⽂件描述符的集合,可写⽂件描述符的集合及异常⽂件描述符的集合;如果对某一个的条件不感兴趣,就可以把它设为空指针。
struct fd_set可以理解为一个位图,这个集合中存放的是文件描述符,※※※既是输入型参数,又是输出型参数,用来传递信息,可通过以下四个宏进行设置:
void FD_ZERO(fd_set *fdset); //清空描述词组set的全部位
void FD_SET(int fd, fd_set *fdset); //设置描述词组set中相关fd的位
void FD_CLR(int fd, fd_set *fdset); //清除描述词组set中相关fd的位
int FD_ISSET(int fd, fd_set *fdset); //测试描述词组set中相关fd的位是否为真
timeout:告知内核等待所指定描述字中的任何一个就绪可花多少时间。其timeval结构⽤来设置select()的等待时间,该参数有三个返回值:
执⾏成功则返回⽂件描述词状态已改变的个数
如果返回0代表在描述词状态改变前已超过timeout时间,没有返回
当有错误发⽣时则返回-1,错误原因存于errno,此时参数readfds,writefds, exceptfds和timeout的值变成不可预测。
二、理解select的执行过程:
理解select模型的关键在于理解fd_set,为说明⽅便,取fd_set⻓度为1字节(8bite),fd_set中的每⼀bit可以对应⼀个文件描述符fd。则1字节长的fd_set最大可以对应8个fd.
三、select的特点:
<1>可监控的文件描述符个数取决与sizeof(fdset)的值, 若服务器上sizeof(fdset)=128,每一个bit表示⼀个文件描述符,则服务器上支持的最大文件描述符是128*8=1024
<2>将fd加入select监控集的同时,还要再使用⼀个数组array保存放到select监控集中的fd. ⼀是用于在select 返回后,array作为源数据和fdset进行FDISSET判断。 ⼆是select返回后会把以前加入的但并无事件发生的fd清空(参考上文执行过程),则每次开始select前都要重新从array取得fd逐⼀加入(FD_ZERO最先),扫描array的同时取得fd最大值maxfd,用于select的第⼀个参数。 ( fd_set的大小可以调整,涉及到重新编译内核)
四、select的缺点:(优点:可以同时处理多个请求 , 效率相对而言高)
适用场景:拥有大量连接但是只有少量连接是活跃的。
poll的机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
一、函数原型:
调用时用户告诉系统关心fd描述符的events事件,返回时系统会告诉用户该事件已就绪。
# include
int poll ( struct pollfd * fds, unsigned int nfds, int timeout);
//fds是⼀个poll函数监听的结构列表. 每⼀个元素中, 包含了三部分内容: ⽂件描述符, 监听的事件集
//合, 返回的事件集合.
//nfds表⽰fds数组的⻓度.
//timeout表⽰poll函数的超时时间, 单位是毫秒(ms)
struct pollfd
{
int fd; /* 文件描述符 */
short events; /* 等待的事件 */
short revents; /* 实际发生了的事件 */
} ;
/*
返回值⼩于0, 表⽰出错;
返回值等于0, 表⽰poll函数等待超时;
返回值⼤于0, 表⽰poll由于监听的⽂件描述符就绪⽽返回.
*/
/*
events和revents的取值:
事件 描述
POLLIN 有数据可读。
POLLRDNORM 有普通数据可读。
POLLRDBAND 有优先数据可读。
POLLPRI 有紧迫数据可读。
POLLOUT 写数据不会导致阻塞。
POLLWRNORM 写普通数据不会导致阻塞。
POLLWRBAND 写优先数据不会导致阻塞。
POLLMSGSIGPOLL 消息可用。
POLLER 指定的文件描述符发生错误。
POLLHUP 指定的文件描述符挂起事件。
POLLNVAL 指定的文件描述符非法。
*/
二、poll的优点:
三、poll的缺点:
poll中监听的文件描述符数目增多时 :
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的句柄*/
int epoll_create(int size);
//⾃从linux2.6.8之后,size参数是被忽略的.
//⽤完之后, 必须调⽤close()关闭,返回值epfd.
/*epoll的事件注册函数*/
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
//它不同于select()是在监听事件时告诉内核要监听什么类型的事件, ⽽是在这⾥先注册要监听的事件类型.
//第⼀个参数是epoll_create()的返回值(epoll的句柄).
//第⼆个参数表⽰动作,⽤三个宏来表示:
EPOLL_CTL_ADD :注册新的fd到epfd中;
EPOLL_CTL_MOD :修改已经注册的fd的监听事件;
EPOLL_CTL_DEL :从epfd中删除⼀个fd;
//第三个参数是需要监听的fd.
//第四个参数是告诉内核需要监听什么事:events可以是以下⼏个宏的集合:
EPOLLIN : 表⽰对应的⽂件描述符可以读 (包括对端SOCKET正常关闭);
EPOLLOUT:表⽰对应的⽂件描述符可以写;
EPOLLPRI:表⽰对应的⽂件描述符有紧急的数据可读(这⾥应该表⽰有带外数据到来);
EPOLLERR : 表⽰对应的⽂件描述符发⽣错误;
EPOLLHUP : 表⽰对应的⽂件描述符被挂断;
EPOLLET : 将EPOLL设为边缘触发(ET)模式, 这是相对于⽔平触发(LT)来说的.
EPOLLONESHOT:只监听⼀次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要再次把这个socket加⼊到EPOLL队列⾥.
/*收集在epoll监控的事件中已经发送的事件*/
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
//参数events是分配好的epoll_event结构体数组.
//epoll将会把发⽣的事件赋值到events数组中 (events不可以是空指针,内核只负责把数据复制到这个events数组中,不会去帮助我们在⽤户态中分配内存).
//maxevents告知内核这个events有多⼤,这个maxevents的值不能⼤于创建epoll_create()时的size.
//参数timeout是超时时间 (毫秒,0会⽴即返回,-1是永久阻塞).
//如果函数调⽤成功,返回对应I/O上已准备好的⽂件描述符数目,如返回0表⽰已超时, 返回⼩于0表示函数失败.
epoll的使用过程:(共3步)
二、工作原理:
三、epoll的优点:
<1>文件描述符数目无上限: 通过epoll_ctl()来注册⼀个文件描述符, 内核中使用红黑树来管理所有需要监控的文件描述符.
<2>基于事件的就绪通知方式: ⼀旦被监听的某个⽂件描述符就绪, 内核会采用类似于callback的回调机制, 迅速激活这个文件描述符. 这样随着文件描述符数量的增加, 也不会影响判定就绪的性能;
<3>维护就绪队列: 当文件描述符就绪, 就会被放到内核中的⼀个就绪队列中. 这样调用epoll_wait获取就绪文件描述符的时候, 只要取队列中的元素即可, 操作的时间复杂度是O(1);
<4>内存映射机制: 内核直接将就绪队列通过mmap的方式映射到用户态. 避免了拷贝内存这样的额外性能开销.
epoll既然是对select和poll的改进,就应该能避免select的三个缺点:
- 每次调用用select,都需要把fd_set集合从用户态拷贝到内核态,这个开销在fd很多时会很大.
- 每次调用select都需要在内核(轮询)遍历传递进来的所有fd,这个开销在fd很多时也很大.
- select支持的文件描述符数量太小,原因:fd_set决定了select同时管理的链接数是有上限的(通常是128*8=1024).
那epoll都是怎么解决的呢?
- 对于第一个缺点,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(利用schedule_timeout()实现睡一会,判断一会的效果)
- 对于第三个缺点,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内部定义的等待队列)。这也能节省不少的开销。
select是不断轮询去监听的socket,socket个数有限制,一般为1024个;
poll还是采用轮询方式监听,只不过没有个数限制;
epoll并不是采用轮询方式去监听了,而是当socket有变化时通过回调的方式主动告知用户进程
四、epoll工作方式
epoll有2种工作方式:select和poll其实也是工作在LT模式下. epoll既可以支持LT, 也可以支持ET.
总结:
LT:读数据时只要没读完每一次操作系统EPOLL模型都会将该文件描述符对应的读事件就绪添加到就绪队列(不用一次读完)
ET:只有数据到来时或增多时才会将事件添加到就绪队列,所以必须一次读完 【从无到有,有到多】
ET模式:循环读 + 非阻塞接口
五、epoll的使用场景
对于多连接, 且多连接中只有⼀部分连接比较活跃时, 比较适合使用epoll.
例如, 典型的⼀个需要处理上万个客户端的服务器, 例如各种互联网APP的入口服务器, 这样的服务器就很适合epoll.
如果只是系统内部, 服务器和服务器之间进行通信, 只有少数的几个连接, 这种情况下用epoll就并不合适
六、epoll的惊群问题
有一个单进程的linux epoll服务器程序,在服务高峰期间并发的网络请求非常大,目前的单进程版本的支撑不了:单进程时只有一个循环先后处理epoll_wait()到的事件,使得某些不幸排队靠后的socket fd的网络事件得不到及时处理;所以希望将它改写成多进程版本:
接着就遇到了“惊群”现象:当listen_fd有新的accept()请求过来,操作系统会唤醒所有子进程(因为这些进程都epoll_wait()同 一个listen_fd,操作系统又无从判断由谁来负责accept,索性干脆全部叫醒……),但最终只会有一个进程成功accept,其他进程 accept失败。外国IT友人认为所有子进程都是被“吓醒”的,所以称之为Thundering Herd(惊群)。
打个比方,街边有一家麦当劳餐厅,里面有4个服务小窗口,每个窗口各有一名服务员。当大门口进来一位新客人,“欢迎光临!”餐厅大门的感应式门铃自动响了 (相当于操作系统底层捕抓到了一个网络事件),于是4个服务员都抬起头(相当于操作系统唤醒了所有服务进程)希望将客人招呼过去自己所在的服务窗口。但结 果可想而知,客人最终只会走向其中某一个窗口,而其他3个窗口的服务员只能“失望叹息”(这一声无奈的叹息就相当于accept()返回EAGAIN错误),然后埋头继续忙自己的事去。
这样子“惊群”现象必然造成资源浪费,那有没有好的解决办法呢?
lighttpd的解决思路:无视惊群。采用Watcher/Workers模式,具体措施有优化fork()与epoll_create()的位置(让每个子进程自己去 epoll_create()和epoll_wait()),捕获accept()抛出来的错误并忽视等。这样子一来,当有新accept时仍将有多个 lighttpd子进程被唤醒。