IO模型
同步\异步and阻塞\非阻塞(重点)
同步:
所谓同步,就是在发出一个功能调用时,在没有得到结果之前,该调用就不会返回。按照这个定义,其实绝大多数函数都是同步调用。但是一般而言,我们在说同步、异步的时候,特指那些需要其他部件协作或者需要一定时间完成的任务。
举例:
1. multiprocessing.Pool下的apply #发起同步调用后,就在原地等着任务结束,根本不考虑任务是在计算还是在io阻塞,总之就是一股脑地等任务结束
2. concurrent.futures.ProcessPoolExecutor().submit(func,).result()
3. concurrent.futures.ThreadPoolExecutor().submit(func,).result
异步:
#异步的概念和同步相对。当一个异步功能调用发出后,调用者不能立刻得到结果。当该异步功能完成后,通过状态、通知或回调来通知调用者。如果异步功能用状态来通知,那么调用者就需要每隔一定时间检查一次,效率就很低(有些初学多线程编程的人,总喜欢用一个循环去检查某个变量的值,这其实是一 种很严重的错误)。如果是使用通知的方式,效率则很高,因为异步功能几乎不需要做额外的操作。至于回调函数,其实和通知没太多区别。 #举例: #1. multiprocessing.Pool().apply_async() #发起异步调用后,并不会等待任务结束才返回,相反,会立即获取一个临时结果(并不是最终的结果,可能是封装好的一个对象)。 #2. concurrent.futures.ProcessPoolExecutor(3).submit(func,) #3. concurrent.futures.ThreadPoolExecutor(3).submit(func,)
阻塞:
#阻塞调用是指调用结果返回之前,当前线程会被挂起(如遇到io操作)。函数只有在得到结果之后才会将阻塞的线程激活。有人也许会把阻塞调用和同步调用等同起来,实际上他是不同的。对于同步调用来说,很多时候当前线程还是激活的,只是从逻辑上当前函数没有返回而已。
#举例:
#1. 同步调用:apply一个累计1亿次的任务,该调用会一直等待,直到任务返回结果为止,但并未阻塞住(即便是被抢走cpu的执行权限,那也是处于就绪态);
#2. 阻塞调用:当socket工作在阻塞模式的时候,如果没有数据的情况下调用recv函数,则当前线程就会被挂起,直到有数据为止。
非阻塞:
#非阻塞和阻塞的概念相对应,指在不能立刻得到结果之前也会立刻返回,同时该函数不会阻塞当前线程。
小结:
#1. 同步与异步针对的是函数/任务的调用方式:同步就是当一个进程发起一个函数(任务)调用的时候,一直等到函数(任务)完成,而进程继续处于激活状态。而异步情况下是当一个进程发起一个函数(任务)调用的时候,不会等函数返回,而是继续往下执行当,函数返回的时候通过状态、通知、事件等方式通知进程任务完成。
#2. 阻塞与非阻塞针对的是进程或线程:阻塞是当请求不能满足的时候就将进程挂起,而非阻塞则不会阻塞当前进程
IO模型介绍
IO模型都是都是指单个线程下对阻塞的处理方式的模型,注意是单线程
本文讨论的背景是Linux环境下的network IO。本文最重要的参考文献是Richard Stevens的“UNIX® Network Programming Volume 1, Third Edition: The Sockets Networking ”,6.2节“I/O Models ”,Stevens在这节中详细说明了各种IO的特点和区别,如果英文够好的话,推荐直接阅读。Stevens的文风是有名的深入浅出,所以不用担心看不懂。本文中的流程图也是截取自参考文献。
Stevens在文章中一共比较了五种IO Model:
* blocking IO
* nonblocking IO
* IO multiplexing
* signal driven IO
* asynchronous IO
由signal driven IO(信号驱动IO)在实际中并不常用,所以主要介绍其余四种IO Model。
再说一下IO发生时涉及的对象和步骤。对于一个network IO \(这里我们以read举例\),它会涉及到两个系统对象,一个是调用这个IO的process \(or thread\),另一个就是系统内核\(kernel\)。当一个read操作发生时,该操作会经历两个阶段:
1)等待数据准备 (Waiting for the data to be ready)
2)将数据从内核拷贝到进程中(Copying the data from the kernel to the process)
记住这两点很重要,因为这些IO模型的区别就是在两个阶段上各有不同的情况。
阻塞IO(blocking IO)
在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样:
当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据。对于network io来说,很多时候数据在一开始还没有到达(比如,还没有 收到一个完整的UDP包),这个时候kernel就要等待足够的数据到来。
而在
用户进程这边,整个进程会被阻塞。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。
所以,blocking IO的特点就是在IO执行的两个阶段(等待数据和拷贝数据两个阶段)都被block了。
几乎所有的程序员第一次接触到的网络编程都是从listen\(\)、send\(\)、recv\(\) 等接口开始的,
使用这些接口可以很方便的构建服务器/客户机的模型。然而大部分的socket接口都是阻塞型的。如下图
ps:
所谓阻塞型接口是指系统调用(一般是IO接口)不返回调用结果并让当前线程一直阻塞
只有当该系统调用获得结果或者超时出错时才返回。
实际上,除非特别指定,几乎所有的IO接口 ( 包括socket接口 ) 都是阻塞型的。这给网络编程带来了一个很大的问题,如在调用recv(1024)的同时,线程将被阻塞,在此期间,线程将无法执行任何运算或响应任何的网络请求。
一个简单的解决方案:
在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。
该方案的问题是:
开启多进程或都线程的方式,在遇到要同时响应成百上千路的连接请求,则无论多线程还是多进程都会严重占据系统资源,降低系统对外界响应效率,而且线程与进程本身也更容易进入假死状态。
改进方案:
很多程序员可能会考虑使用“线程池”或“连接池”。“线程池”旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。
“连接池”维持连接的缓存池,尽量重用已有的连接、减少创建和关闭连接的频率。这两种技术都可以很好的降低系统开销,都被广泛应用很多大型系统,如websphere、tomcat和各种数据库等。
改进后方案其实也存在着问题:
“线程池”和“连接池”技术也只是在一定程度上缓解了频繁调用IO接口带来的资源占用。而且,所谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用“池”必须考虑其面临的响应规模,
并根据响应规模调整“池”的大小。
对应上例中的所面临的可能同时出现的上千甚至上万次的客户端请求,“线程池”或“连接池”或许可以缓解部分压力,但是不能解决所有问题。总之,多线程模型可以方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用非阻塞接口来尝试解决这个问题。
非阻塞IO(non-blocking IO)
Linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:
从图中可以看出,当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是用户就可以在本次到下次再发起read询问的时间间隔内做其他事情,或者直接再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存(这一阶段仍然是阻塞的),然后返回。
也就是说非阻塞的recvform系统调用调用之后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好,此时会返回一个error。
进程在返回之后,可以干点别的事情,然后再发起recvform系统调用。重复上面的过程,循环往复的进行recvform系统调用。
这个过程通常被称之为轮询。轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态。
所以,在非阻塞式IO中,用户进程其实是需要不断的主动询问kernel数据准备好了没有。
非阻塞IO示例
server
1 from socket import * 2 3 def server(ip,port): 4 server = socket(AF_INET,SOCK_STREAM) 5 server.bind((ip,port)) 6 server.listen(5) 7 server.setblocking(False) #设置非阻塞模式,当没有数据时会立即返回一个信号BlockingIOError,而不是阻塞等待 8 9 rlist = [] #存储链接建立后的conn 10 wlist = [] #存储接收到数据的conn,用于回复信息 11 12 while True: 13 try: 14 # print('starting...') 15 conn,addr = server.accept() #对应client端的connect() 16 rlist.append(conn) 17 ''' 18 若链接请求一直来,一直在建链接,则不会出现BlockingIOError,就会一直建连接,消息的的接收就会不及时 19 ''' 20 print(rlist) 21 except BlockingIOError: #非阻塞模式,client端没有发起connect时,会立即返回一个信号BlockingIOError 22 # print('执行别的任务') 23 del_rlist = [] 24 for conn in rlist: 25 try: 26 data = conn.recv(1024) #对应client端没有send()数据时,会立即返回一个信号BlockingIOError 27 if not data: 28 del_rlist.append(conn) 29 wlist.append((conn,data.upper())) 30 ''' 31 若消息一直来,一直在接消息,则不会出现BlockingIOError,就会一接消息,则此时若有新的链接请求就会响应不及时 32 ''' 33 except BlockingIOError:#若某个conn没有接收到数据,保存下来等待下一次建立链接时有BlockingIOError时在重新询问接收数据 34 continue 35 except Exception: #当client端出现断开链接ConnectionResetError等其他的异常就会关闭该conn套接字 36 conn.close() 37 del_rlist.append(conn) #准备删除的conn,client端单方面中断,回收对应的conn 38 39 del_wlist = [] 40 for item in wlist: #信息发送 41 try: 42 conn = item[0] 43 data = item[1] 44 conn.send(data) 45 del_wlist.append(conn) 46 except BlockingIOError: # 发送数据也属于IO操作,当系统缓存满时数据就会有BlockingIOError,阻塞IO模式下会一直等待系统缓存有空间再copy到缓存中 47 continue #如果回收此次发送不成功的conn后,client端终止了呢,此conn下次再发送数据不是会报连接异常吗? 48 except Exception: 49 conn.close() 50 del_wlist.append(conn) 51 52 for conn in del_wlist: #删除发送消息成功或连接断开的conn 53 for item in wlist: 54 if conn in item: 55 wlist.remove(item) 56 57 for conn in del_rlist: #删除接收消息时链接断开的conn 58 rlist.remove(conn) 59 ''' 60 rlist 中的conn和wlist中的conn是互斥的,只有成功接收到消息的conn才会被放入wlist中 61 ''' 62 server.close() 63 64 if __name__ == '__main__': 65 server('127.0.0.1',8080)
1 from socket import * 2 c=socket(AF_INET,SOCK_STREAM) 3 c.connect(('127.0.0.1',8080)) 4 5 while True: 6 msg=input('>>: ') 7 if not msg:continue 8 c.send(msg.encode('utf-8')) 9 data=c.recv(1024) 10 print(data.decode('utf-8'))
但是非阻塞IO模型绝不被推荐。
我们不能否则其优点:能够在等待任务完成的时间里干其他活了(包括提交其他任务,也就是 “后台” 可以有多个任务在“”同时“”执行)。
但是也难掩其缺点:
1. 循环调用recv()将大幅度推高CPU占用率;这也是我们在代码中留一句time.sleep(2)的原因,否则在低配主机下极容易出现卡机情况
2. 任务完成的响应延迟增大了,因为每过一段时间才去轮询一次read操作,而任务可能在两次轮询之间的任意时间完成。
这会导致整体数据吞吐量的降低。
多路复用IO(IO multiplexing)
IO multiplexing这个词可能有点陌生,但是如果我说select/epoll,大概就都能明白了。有些地方也称这种IO方式为事件驱动IO
(event driven IO)。我们都知道,select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。它的流程如图:
当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
这个图和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的优势在于可以处理多个连接,不适用于单个连接
select网络IO模型示例
在select网络IO模型中没有检测except BlockingIOError,因为select模块帮忙询问了,无论是链接建立、数据接收、数据发送都帮忙询问了,
sever端
1 from socket import * 2 import select 3 4 def server(ip,port): 5 server = socket(AF_INET,SOCK_STREAM) 6 server.bind((ip,port)) 7 server.listen(5) 8 server.setblocking(False) 9 10 rlist = [server, ] 11 wlist = [] 12 wdata = {} 13 while True: 14 rl,wl,xl=select.select(rlist,wlist,[],0.5) #0.5s才能询问一次,询问 15 print('wl1',wl) 16 for sock in rl: 17 if sock == server: 18 conn,addr = sock.accept() 19 rlist.append(conn) 20 else: 21 try: 22 data = sock.recv(1024) 23 if not data: 24 sock.close() 25 rlist.remove(sock) 26 continue 27 wlist.append(sock) 28 wdata[sock]=data.upper() #这样在第二次同一个客户端发来信息时会覆盖之前发来的信息, 29 # 因为第一次添加数据后没有立刻返回到wl,必须等待下次0.5s后返回wl 30 print('wl',wl) #再次执行select才能返回到wl中 31 except Exception: 32 sock.close() 33 rlist.remove(sock) 34 for sock in wl: #处理的是上一次的返回的wl,此次添加到wlist中的conn没有返回wl 35 data = wdata[sock] 36 sock.send(data) 37 wlist.remove(sock) 38 wdata.pop(sock) 39 40 if __name__ == '__main__': 41 server('127.0.0.1',8081)
client端
1 from socket import * 2 c=socket(AF_INET,SOCK_STREAM) 3 c.connect(('127.0.0.1',8081)) 4 5 while True: 6 msg=input('>>: ') 7 if not msg:continue 8 c.send(msg.encode('utf-8')) 9 data=c.recv(1024) 10 print(data.decode('utf-8'))
另外的示例,上述的代码有些问题
1 import socket 2 import queue 3 from select import select 4 5 SERVER_IP = ('127.0.0.1', 9999) 6 7 # 保存客户端发送过来的消息,将消息放入队列中 8 message_queue = {} 9 input_list = [] 10 output_list = [] 11 12 if __name__ == "__main__": 13 server = socket.socket() 14 server.bind(SERVER_IP) 15 server.listen(10) 16 # 设置为非阻塞 17 server.setblocking(False) 18 19 # 初始化将服务端加入监听列表 20 input_list.append(server) 21 22 while True: 23 # 开始 select 监听,对input_list中的服务端server进行监听 24 stdinput, stdoutput, stderr = select(input_list, output_list, input_list) 25 26 # 循环判断是否有客户端连接进来,当有客户端连接进来时select将触发 27 for obj in stdinput: 28 # 判断当前触发的是不是服务端对象, 当触发的对象是服务端对象时,说明有新客户端连接进来了 29 if obj == server: 30 # 接收客户端的连接, 获取客户端对象和客户端地址信息 31 conn, addr = server.accept() 32 print("Client {0} connected! ".format(addr)) 33 # 将客户端对象也加入到监听的列表中, 当客户端发送消息时 select 将触发 34 input_list.append(conn) 35 # 为连接的客户端单独创建一个消息队列,用来保存客户端发送的消息 36 message_queue[conn] = queue.Queue() 37 38 else: 39 # 由于客户端连接进来时服务端接收客户端连接请求,将客户端加入到了监听列表中(input_list),客户端发送消息将触发 40 # 所以判断是否是客户端对象触发 41 try: 42 recv_data = obj.recv(1024) 43 # 客户端未断开 44 if recv_data: 45 print("received {0} from client {1}".format(recv_data.decode(), addr)) 46 # 将收到的消息放入到各客户端的消息队列中 47 message_queue[obj].put(recv_data) 48 49 # 将回复操作放到output列表中,让select监听 50 if obj not in output_list: 51 output_list.append(obj) 52 53 except ConnectionResetError: 54 # 客户端断开连接了,将客户端的监听从input列表中移除 55 input_list.remove(obj) 56 # 移除客户端对象的消息队列 57 del message_queue[obj] 58 print("\n[input] Client {0} disconnected".format(addr)) 59 60 # 如果现在没有客户端请求,也没有客户端发送消息时,开始对发送消息列表进行处理,是否需要发送消息 61 for sendobj in output_list: 62 try: 63 # 如果消息队列中有消息,从消息队列中获取要发送的消息 64 if not message_queue[sendobj].empty(): 65 # 从该客户端对象的消息队列中获取要发送的消息 66 send_data = message_queue[sendobj].get() 67 sendobj.sendall(send_data) 68 else: 69 # 将监听移除等待下一次客户端发送消息 70 output_list.remove(sendobj) 71 72 except ConnectionResetError: 73 # 客户端连接断开了 74 del message_queue[sendobj] 75 output_list.remove(sendobj) 76 print("\n[output] Client {0} disconnected".format(addr))