zoukankan      html  css  js  c++  java
  • io多路复用

    什么是IO多路复用

    一句话解释:单线程或单进程同时监测若干个文件描述符是否可以执行IO操作的能力。再简单说明,单线程或者单进程,同时执行多个io操作的能力;多路代表多个io,复用代表单条连接,或者单个线程,单个进程。

    解决什么问题

    应用程序通常需要处理来自多条事件流中的事件,比如我现在用的电脑,需要同时处理键盘鼠标的输入、读取硬盘,网络访问。

    而CPU单核在同一时刻只能做一件事情,一种解决办法是对CPU进行时分复用:多个事件流将CPU切割成多个时间片,不同事件流的时间片交替进行,简单来说就是cpu划分一下时间片(例如1ms),每个时间片执行一个事件流,不管这个事件流是否执行完成,到点后就去执行下一个事件流,然后循环执行,知道所有的事件流都执行完毕。

    在计算机系统中,我们用线程或者进程来表示一条事件流,通过不同的线程或进程在操作系统内部的调度,来做到对CPU处理的时分复用。这样多个事件流就可以并发进行,不需要一个等待另一个太久,在用户看起来他们似乎就是并行在做一样。

    但凡事都是有成本的。线程/进程也一样,有这么几个方面:

    1. 线程/进程创建成本,例如资源
    2. CPU切换不同线程/进程的成本,例如保持现场,上下文切换等等
    3. 多线程的资源竞争,这个也需要额外的成本来协调

    有没有一种可以在单线程/进程中处理多个事件流的方法呢?一种答案就是IO多路复用。

    因此IO多路复用解决的本质问题是在用更少的资源完成更多的事。

    为了更全面的理解,先介绍下在Linux系统下所有IO模型。

    I/O模型

    目前Linux系统中提供了5种IO处理模型

    1. 阻塞IO
    2. 非阻塞IO
    3. IO多路复用
    4. 信号驱动IO
    5. 异步IO

    阻塞IO

    这是最常用的简单的IO模型。阻塞IO意味着当我们发起一次IO操作后一直等待成功或失败之后才返回,在这期间程序不能做其它的事情。

    来个简单的小例子:

    饭店共有10张桌子,且配备了10位服务员。只要有客人来了,大堂经理就把客人带到一张桌子,并安排一位服务员全程陪同。

    客人下单后,服务员需要去取菜,厨师没有做完之前,服务员就在哪里干等,啥也做不了,直到菜做好,服务员才去拿菜给客人。

    非阻塞IO

    我们在发起IO时,通过对文件描述符设置O_NONBLOCK flag来指定该文件描述符的IO操作为非阻塞。非阻塞IO通常发生在一个for循环当中,因为每次进行IO操作时要么IO操作成功,要么当IO操作会阻塞时返回错误EWOULDBLOCK/EAGAIN,然后再根据需要进行下一次的for循环操作,这种类似轮询的方式会浪费很多不必要的CPU资源,是一种糟糕的设计。

    例子:  服务员去取菜,厨师没有做完之前,服务员不需要干等,但是服务员可以去给客人倒茶,也可以看手机,而且需要时不时去看看菜做好了没有,知道菜做好了,服务员去拿菜给客人。

    信号驱动IO

    利用信号机制,让内核告知应用程序文件描述符的相关事件。。

    但信号驱动IO在网络编程的时候通常很少用到,因为在网络环境中,和socket相关的读写事件太多了,比如下面的事件都会导致SIGIO信号的产生:

    1. TCP连接建立
    2. 一方断开TCP连接请求
    3. 断开TCP连接请求完成
    4. TCP连接半关闭
    5. 数据到达TCP socket
    6. 数据已经发送出去(如:写buffer有空余空间)

    上面所有的这些都会产生SIGIO信号,但我们没办法在SIGIO对应的信号处理函数中区分上述不同的事件,SIGIO只应该在IO事件单一情况下使用,比如说用来监听端口的socket,因为只有客户端发起新连接的时候才会产生SIGIO信号。

    例子:  服务员去取菜,厨师没有做完之前,服务员不需要干等,但是服务员可以去给客人倒茶,也可以看手机,当菜做好了,厨师会通知服务员才做好了,服务员去拿菜给客人。

    异步IO

    异步IO和信号驱动IO差不多,但它比信号驱动IO可以多做一步:相比信号驱动IO需要在程序中完成数据从用户态到内核态(或反方向)的拷贝,异步IO可以把拷贝这一步也帮我们完成之后才通知应用程序。

    例子:  服务员去拿菜,厨师没有做完之前,服务员不需要干等,但是服务器可以去给客人倒茶,也可以看手机,当菜做好了,厨师会通知服务员才做好了,并且把才端出来,服务员直接拿给客人。
     
    异步io和信号驱动io非常类似,区别就是异步io会把完成的数据放指定内存上,不需要程序主动去取,而信号驱动io则需要程序主动去取数据。
     
    上面的几种io方式实际上都是一个服务员只能服务一个客人(一个进程或者线程只能处理一个io),如果需要服务多个客人,只能添加更多的服务员,而io多路复用可以实现一个服务员服务多个客人。

    IO多路复用

    IO多路复用在Linux下包括了三种,selectpollepoll,抽象来看,他们功能是类似的,但具体细节各有不同:首先都会对一组文件描述符进行相关事件的注册,然后阻塞等待某些事件的发生或等待超时。更多细节详见下面的 "具体怎么用"。IO多路复用都可以关注多个文件描述符,但对于这三种机制而言,不同数量级文件描述符对性能的影响是不同的,下面会详细介绍。

    例子:

    一个服务员可以可以服务多个客人,厨师没有做完之前,服务员不需要干等,但是服务器可以去给每位客人倒茶,也可以看手机,当有菜做好了,厨师会把菜端出来,服务员直接拿给某个客人,当再有其他菜做好了,厨师也会端出来,服务员继续拿给其他的客人。

    整体的效率都提高,一个服务员可以服务10个客人。

    上面的例子每次都需要厨师把菜取出来给服务员,厨师的效率就降低了,所以io多路复用还可以进行优化,新增一个跑腿人员,专门用来端菜给服务员,当菜做好了,跑腿就去把菜端出来给服务员。

    这样每个人的职责就更加明确,各司其职,效率也更加高了

    我在工作中接触的都是Linux系统的服务器,目前对于io多路复用的方案在linux有 select、poll、epoll 这几种方案,下面分别对这三种方案进行一个介绍.
     
    select机制:
    select是一种系统调用,通常是可以直接当做系统函数来调用,下面我们会已select的用法为基准来讲解它的原理。
    下面是最基础的网络编程的例子
    //创建socket
    int s = socket(AF_INET, SOCK_STREAM, 0);   
    //绑定
    bind(s, ...)
    //监听
    listen(s, ...)
    //接受客户端连接
    int c = accept(s, ...)
    //接收客户端数据
    recv(c, ...);
    //将数据打印出来
    printf(...)
    当程序执行到accept这一步的时候,程序就会进入阻塞状态(等待状态),cpu会切换到其他的程序里面。当socket接受到数据连接的时候,系统会发送一个中断信号给cpu,然后cpu知道accept有数据进来了,然后就重新唤醒这个程序,继续执行下面的代码步骤。
     
    前面说过了,io多路复用是代表程序可以对多个io操作,以socket为例子,假如我们有一个程序A,  程序A监控着多个socket。
    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主要是优化了两个方面:
    1. 把socket和主进程解耦,之前是每次都是把进程放到socket的监听队列,完事后就又移除掉,对于系统来说,开销太大
    于是epoll就重新使用
    epoll_create创建了一个新对象,把socket和epoll对象进行关联,然后主程序只要跟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内部定义的等待队列)。这也能节省不少的开销。

     
  • 相关阅读:
    强迫症患者
    GG的匹配串
    漂洋过海来看你
    Fire or Retreat
    1011. A+B和C (15)
    1010. 一元多项式求导 (25)
    1009. 说反话 (20)
    1008. 数组元素循环右移问题 (20)
    1007. 素数对猜想 (20)
    1006. 换个格式输出整数 (15)
  • 原文地址:https://www.cnblogs.com/wilken/p/14179903.html
Copyright © 2011-2022 走看看