1、IO模型介绍
为了更好地了解IO模型,咱们须要事先回顾下:同步、异步、阻塞、非阻塞html
同步(synchronous) IO和异步(asynchronous) IO,阻塞(blocking) IO和非阻塞(non-blocking)IO分别是什么,到底有什么区别?这个问题其实不一样的人给出的答案均可能不一样,好比wiki,就认为asynchronous IO和non-blocking IO是一个东西。linux
本文讨论的背景是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: web
- blocking IO 阻塞I/O
- nonblocking IO 非阻塞I/O
- IO multiplexing I/O多路复用
- signal driven IO 信号驱动I/O
- asynchronous IO 异步I/O
由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模型的区别就是在两个阶段上各有不一样的状况。tomcat
补充:服务器
一、输入操做:read、readv、recv、recvfrom、recvmsg共5和函数,若是会阻塞状态,则会经历wait data和copy data两个阶段,若是设置为非阻塞则在wait不到data时抛出异常。
二、输出操做:write、writev、send、sendto、sendmsg共5个函数,在发送缓冲区满了会阻塞在原地,若是设置为非阻塞,则会抛出异常。
三、接收外来连接:accept,与输入操做相似
四、发起外出连接:connect,与输出操做相似
2、阻塞IO(blocking IO)
在linux中,默认状况下全部的socket都是blocking,一个典型的读操做流程大概是这样:

当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据。对于network io来讲,不少时候数据在一开始尚未到达(好比,尚未收到一个完整的UDP包),这个时候kernel就要等待足够的数据到来。
而在用户进程这边,整个进程会被阻塞。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,而后kernel返回结果,用户进程才解除block的状态,从新运行起来。
因此,blocking IO的特色就是在IO执行的两个阶段(等待数据和拷贝数据两个阶段)都被block了。
from socket import *
server = socket(AF_INET, SOCK_STREAM)
server.bind(('127.0.0.1', 8093))
server.listen(5)
while True: # 连接循环
print("starting....")
conn, addr = server.accept() # 等对方来连————阻塞(操做系统会将cpu拿走)
print(addr)
while True: # 通信循环
try:
data = conn.recv(1024) # 等待收消息————阻塞
if not data: break
conn.send(data.upper())
except ConnectionResetError:
break
conn.close()
server.close()
服务端
from socket import *
client = socket(AF_INET, SOCK_STREAM)
client.connect(("127.0.0.1", 8093))
while True:
msg = input(">>").strip()
if not msg:continue
client.send(msg.encode("utf-8"))
data = client.recv(1024)
print(data.decode("utf-8"))
client.close()
客户端
以前学习的网络编程都是从listen、send、recv等接口开始的,使用这些接口能够很方便地构建服务器\客户机模型。可是大部分的socket接口都是阻塞型的。

所谓阻塞型接口是指系统调用(通常是IO接口)不返回调用结果并让当前线程一直阻塞,只有当该系统调用得到结果或超时出错时才返回。
为了能在调用recv(),线程被阻塞的同时,仍能响应其余的网络请求,可使用以前学习到的多线程(或多进程)来解决。
from socket import *
from threading import Thread
def communicate(conn):
while True: # 通信循环
try:
data = conn.recv(1024) # 等待收消息————阻塞
if not data: break
conn.send(data.upper())
except ConnectionResetError:
break
conn.close()
server = socket(AF_INET, SOCK_STREAM)
server.bind(('127.0.0.1', 8093))
server.listen(5)
while True: # 连接循环
print("starting....")
conn, addr = server.accept() # 等对方来连————阻塞(操做系统会将cpu拿走)
print(addr)
t=Thread(target=communicate, args=(conn,))
t.start()
server.close()
多线程服务端
上面这个方案存在的问题:开启多进程或都线程的方式,在遇到要同时响应成百上千路的链接请求,则不管多线程仍是多进程都会严重占据系统资源,下降系统对外界响应效率,并且线程与进程自己也更容易进入假死状态。
进一步的改进方案:不少程序员会考虑使用“线程池”或“链接池”。
“线程池”旨在减小建立和销毁线程的频率,其维持必定合理数量的线程,并让空闲的线程从新承担新的执行任务。
“链接池”维持链接的缓存池,尽可能重用已有的链接、减小建立和关闭链接的频率。
这两种技术均可以很好的下降系统开销,都被普遍应用不少大型系统,如websphere、tomcat和各类数据库等。
新改进方案存在的问题:
“线程池”和“链接池”技术也只是在必定程度上缓解了频繁调用IO接口带来的资源占用。并且,所谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。因此使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。(规模过大,反而会下降效率)
总之,多线程模型能够方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈,能够用非阻塞接口来尝试解决这个问题。
3、非阻塞IO(non-blocking IO)
Linux下,能够经过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操做时,流程是这个样子:

