一.高级IO
在介绍多路复用IO之前,先介绍一下其它四种高级IO:
- 阻塞IO: 在内核将数据准备好之前,系统调用会一直等待.所以的套集字默认是阻塞方式.
- 非阻塞IO: 在内核还未将数据准备好,则系统调用仍然会直接返回,并且返回错误码.
- 信号驱动IO: 内核将数据准备好的时候,使用SIGIO信号通知应用程序进行IO操作
- 异步IO: 由内核在数据拷贝完成时,通知应用进程 (信号驱动IO则是告诉应用进程开始拷贝数据)
注意:几种IO效率越来越高,但是流程控制越来越复杂,占用的资源也越来越多.
重点概念的区分理解:
- 阻塞: 为了完成功能发起调用,如果当前不具备完成条件,则调用一直等待
- 非阻塞: 为了完成功能发起调用,如果当前不具备完成条件,则报错返回
- 区别: 发起一个调用是否能立即返回
- 同步: 为了完成功能发起调用,如果当前不具备完成条件,则调用一直等待,直到功能完成后返回
- 异步: 为了完成功能发起调用,但是功能的实现完成过程并不由自身完成
- 区别: 功能的完成是否由 自身完成
- 同步阻塞:一直等待功能的完成
- 同步非阻塞:循环判断是否能够完成功能,能够完成的时候,则进行具体操作
- 异步阻塞:等待别人完成功能
- 异步非阻塞:调用直接返回,不等待被人完成功能.功能完成后通过信号来通知
同步与异步的优缺点分析:
- 同步流程控制更加简单,但是对资源(CPU)的利用率不够
- 异步对资源的利用更加充分,但是流程控制更加复杂一点,同一时间占用的资源也更多
二.多路复用(转接)IO
多路复用IO的概念: 多路复用IO用于对大量描述符进行IO就绪事件监控,能够让用户只针对就绪指定事件的描述符进行操作.
IO就绪事件可分为可读,可写,异常:
- 可读事件: 一个描述符对应的缓存区中有数据可读
- 可写事件: 一个描述符对应的缓存区有剩余空间可以写入数据
- 异常事件: 一个描述符发生了特定的异常信息
相较于其它的IO方式,多路复用IO避免了对没有就绪的描述符进行操作而带来的阻塞,同时只针对已就绪的描述符进行操作,提高了效率.
多路复用IO与多线程/多进程的并发:
多路复用IO模型进行服务器并发处理:
即在单执行流中进行轮询处理就绪的描述符.如果就绪的描述符较多时,很难做到负载均衡. 即最后一个描述符要等待很长的时间,前边的描述符处理完了才能处理它)
解决这一问题的方法就是 : 在用户态实现负载均衡,规定每个描述符只能读取指定数量的数据,读取了就进行下一个描述符.
多路复用IO模型适用于有大量描述符需要监控,但同一时间只有少量活跃的场景.
多线程/多进程进行服务器并发处理:
即操作系统通过轮询调度执行流实现每个执行流中描述符的操作,由于其在内核态实现了负载均衡.由于其在内核态实现了负载均衡,所以不需要再用户态做过多操作.
多路复用适合用于IO密集型服务,多进程或多线程适用于CPU密集型服务.它们各有各的优势,并不存在谁取代谁的倾向.基于两者的特点,通常可以将多路复用IO和多线程/多进程搭配一起使用.
即:使用多路复用IO监控大量的描述符,哪个描述符就绪有事件到来,就创建执行流去处理.这样做的好处是防止直接创建执行流而描述符还未就绪,浪费资源.
在Linux下,操作系统提供了三种模型:select模型,poll模型,epoll模型
select:
//清空集合
void FD_ZERO(fd_set *set);
//向集合中添加描述符fd
void FD_SET(int fd, fd_set *set);
//从集合中删除描述符fd
void FD_CLR(int fd, fd_set *set);
//判断描述符是否还在集合中
int FD_ISSET(int fd, fd_set *set);
//发起调用将集合拷贝到内核中并进行监控
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
/*
fd:文件描述符
set:描述符位图
nfds:集合中最大描述符数值+1
readfds:可读事件集合
writefds:可写事件集合
exceptfds:异常事件集合
timeout:超时等待时间
timeval结构体有两个成员
struct timeval {
long tv_sec; 毫秒
long tv_usec; 微秒
};
*/
工作原理:
- 定义指定监控事件的描述符集合(位图),初始化集合后,将需要监控的指定事件的描述符添加到指定事件(可读,可写,异常)的描述符集合中
- 将描述符集合拷贝到内核当中,对集合中所有描述符进行轮询判断,当描述符就绪或者等待超时后就调用返回,返回后的集合中只剩下已就绪的描述符(未就绪会在位图中置为0)
- 通过遍历描述符集合(位图),判断哪些描述符还在集合中,就知道哪些描述符已经就绪了,开始对应的IO事件
优缺点分析:
优点:
- select遵循POSIX标准,可以跨平台移植
- select的超时等待时间比较精确,可以精确到微秒
缺点:
- select所能监控的描述符数量有上限,由宏_FD_SETSIZE决定,默认是1024个
- select会将描述符集合拷贝到内核中轮询遍历判断描述符是否就绪,效率会随着描述符的增多而降低
- select返回的集合是一个位图而不是真正的描述符数组,所以需要用户遍历判断哪个描述符在集合中才能确认其是否就绪
- select监控完毕后返回的描述符集合中只有已就绪的描述符,移除了未就绪的描述符,所以每次监控都必须要重新将描述符加入到集合中,重新拷贝到内核
poll:
struct pollfd
{
int fd; //需要监控的文件描述符
short events; //需要监控的事件
short revents; //实际就绪的事件
};
/*
操作相对简单,如果某个描述符不需要继续监控时,直接将对应结构体中的fd置为-1即可。
*/
//发起监控
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
/*
fds:pollfd数组
nfds:数组的大小
timeout:超时等待时间,单位为毫秒
*/
工作原理:
- 定义pollfd结构体数组,将需要监控的描述符以及监控的事件信息添加进去
- 发起监控调用poll,将数组中的数据拷贝到内核当中进行轮询遍历监控,当有描述符就绪或者等待超时后返回,返回时将已就绪的事件添加进pollfd结构体中的revents中(如果没就绪则为0)
- 监控调用返回后,遍历pollfd数组中的每一个节点的revents,根据对应的就绪事件进行相应操作
优缺点分析:
优点:
- poll通过事件描述符件结构体的方式将select的描述符集合的操作流程合并在一起,简化了操作
- poll所监控的描述符数量没有限制,需要多少描述符就给多大的数组
- 不需要每次监控都重新定义事件结构体
缺点:
- 在内核中轮询判断描述符是否就绪,效率会随着描述符的增加而下降
- 每次调用返回后需要用户自行判断revents才能知道是哪个描述符就绪了哪个事件
- 无法跨平台移植
- 超时等待时间只能精确到毫秒
epoll:
struct eventpoll{
//红黑树的根节点
struct rb_root rbr;
//双链表
struct list_head rdlist;
...
};
红黑树以及链表的节点信息由描述符和所要监控的事件等 组成,可以使用epoll_event 结构体进行组织.
struct epoll_event{
unint32_t events;
epoll_data_t data;
};
typedef union epoll_data{
int fd;
}epoll_data_t;
在epoll中,对于每一个事件,都会建立一个epitem结构体:
struct epitem{
struct rb_node rbn; //红黑树节点
struct list_head rdllist; //双向链表节点
struct epoll_filefd ffd;//事件句柄信息
struct eventpoll* ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
};
//在内核中创建eventpoll结构体,返回操作句柄(size为监控的最大数量,但是在linux2.6.8后忽略上限,只需要给一个大于0的数字即可)
int epoll_create(int size);
//组织描述符事件结构体
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/*
epfd:eventpoll结构体的操作句柄
op:操作的选项,EPOLL_CTL_ADD/EPOLL_CTL_MOD/EPOLL_CTL_DEL
fd:描述符
event:监控描述符对应的事件信息结构体
struct epoll_event
{
uint32_t events; // 要监控的事件,以及调用返回后实际就绪的事件
epoll_data_t data; // 联合体,用来存放各种类型的描述符
};
typedef union epoll_data {
int fd;
} epoll_data_t;
*/
//事件:
//1.EPOLLIN:表示对应的文件描述符可以读
//2.EPOLLOUT:表示对应的文件描述可以写
//3.EPOLLET:将epoll设置为边缘触发模式
//4.EPOLLERR:表示对应的文件描述符发送错误
//开始监控,当有描述符就绪或者等待超时后调用返回
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
/*
maxevents:events数组的结点数量
timeout:超时等待时间
返回值为就绪的描述符个数
*/
工作原理:
- 在内核中创建eventpoll结构体,返回一个描述符作为操作句柄
- 对需要监控的描述符组织事件结构体(struct epoll_event), 将描述符和对应事件所对应的epoll_event结构体添加到内核的eventpoll结构体中
- 开始监控,epoll的监控时一个异步阻塞操作,只需要告诉操作系统哪些描述符需要监控,然后这个监控的过程就由操作系统来完成.操作系统为每一个描述符所需要监控的事件设置了一个回调函数,一旦对应事件就绪,就会自动调用回调函数,将描述符所对应的epoll_event事件结构体添加到rdllist双向链表中
- 发起监控后,每隔一段时间就会去查看双向链表rdllist是否为空(阻塞操作,除非链表不为空或者超时才会返回),如果不为空则代表有描述符就绪,将就绪的描述符的结构体信息添加到epoll_wait传入的events数组中.只需要对events数组进行遍历,判断就绪的是什么事件然后对描述符进行相应处理即可
优缺点分析:
epoll是linux下性能最高的多路复用IO模型,几乎具备了一切所需的优点
优点:
- 底层用的红黑树存储,监控的描述符数量没有上限
- 所有的描述符事件信息只需要向内核中拷贝一次
- 监控采用异步阻塞,性能不会随着描述符增多而下降
- 直接返回就绪描述符事件信息,可以直接对就绪描述符进行操作,不需要像select和poll一样遍历判断
缺点:
- 无法跨平台移植
- 超时等待时间只能精确到毫秒
- 在活跃连接较多的时候,由于会大量触发回调函数,所以此时epoll的效率未必会比select和poll高,所以epoll适用于连接数量多,但是活跃连接少的情况
三.epoll的工作模式
epoll有两种工作模式,LT(水平触发模式)和ET(边缘触发模式).
LT(水平触发模式):
水平触发模式是epoll的默认触发模式(select和poll只有这种模式).
触发条件:
- 可读事件: 接收缓冲区中的数据大小高于低水位标记,则会触发事件
- 可写事件: 发送缓存区中的剩余大小大于低水位标记,则会触发事件
- 低水位标记: 一个基准值,默认为1
所以简单点说: 水平触发模式就是只要缓冲区中还有数据,就会一直触发事件.
- 当epoll检测到socket上事件就绪的时候,可以不立即进行处理,或者只处理一部分
- 如上面的例子,由于只读了1k的数据,缓存区还剩1K的数据,在第二次调用epoll_wait的时候,epoll_wait仍然会立即返回并通知读事件就绪
- 直到缓冲区上所有的数据被处理完,epoll_wait才不会立即返回
- 支持阻塞读写和非阻塞读写
ET(边缘触发模式):
边缘触发模式:将socket添加到epoll_event结构体的时候(使用epoll_ctl函数)使用EPOLLET标志,epoll就会进入ET工作模式
触发条件:
- 可读事件:(不关心接收缓存区是否有数据) 每当有新数据到来时才会触发事件
- 可写事件: 剩余空间从无到有的时候才会触发事件
边缘触发模式只有在新数据到来的情况下才会触发事件.这也就要求我们在新数据到来的时候最好能够一次性将所有数据取出,否则不会触发第二次事件,只有等到下次再有新数据到来时才会触发.
而我们也不知道具体有多少数据,所以就需要循环处理,直到缓冲区为空,但是recv是一个阻塞读取,如果没有数据时就会阻塞等待,这时候就需要将描述符的属性设置为非阻塞,才能解决这个问题
当epoll检测到socket上事件就绪时, 必须立刻处理.
如上面的例子, 虽然只读了1K的数据, 缓冲区还剩1K的数据, 在第二次调用 epoll_wait 的时候, epoll_wait 不会再返回了.
也就是说, ET模式下, 文件描述符上的事件就绪后, 只有一次处理机会.
ET的性能比LT性能更高( epoll_wait 返回的次数少了很多). Nginx默认采用ET模式使用epoll.
只支持非阻塞的读写
水平触发模式与边缘触发模式:
水平触发模式就是只要缓冲区中还有数据,就会一直触发事件,而边缘触发模式下只有新数据到来的情况下才会触发事件.
水平触发模式的优缺点分析:
- 优点: 主要在于其简单且稳定,不容易出现问题.传统的select和poll都是使用这个模式
- 缺点: 事件触发过多导致效率降低
边缘触发模式的优缺点分析:
- 优点: 减少了epoll的触发次数,但是也带来了巨大的代价
- 缺点: 要求必须一次性将所有的数据处理完,虽然效率得到了提高,但是代码的复杂程度大大的增加了 Nginx就是默认采用ET模式
还有一种场景适合ET模式使用,如果我们需要接受一条数据,但是这条数据因为某种问题导致其发送不完整,需要分批发送。所以此时的缓冲区中数据只有部分,如果此时将其取出,则会增加维护数据的开销,正确的做法应该是等待后续数据到达后将其补全,再一次性取出。但是如果此时使用的是LT模式,就会因为缓冲区不为空而一直触发事件,所以这种情况下使用ET会比较好。
四.epoll存在的惊群问题
什么是惊群问题?
在一个执行流中,如果添加了特别多的描述符进行监控,则轮询处理就会比较慢.
因此就会采取多执行流的解决方法,在多个执行流中创建epoll,每个epoll监控一部分描述符,使压力分摊.但是可能因为无法确定哪些描述符即将就绪,所以就会让每个执行流都监控所有描述符,谁先抢到事件谁就去处理.
所以当多个执行流同时在等待就绪事件时,如果某个描述符就绪,它就会唤醒全部执行流中的epoll进行争抢,但是此时只会有一个执行流抢到并执行,而此时其它的执行流都会因为争抢失败而报错,错误码EAGAIN.这就是惊群问题.
惊群问题所带来的坏处:
- 一个就绪事件唤醒多个执行流,而多个执行流争抢资源,而最终只有一个能够成功,导致了操作系统进行了大量无意义的调度,上下文切换,导致性能大打折扣
- 为了保证线程安全的问题,需要对资源进行加锁保护,增大了系统的开销
惊群问题如何解决呢?
多线程环境下惊群问题的解决方法:
只使用一个线程进行事件的监控,每当有就绪事件到来时,就将这些事件转交给其它线程去处理,这样就避免了因为多执行流同时使用epoll监控而带来的惊群问题.
多进程环境下惊群问题的解决方法:
主要借鉴的是 Lighttpd和nginx的解决方法:
Lighttpd的解决思路很简单粗暴,就是直接无视这个问题,事件到来后依旧能够唤醒多个进程来争抢,并且只有一个能成功,其它进程争抢失败后的报错EAGAIN会被捕获,捕获后不会处理这个错误,而是直接无视,就当做没有发生.
Nginx的解决思路其实就是加锁与负载均衡. 使用一个全局的互斥锁,每当有描述符就绪,就会让每个进程都去竞争这把锁.(如果某个进程当前连接数达到了最大连接数的7/8,也就是负载均衡点,此时这个进程就不会再去争抢锁资源,而是将负载均衡到其它进程上),如果成功竞争到了锁,则将描述符加入进自己的wait集合中,而对于没有竞争到锁的进程,则将其从自己的wait集合中移除,这样就保证了不会让多个进程同一事件进行监控,而是让每个进程都通过竞争锁的方式轮流进行监控,这样保证了同一时间只会有一个进程进行监控,因此解决了惊群问题.