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操作完成了。

posted @ 2019-01-22 19:03  呔!妖精。。。  阅读(161)  评论(0编辑  收藏  举报