io多路复用
什么是IO多路复用
一句话解释:单线程或单进程同时监测若干个文件描述符是否可以执行IO操作的能力。再简单说明,单线程或者单进程,同时执行多个io操作的能力;多路代表多个io,复用代表单条连接,或者单个线程,单个进程。
解决什么问题
应用程序通常需要处理来自多条事件流中的事件,比如我现在用的电脑,需要同时处理键盘鼠标的输入、读取硬盘,网络访问。
而CPU单核在同一时刻只能做一件事情,一种解决办法是对CPU进行时分复用:多个事件流将CPU切割成多个时间片,不同事件流的时间片交替进行,简单来说就是cpu划分一下时间片(例如1ms),每个时间片执行一个事件流,不管这个事件流是否执行完成,到点后就去执行下一个事件流,然后循环执行,知道所有的事件流都执行完毕。
在计算机系统中,我们用线程或者进程来表示一条事件流,通过不同的线程或进程在操作系统内部的调度,来做到对CPU处理的时分复用。这样多个事件流就可以并发进行,不需要一个等待另一个太久,在用户看起来他们似乎就是并行在做一样。
但凡事都是有成本的。线程/进程也一样,有这么几个方面:
- 线程/进程创建成本,例如资源
- CPU切换不同线程/进程的成本,例如保持现场,上下文切换等等
- 多线程的资源竞争,这个也需要额外的成本来协调
有没有一种可以在单线程/进程中处理多个事件流的方法呢?一种答案就是IO多路复用。
因此IO多路复用解决的本质问题是在用更少的资源完成更多的事。
为了更全面的理解,先介绍下在Linux系统下所有IO模型。
I/O模型
目前Linux系统中提供了5种IO处理模型
- 阻塞IO
- 非阻塞IO
- IO多路复用
- 信号驱动IO
- 异步IO
阻塞IO
这是最常用的简单的IO模型。阻塞IO意味着当我们发起一次IO操作后一直等待成功或失败之后才返回,在这期间程序不能做其它的事情。
来个简单的小例子:
饭店共有10张桌子,且配备了10位服务员。只要有客人来了,大堂经理就把客人带到一张桌子,并安排一位服务员全程陪同。
客人下单后,服务员需要去取菜,厨师没有做完之前,服务员就在哪里干等,啥也做不了,直到菜做好,服务员才去拿菜给客人。
我们在发起IO时,通过对文件描述符设置O_NONBLOCK flag来指定该文件描述符的IO操作为非阻塞。非阻塞IO通常发生在一个for循环当中,因为每次进行IO操作时要么IO操作成功,要么当IO操作会阻塞时返回错误EWOULDBLOCK/EAGAIN,然后再根据需要进行下一次的for循环操作,这种类似轮询的方式会浪费很多不必要的CPU资源,是一种糟糕的设计。
例子: 服务员去取菜,厨师没有做完之前,服务员不需要干等,但是服务员可以去给客人倒茶,也可以看手机,而且需要时不时去看看菜做好了没有,知道菜做好了,服务员去拿菜给客人。
信号驱动IO
利用信号机制,让内核告知应用程序文件描述符的相关事件。。
但信号驱动IO在网络编程的时候通常很少用到,因为在网络环境中,和socket相关的读写事件太多了,比如下面的事件都会导致SIGIO信号的产生:
- TCP连接建立
- 一方断开TCP连接请求
- 断开TCP连接请求完成
- TCP连接半关闭
- 数据到达TCP socket
- 数据已经发送出去(如:写buffer有空余空间)
上面所有的这些都会产生SIGIO信号,但我们没办法在SIGIO对应的信号处理函数中区分上述不同的事件,SIGIO只应该在IO事件单一情况下使用,比如说用来监听端口的socket,因为只有客户端发起新连接的时候才会产生SIGIO信号。
例子: 服务员去取菜,厨师没有做完之前,服务员不需要干等,但是服务员可以去给客人倒茶,也可以看手机,当菜做好了,厨师会通知服务员才做好了,服务员去拿菜给客人。异步IO
异步IO和信号驱动IO差不多,但它比信号驱动IO可以多做一步:相比信号驱动IO需要在程序中完成数据从用户态到内核态(或反方向)的拷贝,异步IO可以把拷贝这一步也帮我们完成之后才通知应用程序。
例子: 服务员去拿菜,厨师没有做完之前,服务员不需要干等,但是服务器可以去给客人倒茶,也可以看手机,当菜做好了,厨师会通知服务员才做好了,并且把才端出来,服务员直接拿给客人。IO多路复用
IO多路复用在Linux下包括了三种,select、poll、epoll,抽象来看,他们功能是类似的,但具体细节各有不同:首先都会对一组文件描述符进行相关事件的注册,然后阻塞等待某些事件的发生或等待超时。更多细节详见下面的 "具体怎么用"。IO多路复用都可以关注多个文件描述符,但对于这三种机制而言,不同数量级文件描述符对性能的影响是不同的,下面会详细介绍。
例子:
一个服务员可以可以服务多个客人,厨师没有做完之前,服务员不需要干等,但是服务器可以去给每位客人倒茶,也可以看手机,当有菜做好了,厨师会把菜端出来,服务员直接拿给某个客人,当再有其他菜做好了,厨师也会端出来,服务员继续拿给其他的客人。
整体的效率都提高,一个服务员可以服务10个客人。
上面的例子每次都需要厨师把菜取出来给服务员,厨师的效率就降低了,所以io多路复用还可以进行优化,新增一个跑腿人员,专门用来端菜给服务员,当菜做好了,跑腿就去把菜端出来给服务员。
这样每个人的职责就更加明确,各司其职,效率也更加高了
我在工作中接触的都是Linux系统的服务器,目前对于io多路复用的方案在linux有 select、poll、epoll 这几种方案,下面分别对这三种方案进行一个介绍.
//创建socket
int s = socket(AF_INET, SOCK_STREAM, 0);
//绑定
bind(s, ...)
//监听
listen(s, ...)
//接受客户端连接
int c = accept(s, ...)
//接收客户端数据
recv(c, ...);
//将数据打印出来
printf(...)
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)
int fds[] = 存放需要监听的socket
while(1){
int n = select(..., fds, ...)
for(int i=0; i < fds.count; i++){
if(FD_ISSET(fds[i], ...)){ # 判断是否有数据
//fds[i]的数据处理
}
}
}
这个程序的作用就是监控多个socket,只要其中一个接收有数据,就马上打印出来
fds这个数据保存着所有socket, 当程序执行select这一句代码的时候,到底做了什么样的操作。
系统做了一下2个事情:
1.程序首先就进入到阻塞状态
2.系统会把所有的socket跟程序A关联起来(把程序A放到所有socket的监听队列),为了让cpu知道这个socket到底是属于哪个程序
当某个socket接受到数据的时候,中断程序会通知系统,然后系统就做了下面的动作:
1.遍历socket的监听列表,然后把相关的程序给唤醒
2.同时把所有的socket的监听队列清空
然后呢,程序A就从阻塞状态变为执行状态,cpu就可以继续执行了。然后开始往下执行:
遍历所有的socket,判断一下到底那个socket有数据,进而执行我们需要的逻辑
上面就是select的基本用法和原理,简单来说。调用select监听大量的socket,当没有接受数据,程序会阻塞;当任何一个socket接受到数据,
马上就返回,程序继续。
从上面的代码其实也可以看出问题:
1. 每次调用select需要把所有的socket都传给内核cpu去判断监控,这样开销非常大
2. 每次select成功后,还需要遍历一次socket, 把所有socket的监听队列清空, 这个很挫,假如反复调用select, 这个动作就好挫:
添加监听,返回,移除监听。。。。。不断重复,如果socket的数据量大的话,就开销很大了
3. select成功,还需要再次遍历,找出到底是哪个socket接受到数据了,这个也太挫了
poll
跟select原理是一样的,只不多是数据结构不一样,采用了链表的数据格式来存储对应的io对象,而且没有了大小限制,
而select是有大小个数限制的,linux默认限制1024个。
epoll:
其实epoll就是对select的一次改进,从上面我们发现的三个问题,就可以找到了优化的方向了,
1. 根本没必要每次调用select都要重新设置监听,也没必要每次返回后都把所有的监听都移除掉
2. 为啥不记录一下到底是哪个socket接受到了数据,这样我们就没必要每次都遍历了
我们先看一段epoll的使用代码, 如下的代码中:
先用epoll_create创建一个epoll对象epfd,
再通过epoll_ctl将需要监视的socket添加到epfd中,
最后调用epoll_wait等待数据。
int s = socket(AF_INET, SOCK_STREAM, 0); bind(s, ...) listen(s, ...) int epfd = epoll_create(...); epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中 while(1){ int n = epoll_wait(...) for(接收到数据的socket){ //处理 } }
epoll_create创建了一个新对象,把socket和epoll对象进行关联,然后主程序只要跟epoll打交道即可。
epoll主要是优化了两个方面:
1. 把socket和主进程解耦,之前是每次都是把进程放到socket的监听队列,完事后就又移除掉,对于系统来说,开销太大
于是epoll就重新使用
这样对于系统来说,开销就大大减少了。
这个epoll对象跟socket是同样是系统文件对象,跟socket是一样的,也是拥有自己的监听队列。
当调用epoll_wait时候,系统会把epoll对象放到所有的socket的监听队列,同时把主进程放到epoll对象的监听队列
当socket收到数据后,中断程序会操作epoll对象,然后epoll进而唤醒操作进程。
所以epoll其实等于是select添加了一个中间层,中间层对系统,进程,socket之间进行了协调优化
在epoll_ctl函数中。每次注册新的事件到epoll句柄中时,会把所有的fd拷贝进内核空间,而不是在epoll_wait的时候重复拷贝。
epoll保证了每个fd在整个过程中只会拷贝一次。
2. epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd。
同时中断程序会给epoll的“就绪列表”添加socket引用,epoll能够直接获取到已经有数据的socket,而无需进行遍历,比select的遍历方式快得多
在这里重新过一下select和epoll的执行逻辑
程序A 监听多个socket
select:
1、当调用select的时候,程序阻塞
2. 系统把主进程放到所有的socket的监听列表,并且复制到内核空间
3. 当某个socket接受到数据,cpu收到中断信号
4. cpu遍历socket列表,找到所有的监听列表,唤醒对应的进程,同时把socket的所有监听列表清空
5. 主进程进行执行状态,然后遍历所有的socket,找出有数据的socket,进行数据处理
epoll:
1、当调用epoll_wait
的时候,程序阻塞
2. 系统把主进程放到epoll对象的监听列表,把epoll放到所有的socket的监听列表,无需复制到内核空间,数据放在内核和epoll的共享内存里面
3. 当某个socket接受到数据,cpu收到中断信号,中断程序识别出有数据的socket,并且把socket的引用放到epoll的一个数据列表
4. 然后epoll对象的状态发生变化,cpu同样受到中断信号
5. cpu遍历epoll对象的监听列表,唤醒对应的程序
5. 主进程进行执行状态,根据epoll对象的数据列表,获取到有数据的socket,进行数据处理
select、poll、epoll 其他的一些区别总结:
1、支持一个进程所能打开的最大连接数
select:单个进程所能打开的最大连接数有FD_SETSIZE宏定义,其大小是32个整数的大小(在32位的机器上,大小就是3232,同理64位机器上FD_SETSIZE为3264),当然我们可以对进行修改,然后重新编译内核,但是性能可能会受到影响,这需要进一步的测试。
poll:poll本质上和select没有区别,但是它没有最大连接数的限制,原因是它是基于链表来存储的。
epoll:虽然连接数有上限,但是很大,1G内存的机器上可以打开10万左右的连接,2G内存的机器可以打开20万左右的连接。
2、FD剧增后带来的IO效率问题
select:因为每次调用时都会对连接进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”。
poll:同上
epoll:因为epoll内核中实现是根据每个fd上的callback函数来实现的,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,使用epoll没有前面两者的线性下降的性能问题,但是所有socket都很活跃的情况下,可能会有性能问题。
3、 消息传递方式
select:内核需要将消息传递到用户空间,都需要内核拷贝动作
poll:同上
epoll:epoll通过内核和用户空间共享一块内存来实现的。
总结:
综上,在选择select,poll,epoll时要根据具体的使用场合以及这三种方式的自身特点。
1、表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。
select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。
2、select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善
select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。