并发编程——IO模型详解

​我是一个Python技术小白,对于我而言,多任务处理一般就借助于多进程以及多线程的方式,在多任务处理中如果涉及到IO操作,则会接触到同步、异步、阻塞、非阻塞等相关概念,当然也是并发编程的基础。

​而当我接触到网络编程时,是使用listen()、send()、recv() 等接口,借助于Python提供的Socket网络套接字模块,基于UDPTCP协议进行逻辑编写,会发现一个问题,socket接口都是阻塞型的。所谓阻塞型接口是指系统调用(一般是IO接口)不返回调用结果并让当前线程一直阻塞,只有当该系统调用获得结果或者超时出错时才返回。

并发编程——IO模型详解插图

所以首先分析分析第一种IO模型——阻塞式IO

阻塞式IO模型(blocking IO)

​ 在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样:

并发编程——IO模型详解插图(1)

   并发编程——IO模型详解插图(2)

 

如图所示:

​ 客户端向服务端请求数据时,客户端的recvfrom接口用于接收服务端的响应数据;

​ 服务端在接收到客户端的请求后,kernel就开始了IO的第一个阶段:准备数据。对于network IO来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候kernel就要等待足够的数据到来。

​ 而客户端收发数据的进程会一直被阻塞。当kernel等到数据准备好以后,就会将数据从kernel拷贝到用户内存中,然后返回响应数据,客户端进程才能解除block状态,重新运行起来。

​ 就好比于:用杯子装水,打开水龙头装满水然后离开。这一过程就可以看成是使用了阻塞IO模型,因为如果水龙头没有水,也要等到有水并装满杯子才能离开去做别的事情。很显然,这种IO模型是同步的!

所以,blocking IO的特点就是在IO执行的两个阶段(等待数据和拷贝数据两个阶段)都在block状态。

实际上,除非特别指定,几乎所有的IO接口 ( 包括socket接口 ) 都是阻塞型的。这给网络编程带来了一个很大的问题,如在调用recv(1024)的同时,线程将被阻塞,在此期间,线程将无法执行任何运算或响应任何的网络请求。

​ 此时则会想到并发编程与网络编程相结合,即:

在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。

​ 但是这样处理存在很大的问题:

开启多进程或多线程的方式,在遇到要同时响应成百上千路的连接请求时,无论多线程还是多进程都会严重占据系统资源,降低系统对外界的响应效率,而且线程与进程本身也更容易进入假死状态。

当然,可以借助于线程池或者连接池

“线程池”旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。

“连接池”维持连接的缓存池,尽量重用已有的连接、减少创建和关闭连接的频率。这两种技术都可以很好的降低系统开销,都被广泛应用很多大型系统,如websphere、tomcat和各种数据库等。

	但是呢,这样做的局限性也是显而易见的:

使用“线程池”和“连接池”也只是在一定程度上缓解了频繁调用IO接口带来的资源占用

而且,所谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。

​ 对于所面临的可能同时出现的上千甚至上万次的客户端请求,“线程池”或“连接池”或许可以缓解部分压力,但是不能解决所有问题。总之,多线程模型可以方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型的弊端也会显露出来,所以可以用非阻塞接口来尝试解决这个问题。

非阻塞式IO模型(non-blocking IO)

使用Socket套接字进行网络编程时,可以通过设置socket使其变为non-blockings.setblocking(False) 。设置之后客户端与服务端之间的数据收发以及请求响应的流程如图所示:

   并发编程——IO模型详解插图(3)

 并发编程——IO模型详解插图(4)

​ 由图可知:当客户端向服务端请求数据时,如果服务端kernel中的数据还没有准备好,则会立刻返回给客户端一个error。

​ 从而客户端并不需要等待,而是马上就得到了一个结果。客户端判断结果是一个error时,便就知道数据还没有准备好,于是用户就可以在本次到下次再发起请求的时间间隔内做其他事情,或者直接再次发送请求。

​ 一旦服务端kernel中的数据准备好以后,并且又再次收到了客户端的system call,则将数据拷贝到用户内存中(这一阶段仍然是阻塞的),然后返回响应数据。

​ 也就是说客户端在recvfrom的过程中,其业务逻辑并没有被阻塞,因为服务端会马上返回响应,如果数据还没准备好,会返回一个error。此时客户端可以做其他的业务逻辑,然后再发起请求。重复上面的过程,这个过程通常被称之为轮询。轮询检查内核数据,直到数据准备好,再拷贝数据到用户空间,进行数据处理。需要注意的是,拷贝数据整个过程,进程仍然是属于阻塞的状态。

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

