io介绍
为了更好地了解IO模型,我们需要事先回顾下:同步、异步、阻塞、非阻塞
同步与异步针对的是函数/任务的调用方式:同步就是当一个进程发起一个函数(任务)调用的时候,一直等到函数(任务)完成,而进程继续处于激活状态。而异步情况下是当一个进程发起一个函数(任务)调用的时候,不会等函数返回,而是继续往下执行当,函数返回的时候通过状态、通知、事件等方式通知进程任务完成。
阻塞与非阻塞针对的是进程或线程:阻塞是当请求不能满足的时候就将进程挂起,而非阻塞则不会阻塞当前进程
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(blocking IO)
当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据。对于network io来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候kernel就要等待足够的数据到来。
而在用户进程这边,整个进程会被阻塞。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。
所以,blocking IO的特点就是在IO执行的两个阶段(等待数据和拷贝数据两个阶段)都被block了。
ps:所谓阻塞型接口是指系统调用(一般是IO接口)不返回调用结果并让当前线程一直阻塞,只有当该系统调用获得结果或者超时出错时才返回。
我们来看下ftp的一个阻塞io的例子
服务端:
from socket import *
server = socket(AF_INET, SOCK_STREAM)
server.bind(('127.0.0.1',8081))
server.listen(5)
while True:
conn, addr = server.accept()
print(addr)
while True:
try:
data=conn.recv(1024)
if not data:break
conn.send(data.upper())
except ConnectionResetError:
print("ConnectionResetError")
break
conn.close()
server.close()
客户端:
from socket import *
client = socket(AF_INET, SOCK_STREAM)
client.connect(('127.0.0.1',8081))
while True:
msg=input('>>: ').strip()
if not msg:continue
client.send(msg.encode('utf-8'))
data=client.recv(1024)
print(data.decode('utf-8'))
一个简单的解决方案:
在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。
该方案的问题是:
开启多进程或都线程的方式,在遇到要同时响应成百上千路的连接请求,则无论多线程还是多进程都会严重占据系统资源,降低系统对外界响应效率,而且线程与进程本身也更容易进入假死状态。
改进方案:
很多程序员可能会考虑使用“线程池”或“连接池”。“线程池”旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。“连接池”维持连接的缓存池,尽量重用已有的连接、减少创建和关闭连接的频率。这两种技术都可以很好的降低系统开销,都被广泛应用很多大型系统,如websphere、tomcat和各种数据库等。
改进后方案其实也存在着问题:
“线程池”和“连接池”技术也只是在一定程度上缓解了频繁调用IO接口带来的资源占用。而且,所谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。
非阻塞IO(non-blocking IO)
从图中可以看出,当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是用户就可以在本次到下次再发起read询问的时间间隔内做其他事情,或者直接再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存(这一阶段仍然是阻塞的),然后返回。
也就是说非阻塞的recvform系统调用调用之后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好,此时会返回一个error。进程在返回之后,可以干点别的事情,然后再发起recvform系统调用。重复上面的过程,循环往复的进行recvform系统调用。这个过程通常被称之为轮询。轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态
所以,在非阻塞式IO中,用户进程其实是需要不断的主动询问kernel数据准备好了没有
服务端:
#1 对cpu的占用率过多,但是是无用的占用
#2 在连接数过多的情况下,不能及时响应客户的的消息
from socket import *
import time
server = socket(AF_INET, SOCK_STREAM)
server.bind(('127.0.0.1',8083))
server.listen(5)
server.setblocking(False)
conn_l=[]
while True:
try:
conn, addr = server.accept()
conn_l.append(conn)
print(addr)
except BlockingIOError:
print('干其他活去了',len(conn_l))
del_l = []
for conn in conn_l:
try:
data=conn.recv(1024)
if not data:
conn.close()
del_l.append(conn)
continue
conn.send(data.upper())
except BlockingIOError:
pass
except ConnectionResetError:
conn.close()
del_l.append(conn)
for conn in del_l:
conn_l.remove(conn)
客户端:
from socket import *
client = socket(AF_INET, SOCK_STREAM)
client.connect(('127.0.0.1',8083))
while True:
msg=input('>>: ').strip()
if not msg:continue
client.send(msg.encode('utf-8'))
data=client.recv(1024)
print(data.decode('utf-8'))
多路复用IO(IO multiplexing)
它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程
当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
这个图和blocking IO的图其实并没有太大的不同,事实上还更差一些。因为这里需要使用两个系统调用(select和recvfrom),而blocking IO只调用了一个系统调用(recvfrom)。但是,用select的优势在于它可以同时处理多个connection。
如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
- 在多路复用模型中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。
结论: select的优势在于可以处理多个连接,不适用于单个连接
我们看一个例子
客户端:
from socket import *
client = socket(AF_INET, SOCK_STREAM)
client.connect(('127.0.0.1',8084))
while True:
msg=input('>>: ').strip()
if not msg:continue
client.send(msg.encode('utf-8'))
data=client.recv(1024)
print(data.decode('utf-8'))
服务端:
from socket import *
import time
import select
server = socket(AF_INET, SOCK_STREAM)
server.bind(('127.0.0.1',8084))
server.listen(5)
server.setblocking(False)
read_l=[server]
print('starting....')
while True:
#rlist --等到准备阅读
#wlist --等到准备写作
#xList --等待` `例外条件”
rl,wl,xl=select.select(read_l,[],[]) #read_l=[server,conn1,conn2,conn3]
#rl=[server,conn2]
# print('===>',rl)
# print(rl[0] is server)
for r in rl:
if r is server:
conn,addr=rl[0].accept() #conn,addr=server.accpet()
print(addr)
read_l.append(conn)
# print(len(read_l))
else:
try:
data=r.recv(1024)
if not data:
r.close()
read_l.remove(r)
continue
r.send(data.upper())
except ConnectionResetError:
r.close()
read_l.remove(r)
上面代码的意思是:select.select
select函数阻塞程序运行,监控read_l中的套接字,当其中有套接字满足可读的条件(第一个参数为可读,如果是第二个参数则为可写),则把这个套接字返回给rl,然后程序继续运行。
至于套接字怎么才算可读呢?搜索可知,当套接字缓冲区大于1byte时,就被标记为可读。也就是说,当套接字收到客户端发来的数据,就变成可读,然后select就会把这个套接字取出来,进入下一步程序。
我们通过调试来看下输出日志信息
1、启动服务端
2、启动客户端1
3、启动客户端2
4、在客户端1输入信息
5、在客户端2输入信息
以下是服务端的输出(我简化了):
#启动服务端、客户端1
starting....
read_l [<socket.socket laddr=('127.0.0.1', 8084)>]
True ,rl: [<socket.socket laddr=('127.0.0.1', 8084)>]
<socket.socket laddr=('127.0.0.1', 8084), raddr=('127.0.0.1', 56868)>
#启动客户端2
read_l [<socket.socket laddr=('127.0.0.1', 8084)>, <socket.socket laddr=('127.0.0.1', 8084), raddr=('127.0.0.1', 56868)>]
True ,rl: [<socket.socket laddr=('127.0.0.1', 8084)>]
<socket.socket laddr=('127.0.0.1', 8084), raddr=('127.0.0.1', 56870)>
#客户端1发送消息
read_l [<socket.socket laddr=('127.0.0.1', 8084)>, <socket.socket laddr=('127.0.0.1', 8084), raddr=('127.0.0.1', 56868)>, <socket.socket laddr=('127.0.0.1', 8084), raddr=('127.0.0.1', 56870)>]
False ,rl: [<socket.socket laddr=('127.0.0.1', 8084), raddr=('127.0.0.1', 56868)>]
#客户端2发送消息
read_l [<socket.socket laddr=('127.0.0.1', 8084)>, <socket.socket
laddr=('127.0.0.1', 8084), raddr=('127.0.0.1', 56868)>, <socket.socket laddr=('127.0.0.1', 8084), raddr=('127.0.0.1', 56870)>]
False ,rl: [<socket.socket laddr=('127.0.0.1', 8084), raddr=('127.0.0.1', 56870)>]
所以列表初始状态要监测server套接字,监测的是具有阻塞行为的,通过上述的输出就能了解其流程了