当用户进程发出read操做时,若是kernel中的数据尚未准备好,那么它并不会block用户进程,而是马上返回一个error。从用户进程角度讲 ,它发起一个read操做后,并不须要等待,而是立刻就获得了一个结果。用户进程判断结果是一个error时,它就知道数据尚未准备好,因而用户就能够在本次到下次再发起read询问的时间间隔内作其余事情,或者直接再次发送read操做。一旦kernel中的数据准备好了,而且又再次收到了用户进程的system call,那么它立刻就将数据拷贝到了用户内存(这一阶段仍然是阻塞的),而后返回。
能够看出wait data这个阶段就被利用上了,copy data是一个本地的操做,时间也比较短,效率获得了很大提升。
也就是说非阻塞的recvform系统调用调用以后,进程并无被阻塞,内核立刻返回给进程,若是数据还没准备好, 此时会返回一个error。进程在返回以后,能够干点别的事情,而后再发起recvform系统调用。重复上面的过程, 循环往复的进行recvform系统调用。这个过程一般被称之为轮询。轮询检查内核数据,直到数据准备好,再拷贝数据到进程, 进行数据处理。须要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态。
因此,在非阻塞式IO中,用户进程实际上是须要不断的主动询问kernel数据准备好了没有。
非阻塞IO示例:
server.setblocking(False) # 默认是True:阻塞,改成False:非阻塞,这行运行后,后面全部的IO操做变为非阻塞。
from socket import *
server = socket(AF_INET, SOCK_STREAM)
server.bind(('127.0.0.1', 8093))
server.listen(5)
server.setblocking(False) # 默认是True:阻塞,改成False:非阻塞,这行运行后,后面全部的IO操做变为非阻塞
print("starting....")
rlist = []
wlist = []
while True: # 连接循环
try:
conn, addr = server.accept() # 问操做系统有没有来连接
rlist.append(conn)
print(rlist)
except BlockingIOError: # 没有连接捕捉异常
# print("干其余活")
"""收消息"""
del_rlist = []
for conn in rlist:
try:
data = conn.recv(1024) # 收消息
if not data:
del_rlist.append(conn)
continue
# conn.send(data.upper()) # 传输的内容不少时,send也会有阻塞
wlist.append((conn, data.upper())) # 存放套接字及套接字准备发送的内容
except BlockingIOError: # 捕捉异常,跳过阻塞异常
continue
except Exception:
conn.close()
del_rlist.append(conn) # 要删除对象加入空列表del_rlist
"""发消息"""
del_wlist = []
for item in wlist:
try:
conn = item[0]
data = item[1]
conn.send(data)
del_wlist.append(conn) # 正常,走到这一步
except BlockingIOError: # 没让发抛出异常
pass
for item in del_wlist:
wlist.remove(item)
for conn in del_rlist:
rlist.remove(conn)
server.close()
非阻塞IO-服务端
from socket import *
client = socket(AF_INET, SOCK_STREAM)
client.connect(("127.0.0.1", 8093))
while True:
msg = input(">>").strip()
if not msg:continue
client.send(msg.encode("utf-8"))
data = client.recv(1024)
print(data.decode("utf-8"))
client.close()
非阻塞IO-客户端
总结非阻塞IO模型:
优势:可以在等待任务完成的时间里干其余活了(包括提交其余任务,也就是 “后台” 能够有多个任务在“”同时“”执行)。
缺点:
一、循环调用recv()将大幅度推高CPU占用率;这也是咱们在代码中留一句time.sleep(2)的缘由,不然在低配主机下极容易出现卡机状况
二、任务完成的响应延迟增大了,由于每过一段时间才去轮询一次read操做,而任务可能在两次轮询之间的任意时间完成。 这会致使总体数据吞吐量的下降。
三、此外,在这个方案中recv()更多的是起到检测“操做是否完成”的做用,实际操做系统提供了更为高效的检测“操做是否完成“做用的接口,例如select()多路复用模式,能够一次检测多个链接是否活跃。
所以非阻塞IO模型不被推荐使用。
4、多路复用IO(IO multiplexing)
多路复用IO模型也有人称为IO多路复用,还有人称为事件驱动IO(event driven IO)。
select是多路复用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的优点在于能够处理多个链接,不适用于单个链接
多路复用IO-服务端
多路复用IO-客户端
select监听fd变化过程分析:
用户进程建立socket对象,拷贝监听的fd到内核空间,每个fd会对应一张系统文件表,内核空间的fd响应到数据后, 就会发送信号给用户进程数据已到; 用户进程再发送系统调用,好比(accept)将内核空间的数据copy到用户空间,同时做为接受数据端内核空间的数据清除, 这样从新监听时fd再有新的数据又能够响应到了(发送端由于基于TCP协议因此须要收到应答后才会清除)。
模型优势:
相比其余模型,使用select() 的事件驱动模型只用单线程(进程)执行,占用资源少,不消耗太多 CPU,同时可以为多客户端提供服务。 若是试图创建一个简单的事件驱动的服务器程序,这个模型有必定的参考价值。
模型缺点:
首先select()接口并非实现“事件驱动”的最好选择。由于当须要探测的句柄值较大时,select()接口自己须要消耗大量时间去轮询各个句柄。 不少操做系统提供了更为高效的接口,如linux提供了epoll,BSD提供了kqueue,Solaris提供了/dev/poll,…。 若是须要实现更高效的服务器程序,相似epoll这样的接口更被推荐。遗憾的是不一样的操做系统特供的epoll接口有很大差别, 因此使用相似于epoll的接口实现具备较好跨平台能力的服务器会比较困难。
其次,该模型将事件探测和事件响应夹杂在一块儿,一旦事件响应的执行体庞大,则对整个模型是灾难性的。
一、select、poll、epoll 模型区别
select,poll,epoll都是IO多路复用的机制。I/O多路复用就经过一种机制,能够监视多个描述符,一旦某个描述符就绪(通常是读就绪或者写就绪),可以通知程序进行相应的读写操做。但select,poll,epoll本质上都是同步I/O,由于他们都须要在读写事件就绪后本身负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需本身负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
(1)select的缺点:
1)单个进程可以监视的文件描述符的数量存在最大限制,一般是1024,固然能够更改数量,但因为select采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差;
2)内核/用户空间内存拷贝问题,select须要复制大量的句柄数据结构,产生巨大的开销
3)select返回的是含有整个句柄的数组,应用程序须要遍历整个数组才能发现哪些句柄发生了事件;
4)select的触发方式是水平触发,应用程序若是没有完成对一个已经就绪的文件描述符进行IO,那么以后再次select调用仍是会将这些文件描述符通知进程。
(2)poll的优劣:
相比于select模型,poll使用链表保存文件描述符,所以没有了监视文件数量的限制,但其余三个缺点依然存在。
(3)epoll特性:
epoll的实现机制与select/poll机制彻底不一样,上面所说的select的缺点在epoll上不复存在。 epoll的设计和实现select彻底不一样。
epoll经过在linux内核中申请一个简易的文件系统(文件系统通常用什么数据结构实现?B+树)。把原先的select/poll调用分红了3个部分:
1)调用epoll_create()创建一个epoll对象(在epoll文件系统中为这个句柄对象分配资源)
2)调用epoll_ctl向epoll对象中添加这100万个链接的套接字
3)调用epoll_wait收集发生的事件的链接 如此一来,要实现上面说的场景,只须要在进程启动时创建一个epoll对象,而后在须要的时候向这个epoll对象中添加或者删除链接。
5、异步IO(Asynchronous I/O)
Linux下的asynchronous IO其实用得很少,从内核2.6版本才开始引入。先看一下它的流程:

用户进程发起read操做以后,马上就能够开始去作其它的事。而另外一方面,从kernel的角度,当它受到一个asynchronous read以后,首先它会马上返回,因此不会对用户进程产生任何block。而后,kernel会等待数据准备完成,而后将数据拷贝到用户内存,当这一切都完成以后(wait data和copy data都完成后),kernel会给用户进程发送一个signal,告诉它read操做完成了。
异步IO具体实如今后面爬虫项目中讲解。
6、IO模型比较分析
一、blocking和non-blocking的区别在哪?
调用blocking IO会一直block住对应的进程直到操做完成,而non-blocking IO在kernel还准备数据的状况下会马上返回。
二、synchronous IO(同步IO)和asynchronous IO(异步IO)的定义和区别?
Stevens给出的定义(实际上是POSIX的定义)是这样子的: A synchronous I/O operation causes the requesting process to be blocked until that I/O operationcompletes; An asynchronous I/O operation does not cause the requesting process to be blocked;
二者的区别就在于synchronous IO作”IO operation”的时候会将process阻塞。按照这个定义,四个IO模型能够分为两大类, 以前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO这一类,而 asynchronous I/O后一类 。
三、non-blocking IO和asynchronous IO的区别?
有人可能会说,non-blocking IO并无被block啊。这里有个很是“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操做, 就是例子中的recvfrom这个system call。
non-blocking IO在执行recvfrom这个system call的时候,若是kernel的数据没有准备好, 这时候不会block进程。可是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了, 在这段时间内,进程是被block的。
而asynchronous IO则不同,当进程发起IO 操做以后,就直接返回不再理睬了,直到kernel发送一个信号, 告诉进程说IO完成。在这整个过程当中,进程彻底没有被block。
四、各个IO 模型比较如图所示:

通过上面的介绍,会发现non-blocking IO和asynchronous IO的区别仍是很明显的。在non-blocking IO中,虽然进程大部分时间都不会被block,可是它仍然要求进程去主动的check,而且当数据准备完成之后,也须要进程主动的再次调用recvfrom来将数据拷贝到用户内存。而asynchronous IO则彻底不一样。它就像是用户进程将整个IO操做交给了他人(kernel)完成,而后他人作完后发信号通知。在此期间,用户进程不须要去检查IO操做的状态,也不须要主动的去拷贝数据。