python 协程、I/O模型

一、引子 (超哥协程

  并发本质:保存状态+切换

  cpu正在运行一个任务,转而执行另一个任务的情概况:1、是该任务发生了阻塞;2、该任务计算的时间过长或有一个优先级更高的程序替代了它。

  协程本质上就是一个线程,使用代码来控制任务的切换。以前线程任务的切换是由操作系统控制的,遇到I/O自动切换,现在我们用协程的目的就是较少操作系统切换的开销(开关线程,创建寄存器、堆栈等,在他们之间进行切换等),在我们自己的程序里面来控制任务的切换。

  python 协程、I/O模型

  ps:在介绍进程理论时,提及进程的三种执行状态,而线程才是执行单位,所以也可以将上图理解为线程的三种状态   

  一:其中第二种情况并不能提升效率,只是为了让cpu能够雨露均沾,实现看起来所有任务都被“同时”执行的效果,如果多个任务都是纯计算的,这种切换反而会降低效率。

二、yield 模拟单线程之间的任务切换过程

#1 yiled可以保存状态,yield的状态保存与操作系统的保存线程状态很像,但是yield是代码级别控制的,更轻量级
#2 send可以把一个函数的结果传给另外一个函数,以此实现单线程内程序之间的切换
import time

def jishi1hao():
for i in range(6):
print("快快来~~~~")
yield #yield 可以记录任务的执行状态
time.sleep(1)
print("%s号客户2s搞定"%i) def jishi2hao():
g=jishi1hao() #获取到生成器
g.__next__() #执行第一段函数,到第一个yield结束
for i in range(5):
time.sleep(1)
print("%s号技师正在忙!!!"%i)
g.__next__() #继续到下一个yield jishi2hao()
#使用yield可以实现程序的切换执行,但是不能提高效率,只是简单的程序分段穿插运行

通过yield实现任务切换+保存现场

  注意:yield可以实现任务的交替执行,但是不能提高程序的运行效率,反而降低了运行效率(切换过程需要时间)。

#基于yield并发执行,多任务之间来回切换,这就是个简单的协程的体现,但是他能够节省I/O时间吗?不能
import time
def consumer():
'''任务1:接收数据,处理数据'''
while True:
x=yield
time.sleep(1) #发现什么?只是进行了切换,但是并没有节省I/O时间
print('处理了数据:',x)
def producer():
'''任务2:生产数据'''
g=consumer()
next(g) #找到了consumer函数的yield位置
for i in range(3):
g.send(i) #给yield传值,然后再循环给下一个yield传值,并且多了切换的程序,比直接串行执行还多了一些步骤,导致执行效率反而更低了。
print('发送了数据:',i)
start=time.time()
#基于yield保存状态,实现两个任务直接来回切换,即并发的效果
#PS:如果每个任务中都加上打印,那么明显地看到两个任务的打印是你一次我一次,即并发执行的.
producer() #我在当前线程中只执行了这个函数,但是通过这个函数里面的send切换了另外一个任务
stop=time.time() # 串行执行的方式
s_t=time.time()
res=producer()
consumer()
e_t=time.time()
print("yield的时间>>>",stop-start)
print("串行的时间>>>",e_t-s_t) #结果显示
#yield的时间>>> 3.0015313625335693
#串行的时间>>> 3.0011236667633057

单纯的切换反而会降低运行速度

  对于单线程下,我们不可避免程序中出现io操作,但如果我们能在自己的程序中(即用户程序级别,而非操作系统级别)控制单线程下的多个任务能在一个任务遇到io阻塞时就切换到另外一个任务去计算,这样就保证了该线程能够最大限度地处于就绪态,即随时都可以被cpu执行的状态,相当于我们在用户程序级别将自己的io操作最大限度地隐藏起来,从而可以迷惑操作系统,让其看到:该线程好像是一直在计算,io比较少,从而更多的将cpu的执行权限分配给我们的线程。

  协程的本质就是在单线程下,由用户自己控制一个任务遇到io阻塞了就切换另外一个任务去执行,以此来提升效率。为了实现它,我们需要找寻一种可以同时满足以下条件的解决方案:

#1、可以检测io操作,在遇到io操作的情况下才发生切换
#2. 可以控制多个任务之间的切换,切换之前将任务的状态保存下来,以便重新运行时,可以基于暂停的位置继续执行。

三、协程介绍

  协程:是单线程下的并发,又称微线程,纤程。英文名Coroutine。一句话说明什么是线程:协程是一种用户态的轻量级线程,即协程是由用户程序自己控制调度的。

  需要强调的是:

#1. python的线程属于内核级别的,即由操作系统控制调度(如单线程遇到io或执行时间过长就会*交出cpu执行权限,切换其他线程运行)
#2. 单线程内开启协程,一旦遇到io,就会从应用程序级别(而非操作系统)控制切换,以此来提升效率(!!!非io操作的切换与效率无关)

  协程优点:

#1. 协程的切换开销更小,属于程序级别的切换,操作系统完全感知不到,因而更加轻量级
#2. 单线程内就可以实现并发的效果,最大限度地利用cpu

  协程缺点

#1. 协程的本质是单线程下,无法利用多核,可以是一个程序开启多个进程,每个进程内开启多个线程,每个线程内开启协程
#2. 协程指的是单个线程,因而一旦协程出现阻塞,将会阻塞整个线程

  

总结协程特点:

  1. 必须在只有一个单线程里实现并发
  2. 修改共享数据不需加锁
  3. 用户程序里自己保存多个控制流的上下文栈
  4. 附加:一个协程遇到IO操作自动切换到其它协程(如何实现检测IO,yield、greenlet都无法实现,就用到了gevent模块(select机制)

四、Greenlet

  

  如果我们在单个线程内有20个任务,要想实现在多个任务之间切换,使用yield生成器的方式过于麻烦(需要先得到初始化一次的生成器,然后再调用send。。。非常麻烦),而使用greenlet模块可以非常简单地实现这20个任务直接的切换

  #真正的协程模块就是使用greenlet完成的切换
from greenlet import greenlet def eat(name):
print('%s eat 1' %name) #
g2.switch('taibai') #
print('%s eat 2' %name) #
g2.switch() #
def play(name):
print('%s play 1' %name) #
g1.switch() #
print('%s play 2' %name) # g1=greenlet(eat)
g2=greenlet(play) g1.switch('taibai')#可以在第一次switch时传入参数,以后都不需要 1

greenlet实现协程(不能提高效率)

  单纯的切换(在没有io的情况下或者没有重复开辟内存空间的操作),反而会降低程序的执行速度。

  greenlet只是提供了一种比generator更加便捷的切换方式,当切到一个任务执行时如果遇到io,那就原地阻塞,仍然是没有解决遇到IO自动切换来提升效率的问题。

 

五、Gevent介绍

  Gevent 是一个第三方库,可以轻松通过gevent实现并发同步或异步编程,在gevent中用到的主要模式是Greenlet, 它是以C扩展模块形式接入Python的轻量级协程。 Greenlet全部运行在主程序操作系统进程的内部,但它们被协作式地调度。

  

#用法
g1=gevent.spawn(func,1,2,3,x=4,y=5)创建一个协程对象g1,spawn括号内第一个参数是函数名,如eat,后面可以有多个参数,可以是位置实参或关键字实参,都是传给函数eat的,spawn是异步提交任务 g2=gevent.spawn(func2) g1.join() #等待g1结束,上面只是创建协程对象,这个join才是去执行 g2.join() #等待g2结束 有人测试的时候会发现,不写第二个join也能执行g2,是的,协程帮你切换执行了,但是你会发现,如果g2里面的任务执行的时间长,但是不写join的话,就不会执行完等到g2剩下的任务了 gevent.joinall([g1,g2]) #等待列表中的所有任务执行完毕 g1.value#拿到func1的返回值
import gevent
import time def func1(i):
print("func1开始")
gevent.sleep(2)
print("1111>>>",i) def func2(i):
print("func2开始")
gevent.sleep(2)
print("2222>>>", i) if __name__ == '__main__':
s_t=time.time()
g1=gevent.spawn(func1,"a")
g2=gevent.spawn(func2,"b")
g1.join()
g2.join()
e_t=time.time()
print("gevent耗时>>>",e_t-s_t)
print("主任务结束")

gevent方法使用和时间测试

 

  from gevent import monkey;monkey.patch_all( )必须放在文件开头,表示拾取文件中的所有的I/O操作。

from gevent import monkey;monkey.patch_all() #必须写在最上面,这句话后面的所有阻塞全部能够识别了

import gevent  #直接导入即可
import time
def eat():
#print()  
print('eat food 1')
time.sleep(2) #加上mokey就能够识别到time模块的sleep了
print('eat food 2') def play():
print('play 1')
time.sleep(1) #来回切换,直到一个I/O的时间结束,这里都是我们个gevent做得,不再是控制不了的操作系统了。
print('play 2') g1=gevent.spawn(eat)
g2=gevent.spawn(play_phone)
gevent.joinall([g1,g2])
print('主')

monkey使用示例

  gevent中的同步与异步效率对比

from gevent import spawn,joinall,monkey;monkey.patch_all()

import time
def task(pid):
"""
Some non-deterministic task
"""
time.sleep(0.5)
print('Task %s done' % pid) def synchronous():#同步提交任务,串行,一次出来一个
for i in range(10):
task(i) def asynchronous():#异步提交任务
g_l=[spawn(task,i) for i in range(10)]
joinall(g_l) if __name__ == '__main__':
print('Synchronous:')
synchronous() print('Asynchronous:')
asynchronous()

gevent 同步和异步

六、协程的应用

  爬虫

from gevent import monkey;monkey.patch_all()
import gevent
import requests
import time def get_page(url):
print('GET: %s' %url)
response=requests.get(url)
if response.status_code == 200:
print('%d bytes received from %s' %(len(response.text),url))
if __name__ == '__main__':

    start_time=time.time()
gevent.joinall([
gevent.spawn(get_page,'https://www.zhihu.com/'),
gevent.spawn(get_page,'https://www.yahoo.com/'),
gevent.spawn(get_page,'https://github.com/'),
])
stop_time=time.time()
print('run time is %s' %(stop_time-start_time))
 

七、I/O模型简介(超哥IO模型

  Stevens在文章中一共比较了五种IO Model:
  * blocking IO          阻塞IO
  * nonblocking IO    非阻塞IO
  * IO multiplexing    IO多路复用
  * signal driven IO   信号驱动IO(不常见,不讲)
  * asynchronous IO 异步IO

  再说一下IO发生时涉及的对象和步骤。对于一个network IO (这里我们以read、recv举例),它会涉及到两个系统对象,一个是调用这个IO的process (or thread),另一个就是系统内核(kernel)。当一个read/recv读数据的操作发生时,该操作会经历两个阶段:

#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,与输出操作类似

网络常见的阻塞函数

  (1)、阻塞I/O(blocking IO)

python 协程、I/O模型

  上图分析:两个阻塞阶段

python 协程、I/O模型

  

  当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据。对于network io来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候kernel就要等待足够的数据到来。

  而在用户进程这边,整个进程会被阻塞。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。

  python 协程、I/O模型

  (2)非阻塞 IO (设置socket 变成non-blocking)

python 协程、I/O模型

  从图中可以看出,当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是用户就可以在本次到下次再发起read询问的时间间隔内做其他事情,或者直接再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存(这一阶段仍然是阻塞的),然后返回。

  也就是说非阻塞的recvform系统调用调用之后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好,此时会返回一个error。进程在返回之后,可以干点别的事情,然后再发起recvform系统调用。重复上面的过程,循环往复的进行recvform系统调用。这个过程通常被称之为轮询。轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态。

  所以,在非阻塞式IO中,用户进程其实是需要不断的主动询问kernel数据准备好了没有。

# 服务端
import socket
import time server=socket.socket()
server.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
server.bind(('127.0.0.1',8083))
server.listen(5) server.setblocking(False) #设置不阻塞
r_list=[] #用来存储所有来请求server端的conn连接
w_list={} #用来存储所有已经有了请求数据的conn的请求数据 while 1:
try:
conn,addr=server.accept() #不阻塞,会报错
r_list.append(conn) #为了将连接保存起来,不然下次循环的时候,上一次的连接就没有了
except BlockingIOError:
# 强调强调强调:!!!非阻塞IO的精髓在于完全没有阻塞!!!
# time.sleep(0.5) # 打开该行注释纯属为了方便查看效果
print('在做其他的事情')
print('rlist: ',len(r_list))
print('wlist: ',len(w_list)) # 遍历读列表,依次取出套接字读取内容
del_rlist=[] #用来存储删除的conn连接
for conn in r_list:
try:
data=conn.recv(1024) #不阻塞,会报错
if not data: #当一个客户端暴力关闭的时候,会一直接收b'',别忘了判断一下数据
conn.close()
del_rlist.append(conn)
continue
w_list[conn]=data.upper()
except BlockingIOError: # 没有收成功,则继续检索下一个套接字的接收
continue
except ConnectionResetError: # 当前套接字出异常,则关闭,然后加入删除列表,等待被清除
conn.close()
del_rlist.append(conn) # 遍历写列表,依次取出套接字发送内容
del_wlist=[]
for conn,data in w_list.items():
try:
conn.send(data)
del_wlist.append(conn)
except BlockingIOError:
continue # 清理无用的套接字,无需再监听它们的IO操作
for conn in del_rlist:
r_list.remove(conn)
#del_rlist.clear() #清空列表中保存的已经删除的内容
for conn in del_wlist:
w_list.pop(conn)
#del_wlist.clear() #客户端
import socket
import os
import time
import threading
client=socket.socket()
client.connect(('127.0.0.1',8083)) while 1:
res=('%s hello' %os.getpid()).encode('utf-8')
client.send(res)
data=client.recv(1024) print(data.decode('utf-8')) ##多线程的客户端请求版本
# def func():
# sk = socket.socket()
# sk.connect(('127.0.0.1',9000))
# sk.send(b'hello')
# time.sleep(1)
# print(sk.recv(1024))
# sk.close()
#
# for i in range(20):
# threading.Thread(target=func).start()

非阻塞IO示例

  虽然我们上面的代码通过设置非阻塞,规避了IO操作,但是非阻塞IO模型绝不被推荐。

  我们不能否定其优点:能够在等待任务完成的时间里干其他活了(包括提交其他任务,也就是 “后台” 可以有多个任务在“”同时“”执行)。

  (3)多路复用IO(IO multiplexing) (重点)

    多路复用:采用了代理(select)模式,把所有的需要监控的对象传递给selct监控,select监控到了被监控对象有动作,就返回,执行相应的任务。

  IO multiplexing这个词可能有点陌生,但是如果我说select/epoll,大概就都能明白了。有些地方也称这种IO方式为事件驱动IO(event driven IO)。我们都知道,select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。它的流程如图:

python 协程、I/O模型

python 协程、I/O模型

  当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。

   python中的select模块:

import select

fd_r_list, fd_w_list, fd_e_list = select.select(rlist, wlist, xlist, [timeout])

参数: 可接受四个参数(前三个必须)
rlist: wait until ready for reading #等待读的对象,你需要监听的需要获取数据的对象列表
wlist: wait until ready for writing #等待写的对象,你需要写一些内容的时候,input等等,也就是说我会循环他看看是否有需要发送的消息,如果有我取出这个对象的消息并发送出去,一般用不到,这里我们也给一个[]。
xlist: wait for an “exceptional condition” #等待异常的对象,一些额外的情况,一般用不到,但是必须传,那么我们就给他一个[]。
timeout: 超时时间
当超时时间 = n(正整数)时,那么如果监听的句柄均无任何变化,则select会阻塞n秒,之后返回三个空列表,如果监听的句柄有变化,则直接执行。
返回值:三个列表与上面的三个参数列表是对应的
  select方法用来监视文件描述符(当文件描述符条件不满足时,select会阻塞),当某个文件描述符状态改变后,会返回三个列表
1、当参数1 序列中的fd满足“可读”条件时,则获取发生变化的fd并添加到fd_r_list中
2、当参数2 序列中含有fd时,则将该序列中所有的fd添加到 fd_w_list中
3、当参数3 序列中的fd发生错误时,则将该发生错误的fd添加到 fd_e_list中
4、当超时时间为空,则select会一直阻塞,直到监听的句柄发生变化

  结论: select的优势在于可以处理多个连接,不适用于单个连接

#服务端
from socket import *
import select
server = socket(AF_INET, SOCK_STREAM)
server.bind(('127.0.0.1',8093))
server.listen(5)
# 设置为非阻塞
server.setblocking(False) # 初始化将服务端socket对象加入监听列表,后面还要动态添加一些conn连接对象,当accept的时候sk就有感应,当recv的时候conn就有动静
rlist=[server,]
rdata = {} #存放客户端发送过来的消息 wlist=[] #等待写对象
wdata={} #存放要返回给客户端的消息 print('预备!监听!!!')
count = 0 #写着计数用的,为了看实验效果用的,没用
while True:
# 开始 select 监听,对rlist中的服务端server进行监听,select函数阻塞进程,直到rlist中的套接字被触发(在此例中,套接字接收到客户端发来的握手信号,从而变得可读,满足select函数的“可读”条件),被触发的(有动静的)套接字(服务器套接字)返回给了rl这个返回值里面;
rl,wl,xl=select.select(rlist,wlist,[],0.5)
print('%s 次数>>'%(count),wl)
count = count + 1
# 对rl进行循环判断是否有客户端连接进来,当有客户端连接进来时select将触发
for sock in rl:
# 判断当前触发的是不是socket对象, 当触发的对象是socket对象时,说明有新客户端accept连接进来了
if sock == server:
# 接收客户端的连接, 获取客户端对象和客户端地址信息
conn,addr=sock.accept()
#把新的客户端连接加入到监听列表中,当客户端的连接有接收消息的时候,select将被触发,会知道这个连接有动静,有消息,那么返回给rl这个返回值列表里面。
rlist.append(conn)
else:
# 由于客户端连接进来时socket接收客户端连接请求,将客户端连接加入到了监听列表中(rlist),客户端发送消息的时候这个连接将触发
# 所以判断是否是客户端连接对象触发
try:
data=sock.recv(1024)
#没有数据的时候,我们将这个连接关闭掉,并从监听列表中移除
if not data:
sock.close()
rlist.remove(sock)
continue
print("received {0} from client {1}".format(data.decode(), sock))
#将接受到的客户端的消息保存下来
rdata[sock] = data.decode() #将客户端连接对象和这个对象接收到的消息加工成返回消息,并添加到wdata这个字典里面
wdata[sock]=data.upper()
#需要给这个客户端回复消息的时候,我们将这个连接添加到wlist写监听列表中
wlist.append(sock)
#如果这个连接出错了,客户端暴力断开了(注意,我还没有接收他的消息,或者接收他的消息的过程中出错了)
except Exception:
#关闭这个连接
sock.close()
#在监听列表中将他移除,因为不管什么原因,它毕竟是断开了,没必要再监听它了
rlist.remove(sock)
# 如果现在没有客户端请求连接,也没有客户端发送消息时,开始对发送消息列表进行处理,是否需要发送消息
for sock in wl:
sock.send(wdata[sock])
wlist.remove(sock)
wdata.pop(sock) # #将一次select监听列表中有接收数据的conn对象所接收到的消息打印一下
# for k,v in rdata.items():
# print(k,'发来的消息是:',v)
# #清空接收到的消息
# rdata.clear() ---------------------------------------
#客户端
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()

select 网络IO模型代码

  

select做得事情和第二阶段的阻塞没有关系,就是从内核态将数据拷贝到用户态的阻塞,始终帮你做得监听的工作,帮你节省了一些第一阶段阻塞的时间。

  IO多路复用的机制:

    select机制: Windows、Linux

    poll机制    : Linux    #和lselect监听机制一样,但是对监听列表里面的数量没有限制,select默认限制是1024个,但是他们两个都是操作系统轮询每一个被监听的文件描述符(如果数量很大,其实效率不太好),看是否有可读操作。

    epoll机制  : Linux    #它的监听机制和上面两个不同,他给每一个监听的对象绑定了一个回调函数,你这个对象有消息,那么触发回调函数给用户,用户就进行系统调用来拷贝数据,并不是轮询监听所有的被监听对象,这样的效率高很多。

上一篇:Python+Selenium+PhantomJS下载JavaScript异步加载网页


下一篇:第一篇:初识ASP.NET控件开发_第三节:“生死有序”的控件生命周期