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
基本概念
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操作完成了。