​ 就好比于:某人用杯子装水,打开水龙头后发现没有水,则离开,过一会又拿着杯子来看看……在中间离开的这些时间里,此人离开了装水现场(回到用户进程空间),可以做他自己的事情。这就是非阻塞IO模型。但是此模型只有在检查数据是否准备好时,是非阻塞的,在数据到达的时候依然要等待复制数据到用户空间(等待水杯中的水装满),因此非阻塞式IO模型还是同步IO!

Python实现非阻塞IO实例

#服务端
from socket import *
import time
s=socket(AF_INET,SOCK_STREAM)
s.bind(('127.0.0.1',8080))
s.listen(5)
s.setblocking(False) #设置socket的接口为非阻塞
conn_l=[]
del_l=[]
while True:
    try:
        conn,addr=s.accept()
        conn_l.append(conn)
    except BlockingIOError:
        print(conn_l)
        for conn in conn_l:
            try:
                data=conn.recv(1024)
                if not data:
                    del_l.append(conn)
                    continue
                conn.send(data.upper())
            except BlockingIOError:
                pass
            except ConnectionResetError:
                del_l.append(conn)

        for conn in del_l:
            conn_l.remove(conn)
            conn.close()
        del_l=[]
#客户端
from socket import *
c=socket(AF_INET,SOCK_STREAM)
c.connect(('127.0.0.1',8080))

while True:
    msg=input('>>: ')
    if not msg:continue
    c.send(msg.encode('utf-8'))
    data=c.recv(1024)
    print(data.decode('utf-8'))

注意:非阻塞IO模型绝不被推荐。

我们不能否则其优点:能够在等待任务完成的时间里干其他活了(包括提交其他任务,也就是 “后台” 可以有多个任务在“”同时“”执行)。

但是也难掩其缺点:

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

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

多路复用式IO模型(IO multiplexing)

  IO multiplexing这个词可能有点陌生,但是如果我说select/epoll,大概就都能明白了。有些地方也称这种IO方式为事件驱动IO(event driven IO)。我们都知道,select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。它的流程如图:

并发编程——IO模型详解插图(5)

  并发编程——IO模型详解插图(6)

​ 此模型在调用recv前先调用select或者poll,这二者的系统调用都可以使服务端在内核准备好数据(网络数据到达内核)时告知用户进程,此时客户端再调用recv接收数据,服务端可以直接讲加载到客户端空间的网络数据返回。因此,此模型中客户端在调用select或poll时,处于阻塞状态,而在recv时则没有阻塞!

​ 有人将非阻塞IO定义成在读写操作时没有阻塞于系统调用的IO操作(不包括数据从内核复制到用户空间时的阻塞,因为这相对于网络IO来说确实很短暂),如果按这样理解,这种IO模型也能称之为非阻塞IO模型,但是按POSIX来看,此模型也属于同步IO,可以称之为同步非阻塞IO

​ 这种IO模型比较特别,分个段。因为它能同时监听多个文件描述符(fd)。

​ 就好比于:某人用水杯去盛水,发现有一排水龙头,管水的大爷告诉他这些水龙头都还没有水,等有水了告诉他。于是便等待(select调用中),过了一会大爷告诉他有水了,但不知道是哪个水龙头有水,自己看吧。于是他将水龙头一个个打开,往杯子里装水(recv)。

​ 这里再顺便说说鼎鼎大名的epoll(高性能的代名词啊),epoll也属于IO复用模型,主要区别在于大爷会告诉他哪几个水龙头有水了,则他就不需要一个个打开看(当然还有其它区别)。

​ 这个图和blocking IO的图其实并没有太大的不同,事实上还更差一些。因为这里需要使用两个系统调用(select和recvfrom),而blocking IO只调用了一个系统调用(recvfrom)。但是,用select的优势在于它可以同时处理多个connection。

强调:

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

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

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

补充:POSIX

POSIX(可移植操作系统接口)把同步IO操作定义为导致进程阻塞直到IO完成的操作,反之则是异步IO

按POSIX的描述似乎把同步和阻塞划等号,异步和非阻塞划等号,但是也有观点称:同步IO不等于阻塞IO

Python实现多路复用式IO模型

