对IO多路复用的理解

对IO多路复用的理解,第1张

文章目录
  • 前言
  • 一、什么是socket?
  • 二、阻塞
  • 三、什么是IO多路复用技术
  • 四、为什么使用多路复用
  • 三、selet 与epoll的区别
    • 3.1 时间复杂度
    • 3.2 select缺点
    • 3.3 poll的理解
    • 3.3 epoll的理解
    • 3.4 select .poll.epoll的使用选择
  • 五 、其他常用IO模型
    • 5.1 堵塞I/O
    • 5.2 非阻塞I/O
    • 5.2 I/O复用模型
    • 5.3 信号驱动I/O模型(signal driven I/O, SIGIO)
    • 5.4 5、异步I/O模型(AIO, asynchronous I/O)
  • 总结


前言

本篇文章要解决的问题:

  1. 什么是IO多路复用
  2. 为什么要使用I/O多路复用
  3. I/O多路复用实现原理
  4. I/O模型之间的比较以及优缺点

一、什么是socket?

  我们都知道unix(like)世界里,一切皆文件,而文件是什么呢?文件就是一串二进制流而已,不管socket,还是FIFO、管道、终端,对我们来说,一切都是文件,一切都是流。在信息 交换的过程中,我们都是对这些流进行数据的收发 *** 作,简称为I/O *** 作(input and output),往流中读出数据,系统调用read,写入数据,系统调用write。不过话说回来了 ,计算机里有这么多的流,我怎么知道要 *** 作哪个流呢?对,就是文件描述符,即通常所说的fd,一个fd就是一个整数,所以,对这个整数的 *** 作,就是对这个文件(流)的 *** 作。我们创建一个socket,通过系统调用会返回一个文件描述符,那么剩下对socket的 *** 作就会转化为对这个描述符的 *** 作。不能不说这又是一种分层和抽象的思想

二、阻塞

  什么是程序的阻塞呢?想象这种情形,比如你等快递,但快递一直没来,你会怎么做?有两种方式:

快递没来,我可以先去睡觉,然后快递来了给我打电话叫我去取就行了。
快递没来,我就不停的给快递打电话说:擦,怎么还没来,给老子快点,直到快递来。

  在计算机世界,这两种情形就对应阻塞和非阻塞忙轮询。

非阻塞忙轮询:数据没来,进程就不停的去检测数据,直到数据来。
阻塞:数据没来,啥都不做,直到数据来了,才进行下一步的处理。

先说说阻塞,因为一个线程只能处理一个套接字的I/O事件,如果想同时处理多个,可以利用非阻塞忙轮询的方式,伪代码如下:

while true  
{  
    for i in stream[]  
    {  
        if i has data  
        read until unavailable  
    }  
}  

  我们只要把所有流从头到尾查询一遍,就可以处理多个流了,但这样做很不好,因为如果所有的流都没有I/O事件,白白浪费CPU时间片。正如有一位科学家所说,计算机所有的问题都可以增加一个中间层来解决,同样,为了避免这里cpu的空转,我们不让这个线程亲自去检查流中是否有事件,而是引进了一个代理(一开始是select,后来是poll),这个代理很牛,它可以同时观察许多流的I/O事件,如果没有事件,代理就阻塞,线程就不会挨个挨个去轮询了,伪代码如下:

while true  
{  
    select(streams[]) //这一步死在这里,知道有一个流有I/O事件时,才往下执行  
    for i in streams[]  
    {  
        if i has data  
        read until unavailable  
    }  
}  

  但是依然有个问题,我们从select那里仅仅知道了,有I/O事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行 *** 作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。
  epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的 *** 作都是有意义的。(复杂度降低到了O(1))伪代码如下:

while true  
{  
    active_stream[] = epoll_wait(epollfd)  
    for i in active_stream[]  
    {  
        read or write till  
    }  
}  

  可以看到,select和epoll最大的区别就是:select只是告诉你一定数目的流有事件了,至于哪个流有事件,还得你一个一个地去轮询,而epoll会把发生的事件告诉你,通过发生的事件,就自然而然定位到哪个流了。不能不说epoll跟select相比,是质的飞跃,我觉得这也是一种牺牲空间,换取时间的思想,毕竟现在硬件越来越便宜了。

