zoukankan      html  css  js  c++  java
  • IO模型

    本文主要介绍四种IO模型:

        1.阻塞IO(blocking IO)

        2.非阻塞IO(non-blocking IO)

        3.多路复用(IO multiplexing)

        4.异步IO(Asynchronous IO)

      IO发生时涉及的对象和步骤,对于一个network IO(这里以read为例),它会涉及到两个系统对象,一个是调用这个IO的process,另一个就是系统内核(kernel),当一个read操作发生时,该操作会经历两个阶段:

      1.等待数据准备(Waiting for the data to be ready)

      2.将数据从内核拷贝到进程中(Copying the data from the kernel to the process)

    补充:
    #1、输入操作:read、readv、recv、recvfrom、recvmsg共5个函数,如果会阻塞状态,则会经理wait data和copy data两个阶段,如果设置为非阻塞则在wait 不到data时抛出异常
    
    #2、输出操作:write、writev、send、sendto、sendmsg共5个函数,在发送缓冲区满了会阻塞在原地,如果设置为非阻塞,则会抛出异常
    
    #3、接收外来链接:accept,与输入操作类似
    
    #4、发起外出链接:connect,与输出操作类似

    一:阻塞IO

      在Linux中,默认情况下所有的socket都是blocking,流程如下:

        当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据。对于network io来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候kernel就要等待足够的数据到来。

        而在用户进程这边,整个进程会被阻塞。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。
        所以,blocking IO的特点就是在IO执行的两个阶段(等待数据和拷贝数据两个阶段)都被block了。

       实际上,几乎所有的IO接口都是阻塞型的。

      一个简单的解决方案:

    #在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。
      
    该方案的问题是:
    #开启多进程或都线程的方式,在遇到要同时响应成百上千路的连接请求,则无论多线程还是多进程都会严重占据系统资源,降低系统对外界响应效率,而且线程与进程本身也更容易进入假死状态。
     

      改进方案:

    #很多程序员可能会考虑使用“线程池”或“连接池”。“线程池”旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。“连接池”维持连接的缓存池,尽量重用已有的连接、减少创建和关闭连接的频率。这两种技术都可以很好的降低系统开销,都被广泛应用很多大型系统,如websphere、tomcat和各种数据库等。
        
    改进后方案其实也存在着问题:
    #“线程池”和“连接池”技术也只是在一定程度上缓解了频繁调用IO接口带来的资源占用。而且,所谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。

    也就是多线程模型可以 方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用非阻塞接口来尝试解决这个问题。

    二:非阻塞IO

      Linux下 ,non-blocking socket执行read时,流程如下:

     

        从图中可以看出,当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是用户就可以在本次到下次再发起read询问的时间间隔内做其他事情,或者直接再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存(这一阶段仍然是阻塞的),然后返回。

        也就是说非阻塞的recvform系统调用调用之后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好,此时会返回一个error。进程在返回之后,可以干点别的事情,然后再发起recvform系统调用。重复上面的过程,循环往复的进行recvform系统调用。这个过程通常被称之为轮询。轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态。

        所以,在非阻塞式IO中,用户进程其实是需要不断的主动询问kernel数据准备好了没有。

    import socket
    server = socket.socket()
    server.bind(("127.0.0.1",21211))
    server.listen()
    
    server.setblocking(False)
    
    
    # all clients
    clients = []
    
    while True:
        try:
            client,addr = server.accept()
            clients.append(client)
        except BlockingIOError:
    
            # 存储所有已经关闭的客户端
            close_ls = []
    
            # 存储所有需要发送数据的客户端和数据
            msg_ls = []
            for c in  clients:
                try:
                    data = c.recv(1024)
                    if not data:
                        c.close()
                        close_ls.append(c)
                    # 把要发送数据的 客户端 和数据存储到列表中  单独发送
                    msg_ls.append((c,data))
                except BlockingIOError:
                    pass
                except ConnectionResetError:
                    c.close()
                    close_ls.append(c)
    
            # 处理发送数据
            # 已经发送完成的客户端和数据
            sended_msg = []
            for client_and_data in msg_ls:
                c = client_and_data[0]
                data= client_and_data[1]
                try:
                    c.send(data.upper())
                    # 加入待删除列表
                    sended_msg.append(client_and_data)
                except BlockingIOError:
                    pass
    
            # 将已经发送成功的数据从待发送列表中删除
            for i in sended_msg:
                msg_ls.remove(i)
            sended_msg.clear()
    
    
            # 把已经关闭的连接从 所有客户端列表中删除
            for i in close_ls:
                clients.remove(i)
            close_ls.clear()
    非阻塞IO服务器
    import socket
    import os,time
    c = socket.socket()
    c.connect(("127.0.0.1",21211))
    
    while True:
        msg = "hello i am: %s" % os.getpid()
        time.sleep(1)
        if not msg:
    
            continue
        c.send(msg.encode("utf-8"))
        print(c.recv(1024).decode("utf-8"))
    非阻塞IO客户端

    但是非阻塞IO模型绝不被推荐。

    尽管能够在等待任务完成的时间里完成其他任务,但是:

    #1. 循环调用recv()将大幅度推高CPU占用率;这也是我们在代码中留一句time.sleep()的原因,否则在低配主机下极容易出现卡机情况
    #2. 任务完成的响应延迟增大了,因为每过一段时间才去轮询一次read操作,而任务可能在两次轮询之间的任意时间完成。这会导致整体数据吞吐量的降低。

      此外,在这个方案中只是提供了一种解决方法来起到监测“操作是否完成”的作用,实际上操作系统提供了更为高效的检测接口,例如select()多路复用模式,可以以此检测多个连接是否活跃。

    三:多路复用IO

    基本概念

      IO多路复用是指内核一旦发现进程指定的一个或者多个IO条件准备读取,它就通知该进程。IO多路复用适用如下场合:

      (1)当客户处理多个描述字时(一般是交互式输入和网络套接口),必须使用I/O复用。

      (2)当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现。

      (3)如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到I/O复用。

      (4)如果一个服务器即要处理TCP,又要处理UDP,一般要使用I/O复用。

      (5)如果一个服务器要处理多个服务或多个协议,一般要使用I/O复用。

      与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。

    select函数

      该函数准许进程指示内核等待多个事件中的任何一个发送,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒。函数原型如下:

    #include <sys/select.h>
    #include <sys/time.h>
    
    int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)
    返回值:就绪描述符的数目,超时返回0,出错返回-1

    函数参数介绍如下:

    (1)第一个参数maxfdp1指定待测试的描述字个数,它的值是待测试的最大描述字加1(因此把该参数命名为maxfdp1),描述字0、1、2...maxfdp1-1均将被测试。

    因为文件描述符是从0开始的。

    (2)中间的三个参数readset、writeset和exceptset指定我们要让内核测试读、写和异常条件的描述字。如果对某一个的条件不感兴趣,就可以把它设为空指针。struct fd_set可以理解为一个集合,这个集合中存放的是文件描述符,可通过以下四个宏进行设置:

              void FD_ZERO(fd_set *fdset);           //清空集合

              void FD_SET(int fd, fd_set *fdset);   //将一个给定的文件描述符加入集合之中

              void FD_CLR(int fd, fd_set *fdset);   //将一个给定的文件描述符从集合中删除

              int FD_ISSET(int fd, fd_set *fdset);   // 检查集合中指定的文件描述符是否可以读写 

    (3)timeout告知内核等待所指定描述字中的任何一个就绪可花多少时间。其timeval结构用于指定这段时间的秒数和微秒数。

             struct timeval{

                       long tv_sec;   //seconds

                       long tv_usec;  //microseconds

           };

    这个参数有三种可能:

    (1)永远等待下去:仅在有一个描述字准备好I/O时才返回。为此,把该参数设置为空指针NULL。

    (2)等待一段固定时间:在有一个描述字准备好I/O时返回,但是不超过由该参数所指向的timeval结构中指定的秒数和微秒数。

    (3)根本不等待:检查描述字后立即返回,这称为轮询。为此,该参数必须指向一个timeval结构,而且其中的定时器值必须为0。

      select的好处就在于单进程可以同时处理多个网络连接的IO,它的基本原理就是select会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程,流程如下:

      当用户进程调用了调用了select,那么整个进程会被block,而同时,kernel会“监视”所有的select负责的socket,当任何一个socket中的数据准备好了,select就会返回,这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。这里需要使用两个系统调用(select和recvfrom),而blocking IO之调用了一个系统调用(recvfrom)。但是,用select的优势在于它可以同时处理多个连接。

    需要强调的是:

      1.如果处理的连接数不是很高的话,使用select的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select的优势并不是对于单个连接能处理的更快,而在于能处理更多的连接。

      2.在多路复用模型中,对于每一个socket,一般都设置为non-blocking,但是,如上图所示,整个用户的进程一直都是被锁定的 ,只不过进程是被socket这个函数锁定,而不是被socket IO给锁定。

    故而:select的优势在于可以处理多个连接,不适用于单个连接。

    import socket
    import select
    server = socket.socket()
    server.bind(("127.0.0.1",21211))
    server.listen()
    
    # server.setblocking(False)
    
    # select 是帮我们监控连接
    # 需要给它传两个列表 一个是检测是否可读(是否可以执行recv)  一个是检测是否可写(是否可执行send)
    rlist = [server,]
    wlist = []
    # 默认select是阻塞的 会直到有其中一个或几个需要被处理
    
    # 存储要发送的数据
    msg = {}
    
    
    # 返回值
    #    1.可读的连接(可以执行recv)
    #    2.可写的连接(可以执行send)
    while True:
        readable_list,writeable_list,_ = select.select(rlist,wlist,[])
        # 接下来就是要处理这些可读可写列表
        print(readable_list)
        # 处理可读列表
        for c in readable_list:
            if c == server: # 说明当需要被处理的是服务器
                client,addr = c.accept()
                # 把客户端也交给select来检测
                rlist.append(client)
            else:
                print("客户端可以recv啦!")
                data = c.recv(1024)
                print(data.decode("utf-8"))
                # 给客户端返回数据
                # c.send(data.upper())
                wlist.append(c)   # 将客户端也交给select检测是否可写
                msg[c] = data
    
        print(writeable_list)
        print(msg)
        # 处理可写列表
        for w in writeable_list:
            w.send(msg[w].upper())
            # 将已经发送完成的连接从 检测列表删除
            wlist.remove(w)
    多路复用服务器
    多路复用客户端
    select监听fd变化的过程分析:
        用户进程创建select对象,拷贝监听的fd到内核空间,每一个fd会对应一张系统文件表,内核空间的fd响应到数据之后,就会发送信号给用户进程数据;
        用户进程再发送系统调用,比如(accept)将内核空间的数据copy到用户空间,同时作为接受数据端内核空间数据清除,这样重新监听时fd再有新的数据又可以响应到了(发送端因为基于TCP协议所以需要收到应答后才会清除)

    该模型的优点是:

      相比其他模型,使用select()的事件驱动模型只用单线程(进程)执行,占用资源少,不消耗太多CPU,同时能够为多客户端提供服务。如果试图建立一个简单的事件驱动的服务器程序,这个模型具有一定的参考价值。

    缺点是:

      select()并不是实现“事件驱动”的最好选择。当需要探测的句柄较大的时候,select()接口本身需要消耗大量的时间区轮询各个句柄。很多操作系统提供了更为高效的接口。其次,该模型将事件探测和事件响应夹杂在一起,一旦事件响应的执行体庞大,则对整个模型是灾难性的。

    四:异步IO(Asynchronous I/O)(在此处只要做了解)

    流程如下:

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

  • 相关阅读:
    时间等待太重要!!!
    (引用 )自动化测试报告HTMLtestrunner
    (转载)selenium-webdriver(python)
    lr_convert_string_encoding()转码函数
    分步骤学习自动化测试
    (引用)web安全测试
    Monkey测试
    (学习网址)Python 自动化测试
    (引用) unittest测试驱动之执行测试(三)
    log4net日志组件
  • 原文地址:https://www.cnblogs.com/dongzhihaoya/p/10305451.html
Copyright © 2011-2022 走看看