#服务端
from socket import *
import select

s=socket(AF_INET,SOCK_STREAM)
s.setsockopt(SOL_SOCKET,SO_REUSEADDR,1)
s.bind(('127.0.0.1',8081))
s.listen(5)
s.setblocking(False) #设置socket的接口为非阻塞
read_l=[s,]
while True:
    r_l,w_l,x_l=select.select(read_l,[],[])
    print(r_l)
    for ready_obj in r_l:
        if ready_obj == s:
            conn,addr=ready_obj.accept() #此时的ready_obj等于s
            read_l.append(conn)
        else:
            try:
                data=ready_obj.recv(1024) #此时的ready_obj等于conn
                if not data:
                    ready_obj.close()
                    read_l.remove(ready_obj)
                    continue
                ready_obj.send(data.upper())
            except ConnectionResetError:
                ready_obj.close()
                read_l.remove(ready_obj)


#客户端
from socket import *
c=socket(AF_INET,SOCK_STREAM)
c.connect(('127.0.0.1',8081))

while True:
    msg=input('>>: ')
    if not msg:continue
    c.send(msg.encode('utf-8'))
    data=c.recv(1024)
    print(data.decode('utf-8'))

模型分析详解

select监听fd变化的过程分析:

#用户进程创建socket对象,拷贝监听的fd到内核空间,每一个fd会对应一张系统文件表,内核空间的fd响应到数据后,就会发送信号给用户进程数据已到;
#用户进程再发送系统调用,比如(accept)将内核空间的数据copy到用户空间,同时作为接受数据端内核空间的数据清除,这样重新监听时fd再有新的数据又可以响应到了(发送端因为基于TCP协议所以需要收到应答后才会清除)。

该模型的优点:

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

该模型的缺点:

#首先select()接口并不是实现“事件驱动”的最好选择。因为当需要探测的句柄值较大时,select()接口本身需要消耗大量时间去轮询各个句柄。#很多操作系统提供了更为高效的接口,如linux提供了epoll,BSD提供了kqueue,Solaris提供了/dev/poll,…。#如果需要实现更高效的服务器程序,类似epoll这样的接口更被推荐。遗憾的是不同的操作系统特供的epoll接口有很大差异,#所以使用类似于epoll的接口实现具有较好跨平台能力的服务器会比较困难。
#其次,该模型将事件探测和事件响应夹杂在一起,一旦事件响应的执行体庞大,则对整个模型是灾难性的。

信号驱动IO模型

通过调用sigaction注册信号函数,等内核数据准备好的时候系统中断当前程序,执行信号函数(在这里面调用recv)。某人让管水的大爷等有水的时候通知他(注册信号函数),没多久他得知有水了,跑去装水。是不是很像异步IO?很遗憾,它还是同步IO(省不了装水的时间啊)

并发编程——IO模型详解插图(7)

异步IO(Asynchronous I/O)

如图所示:客户端进程调用aio_read,让内核等数据准备好,并且复制到用户进程空间后执行事先指定好的函数。某人让管水的大爷将杯子装满水后通知他。整个过程中他都可以做别的事情(没有recv),这才是真正的异步IO

并发编程——IO模型详解插图(8)

  并发编程——IO模型详解插图(9)

​ 用户进程发起请求之后,立刻就可以开始处理其他的业务逻辑。而另一方面,从kernel的角度,当它收到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉客户端请求的数据已经复制到其空间中了。

IO模型比较分析

  并发编程——IO模型详解插图(10)

 

并发编程——IO模型详解插图(11)

​ 经过上面的介绍,会发现非阻塞 IO和异步 IO的区别还是很明显的。在非阻塞 IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。而异步 IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据,可以理解为异步非阻塞

​ IO分两阶段:

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

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

​ 一般来讲:阻塞IO模型、非阻塞IO模型、IO复用模型(select/poll/epoll)、信号驱动IO模型都属于同步IO,因为阶段2是阻塞的(尽管时间很短)。只有异步IO模型是符合POSIX异步IO操作含义的,不管在阶段1还是阶段2都可以干别的事。

本站资源均源自网络,若涉及您的版权、知识产权或其他利益,请附上版权证明邮件告知。收到您的邮件后,我们将在72小时内删除。
若下载资源地址错误或链接跳转错误请联系站长。站长q:770044133。

» 并发编程——IO模型详解

发表评论

免登录下载网,提供全网最优质的资源集合!