一: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()
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模型绝不被推荐。
尽管能够在等待任务完成的时间里完成其他任务,但是:
#1. 循环调用recv()将大幅度推高CPU占用率;这也是我们在代码中留一句time.sleep()的原因,否则在低配主机下极容易出现卡机情况 #2. 任务完成的响应延迟增大了,因为每过一段时间才去轮询一次read操作,而任务可能在两次轮询之间的任意时间完成。这会导致整体数据吞吐量的降低。
此外,在这个方案中只是提供了一种解决方法来起到监测“操作是否完成”的作用,实际上操作系统提供了更为高效的检测接口,例如select()多路复用模式,可以以此检测多个连接是否活跃。
四:多路复用IO
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)
import socket import os import 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"))
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操作完成了。