三、什么是IO多路复用技术

  问题:如果我们先前创建的几个进程承载不了目前快速发展的业务的话,是不是还得增加进程数?我们都知道系统创建进程是需要消耗大量资源的,所以这样就会导致系统资源不足的情况。
那么有没有一种方式可以让一个进程同时为多个客户端端提供服务?
emsp; 首先,多路复用(multiplexing) 是计算机里面很常见的一个概念,我觉得他的核心思想就是利用一组资源做很多件事。
emsp; 常见的多路复用(multiplexing)除了网络编程里面的IO多路复用;还有计算机网络的时分多路复用,频分多路复用;还有 *** 作系统里面的时间复用(Time multiplexing,指在多个用户之间安排连续可重用的资源如CPU ),空间复用(Space multiplexing
emsp; IO多路复用是指使用一个线程来检查多个文件描述符(Socket)的就绪状态,比如调用select和poll函数,传入多个文件描述符,如果有一个文件描述符就绪,则返回,否则阻塞直到超时。(PS:阻塞就是进程的一种状态,表示等待某个事件,如IO,对线程也一样)

四、为什么使用多路复用

emsp; 在处理1000个连接时,只需要1个线程监控就绪状态,对就绪的每个连接开一个线程处理就可以了,这样需要的线程数大大减少,减少了内存开销和上下文切换的CPU开销。
emsp; 实际上,IO复用函数的作用是:应用程序通过IO复用函数向内核注册一组事件,内核通过IO复用函数把其中的就绪事件通知给应用程序。那么使用多线程控制不同的I/O也是高并发和epoll池有什么区别,但是线程多了反而会降低效率。

三、selet 与epoll的区别

emsp; 目前的常用的IO复用模型有三种:select,poll,epoll。

3.1 时间复杂度
  1. select==>时间复杂度O(n)
      它仅仅知道了,有I/O事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行 *** 作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。

  2. poll==>时间复杂度O(n)
      poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的.

  3. epoll==>时间复杂度O(1)
      epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的 *** 作都是有意义的。(复杂度降低到了O(1))

3.2 select缺点

select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。这样所带来的缺点是:

  1. 单个进程可监视的fd数量被限制,即能监听端口的大小有限。
      一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。32位机默认是1024个。64位机默认是2048.

  2. 对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低:
      当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关 *** 作,那就避免了轮询,这正是epoll与kqueue做的。

  3. 每次调用select,都需要把fd集合从用户态拷贝到内核态,去查寻当前每个流的状态这个开销在fd很多时会很大

  4. 同时每次调用select都需要在内核遍历将传递进来的所有查询完毕的fd,这个开销在fd很多时也很大

  5. 需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大

3.3 poll的理解

  poll的实现和select非常相似,只是描述fd集合的方式不同,poll使用pollfd结构而不是select的fd_set结构,其他的都差不多,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。

  poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。

  它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:

  1. 大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
  2. poll还有一个特点是“水平触发”,
    水平触发关心的是缓冲区的状态,当缓冲区可读的时候,就会发出通知,也就是当缓冲区中只要有数据就会发出通知
3.3 epoll的理解
  1. epoll要解决的问题是
    (1).select有最大的链接限制,要轮询查询具体的数据流造成的巨大时间资源的消耗,每次调用select时的内核与用户态的文件描述符的拷贝带来的巨大内存消耗。
    (2).poll虽然没有最大链接数的限制,但是fd整体数组在内核与用户态之间的拷贝也会带来巨大无意义的消耗。
  2. epoll有EPOLL LT和EPOLL ET两种触发模式。
    (1) 水平触发
    水平触发为Level Triggered,简称LT。
    水平触发关心的是缓冲区的状态,当缓冲区可读的时候,就会发出通知,也就是当缓冲区中只要有数据就会发出通知。
    (2) 边缘触发
    边缘触发为Edge Triggered,简称ET。
    边缘触发关心的是缓冲区状态的变化,当缓冲区状态发生变化的时候才会发出通知,比如缓冲区中来了新的数据。

  从上述表述可能不太看得出他们之间的区别,我们设想这样一个场景,当一次read()读取没有读取完缓冲区中的数据时,LT和ET的区别:
1、LT,此时缓冲区中还有数据,会继续发通知
2、ET,此时缓冲区状态并没有发生变化,并没有来新的数据,就不会发通知,在新数据到来之前,之前剩余的数据就无法取出。

所以在ET模式下,当读取数据的时候,一定要循环读取数据,直到缓冲区中的数据全部读取完成,一次性将数据取出。

  1. epoll有LT模式之后为什么还要有ET模式?
    如果采用LT模式的话,试想如果有大量的已经就绪的文件描述符,他们每次调用epoll_wait的时候都会返回,这样他们会大大降低程序处理自己关心的文件描述符。而采用epoll ET边缘触发的话,当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你!!!这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。
  2. epoll的优点
    (1) 没有最大链接数的限制,能打开的fd远高于1024(1G的内存可打开10万个fd)
    (2)效率提升,不会随着FD的剧增而效率低下,只有活跃的FD才会被epoll关注。即epoll只关心活跃的FD与链接条数无关,因此在网络环境中,epoll的效率远高于select与poll。
    (3)内存拷贝,利用mmap加速与内核空间的消息传递,即利用mmap减少了拷贝复制的开销。3.4 select .poll.epoll的使用选择
    1. 表面上epoll会比select和poll的效率好,但是在链接数少以及都十分活跃的情况下,select和poll可能比epoll的好,毕竟epoll的异步通知使用了很多回调函数。
    2. select的低效是因为每次都需要轮询,但是低效也是相对的,视情况而定,可通过良好的程序设计来改善。
五 、其他常用IO模型 5.1 堵塞I/O

  最广泛的模型是阻塞I/O模型,默认情况下,所有套接口都是阻塞的。 进程调用recvfrom系统调用,整个过程是阻塞的,直到数据复制到进程缓冲区时才返回(当然,系统调用被中断也会返回)

5.2 非阻塞I/O

当我们把一个套接口设置为非阻塞时,就是在告诉内核,当请求的I/O *** 作无法完成时,不要将进程睡眠,而是返回一个错误。当数据没有准备好时,内核立即返回EWOULDBLOCK错误,第四次调用系统调用时,数据已经存在,这时将数据复制到进程缓冲区中。这其中有一个 *** 作时轮询(polling)。

5.2 I/O复用模型

此模型用到select和poll函数,这两个函数也会使进程阻塞,select先阻塞,有活动套接字才返回,但是和阻塞I/O不同的是,这两个函数可以同时阻塞多个I/O *** 作,而且可以同时对多个读 *** 作,多个写 *** 作的I/O函数进行检测,直到有数据可读或可写(就是监听多个socket)。select被调用后,进程会被阻塞,内核监视所有select负责的socket,当有任何一个socket的数据准备好了,select就会返回套接字可读,我们就可以调用recvfrom处理数据。
正因为阻塞I/O只能阻塞一个I/O *** 作,而I/O复用模型能够阻塞多个I/O *** 作,所以才叫做多路复用。

5.3 信号驱动I/O模型(signal driven I/O, SIGIO)

首先我们允许套接口进行信号驱动I/O,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O *** 作函数处理数据。当数据报准备好读取时,内核就为该进程产生一个SIGIO信号。我们随后既可以在信号处理函数中调用recvfrom读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环,让它来读取数据报。无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达(第一阶段)期间,进程可以继续执行,不被阻塞。免去了select的阻塞与轮询,当有活跃套接字时,由注册的handler处理。

下面程序在标准输入上使能信号驱动 I/O,之后将终端置为 cbreak 模式,这样每次输入只会有一个字符。之后程序进入无限循环,所做的工作就是递增变量 cnt,同时等待输入就绪。当有输入存在时,SIGIO 信号处理例程就设定一个标志 gotSigio,该标志由主程序监控。当主程序看到该标志被设定后,就读取所有存在的输入字符并将它们连同变量cnt 的当前值一起打印出来。如果输入中读取到了井字符(#),程序就退出

#include 
#include 
#include 
#include 
#include 
#include 
#include 

static volatile sig_atomic_t gotSigio = 0;

static void sigioHandler(int sig){
    gotSigio = 1;
    printf("signal is %d\r\n",sig);
}
int ttySetCbreak(int fd, struct termios *prevTermios)
{
    struct termios t;

    if (tcgetattr(fd, &t) == -1)
        return -1;

    if (prevTermios != NULL)
        *prevTermios = t;

    t.c_lflag &= ~(ICANON | ECHO);
    t.c_lflag |= ISIG;

    t.c_iflag &= ~ICRNL;

    t.c_cc[VMIN] = 1;                   /* Character-at-a-time input */
    t.c_cc[VTIME] = 0;                  /* with blocking */

    if (tcsetattr(fd, TCSAFLUSH, &t) == -1)
        return -1;

    return 0;
}
int main(int argc, char *argv[]){
    int flags, j, cnt;
    struct termios origTermios;
    char ch;
    struct sigaction sa;
    int done;

    /* 为  "I/O possible" 信号建立处理程序 */
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    sa.sa_handler = sigioHandler;
    if (sigaction(SIGIO, &sa, NULL) == -1){
        perror("sigaction");
        exit(EXIT_FAILURE);
    }

    /* 设置接收"I/O possible" 信号的所有者进程  */
    if (fcntl(STDIN_FILENO, F_SETOWN, getpid()) == -1){
        perror("fcntl  F_SETOWN");
        exit(EXIT_FAILURE);
    }

    /* 启用“I/Opossible”信号并使文件描述符的I/O非阻塞 */
    flags = fcntl(STDIN_FILENO, F_GETFL);
    if (fcntl(STDIN_FILENO, F_SETFL, flags | O_ASYNC| O_NONBLOCK) == -1)
    {
        perror("fcntl  F_SETFL");
        exit(EXIT_FAILURE);
    }

    if (ttySetCbreak(STDIN_FILENO, &origTermios) == -1)
    {
        perror("ttySetCbreak");
        exit(EXIT_FAILURE);
    }

    for (done = 0, cnt = 0; !done ; cnt++) {
        for (j = 0; j < 100000000; j++)
            continue;                   /* Slow main loop down a little */

        if (gotSigio) {                 /* Is input available? */
            gotSigio = 0;

            /* Read all available input until error (probably EAGAIN)
               or EOF (not actually possible in cbreak mode) or a
               hash (#) character is read */

            while (read(STDIN_FILENO, &ch, 1) > 0 && !done) {
                printf("cnt=%d; read %c\n", cnt, ch);
                done = ch == '#';
            }
        }
    }

    /* Restore original terminal settings */

    if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &origTermios) == -1)
    {
        perror("tcsetattr TCSAFLUSH");
        exit(EXIT_FAILURE);
    }
    exit(EXIT_SUCCESS);
}

5.4 5、异步I/O模型(AIO, asynchronous I/O)

进程发起read *** 作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read *** 作完成了。

这个模型工作机制是:告诉内核启动某个 *** 作,并让内核在整个 *** 作(包括第二阶段,即将数据从内核拷贝到进程缓冲区中)完成后通知我们。

这种模型和前一种模型区别在于:信号驱动I/O是由内核通知我们何时可以启动一个I/O *** 作,而异步I/O模型是由内核通知我们I/O *** 作何时完成

总结

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/langs/891817.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-05-14
下一篇 2022-05-14

发表评论

登录后才能评论

评论列表(0条)

保存