在介绍多路复用IO之前,先介绍一下其它四种高级IO:
注意:几种IO效率越来越高,但是流程控制越来越复杂,占用的资源也越来越多.
重点概念的区分理解:
同步与异步的优缺点分析:
多路复用IO的概念: 多路复用IO用于对大量描述符进行IO就绪事件监控,能够让用户只针对就绪指定事件的描述符进行操作.
IO就绪事件可分为可读,可写,异常:
相较于其它的IO方式,多路复用IO避免了对没有就绪的描述符进行操作而带来的阻塞,同时只针对已就绪的描述符进行操作,提高了效率.
多路复用IO模型进行服务器并发处理:
即在单执行流中进行轮询处理就绪的描述符.如果就绪的描述符较多时,很难做到负载均衡. 即最后一个描述符要等待很长的时间,前边的描述符处理完了才能处理它)
解决这一问题的方法就是 : 在用户态实现负载均衡,规定每个描述符只能读取指定数量的数据,读取了就进行下一个描述符.
多路复用IO模型适用于有大量描述符需要监控,但同一时间只有少量活跃的场景.
多线程/多进程进行服务器并发处理:
即操作系统通过轮询调度执行流实现每个执行流中描述符的操作,由于其在内核态实现了负载均衡.由于其在内核态实现了负载均衡,所以不需要再用户态做过多操作.
多路复用适合用于IO密集型服务,多进程或多线程适用于CPU密集型服务.它们各有各的优势,并不存在谁取代谁的倾向.基于两者的特点,通常可以将多路复用IO和多线程/多进程搭配一起使用.
即:使用多路复用IO监控大量的描述符,哪个描述符就绪有事件到来,就创建执行流去处理.这样做的好处是防止直接创建执行流而描述符还未就绪,浪费资源.
在Linux下,操作系统提供了三种模型:select模型,poll模型,epoll模型
//清空集合
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; 微秒
};
*/
工作原理:
优缺点分析:
优点:
缺点:
struct pollfd
{
int fd; //需要监控的文件描述符
short events; //需要监控的事件
short revents; //实际就绪的事件
};
/*
操作相对简单,如果某个描述符不需要继续监控时,直接将对应结构体中的fd置为-1即可。
*/
//发起监控
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
/*
fds:pollfd数组
nfds:数组的大小
timeout:超时等待时间,单位为毫秒
*/
工作原理:
优缺点分析:
优点:
缺点:
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:超时等待时间
返回值为就绪的描述符个数
*/
工作原理:
优缺点分析:
epoll是linux下性能最高的多路复用IO模型,几乎具备了一切所需的优点
优点:
缺点:
epoll有两种工作模式,LT(水平触发模式)和ET(边缘触发模式).
水平触发模式是epoll的默认触发模式(select和poll只有这种模式).
触发条件:
所以简单点说: 水平触发模式就是只要缓冲区中还有数据,就会一直触发事件.
- 当epoll检测到socket上事件就绪的时候,可以不立即进行处理,或者只处理一部分
- 如上面的例子,由于只读了1k的数据,缓存区还剩1K的数据,在第二次调用epoll_wait的时候,epoll_wait仍然会立即返回并通知读事件就绪
- 直到缓冲区上所有的数据被处理完,epoll_wait才不会立即返回
- 支持阻塞读写和非阻塞读写
边缘触发模式:将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.
只支持非阻塞的读写
水平触发模式就是只要缓冲区中还有数据,就会一直触发事件,而边缘触发模式下只有新数据到来的情况下才会触发事件.
水平触发模式的优缺点分析:
边缘触发模式的优缺点分析:
还有一种场景适合ET模式使用,如果我们需要接受一条数据,但是这条数据因为某种问题导致其发送不完整,需要分批发送。所以此时的缓冲区中数据只有部分,如果此时将其取出,则会增加维护数据的开销,正确的做法应该是等待后续数据到达后将其补全,再一次性取出。但是如果此时使用的是LT模式,就会因为缓冲区不为空而一直触发事件,所以这种情况下使用ET会比较好。
什么是惊群问题?
在一个执行流中,如果添加了特别多的描述符进行监控,则轮询处理就会比较慢.
因此就会采取多执行流的解决方法,在多个执行流中创建epoll,每个epoll监控一部分描述符,使压力分摊.但是可能因为无法确定哪些描述符即将就绪,所以就会让每个执行流都监控所有描述符,谁先抢到事件谁就去处理.
所以当多个执行流同时在等待就绪事件时,如果某个描述符就绪,它就会唤醒全部执行流中的epoll进行争抢,但是此时只会有一个执行流抢到并执行,而此时其它的执行流都会因为争抢失败而报错,错误码EAGAIN.这就是惊群问题.
惊群问题所带来的坏处:
- 一个就绪事件唤醒多个执行流,而多个执行流争抢资源,而最终只有一个能够成功,导致了操作系统进行了大量无意义的调度,上下文切换,导致性能大打折扣
- 为了保证线程安全的问题,需要对资源进行加锁保护,增大了系统的开销
惊群问题如何解决呢?
多线程环境下惊群问题的解决方法:
只使用一个线程进行事件的监控,每当有就绪事件到来时,就将这些事件转交给其它线程去处理,这样就避免了因为多执行流同时使用epoll监控而带来的惊群问题.
多进程环境下惊群问题的解决方法:
主要借鉴的是 Lighttpd和nginx的解决方法:
Lighttpd的解决思路很简单粗暴,就是直接无视这个问题,事件到来后依旧能够唤醒多个进程来争抢,并且只有一个能成功,其它进程争抢失败后的报错EAGAIN会被捕获,捕获后不会处理这个错误,而是直接无视,就当做没有发生.
Nginx的解决思路其实就是加锁与负载均衡. 使用一个全局的互斥锁,每当有描述符就绪,就会让每个进程都去竞争这把锁.(如果某个进程当前连接数达到了最大连接数的7/8,也就是负载均衡点,此时这个进程就不会再去争抢锁资源,而是将负载均衡到其它进程上),如果成功竞争到了锁,则将描述符加入进自己的wait集合中,而对于没有竞争到锁的进程,则将其从自己的wait集合中移除,这样就保证了不会让多个进程同一事件进行监控,而是让每个进程都通过竞争锁的方式轮流进行监控,这样保证了同一时间只会有一个进程进行监控,因此解决了惊群问题.