# 53. 协程、Greenlet模块、Gevent模块
# 协程
之前我们学习了线程、进程的概念,了解了在操作系统中进程是资源分配的最小单位,线程是CPU调度的最小单位。
按道理来说我们已经算是把cpu的利用率提高很多了。
但是我们知道无论是创建多进程还是创建多线程来解决问题,都要消耗一定的时间来创建进程、创建线程、以及管理他们之间的切换。
随着我们对于效率的追求不断提高,基于单线程来实现并发又成为一个新的课题,即只用一个主线程(很明显可利用的cpu只有一个)情况下实现并发。
这样就可以节省创建线进程所消耗的时间。
为此我们需要先回顾下并发的本质:切换+保存状态
cpu正在运行一个任务,会在两种情况下切走去执行其他的任务(切换由操作系统强制控制),一种情况是该任务发生了阻塞,另外一种情况是该任务计算的时间过长
协程:是单线程下的并发,又称微线程,纤程。英文名Coroutine。一句话说明什么是线程:协程是一种用户态的轻量级线程,即协程是由用户程序自己控制调度的。
- python的线程属于内核级别的,即由操作系统控制调度(如单线程遇到io或执行时间过长就会被迫交出cpu执行权限,切换其他线程运行)
- 单线程内开启协程,一旦遇到io,就会从应用程序级别(而非操作系统)控制切换,以此来提升效率(!!!非io操作的切换与效率无关)
# 通过yield来验证程序的运行保存状态
为此我们可以基于yield来验证。yield本身就是一种在单线程下可以保存任务运行状态的方法,我们来简单复习一下:
import time
def conn():
while 1:
n = yield
print('干掉了%s'%n)
def pro():
n = conn()
next(n)
for i in range(10000):
print('生产了%s' % i)
n.send(i)
start = time.time()
pro()
print(time.time() - start)
执行结果:
生产了9999
干掉了9999
0.176527738571167
# yield可以保存状态,yield的状态保存与操作系统的保存线程状态很像,但是yield是代码级别控制的,更轻量级
# send可以把一个函数的结果传给另外一个函数,以此实现单线程内程序之间的切换
# 在单线程中,如果存在多个函数,如果有某个函数发生IO操作,我想让程序马上切换到另一个函数去执行
# 以此来实现一个假的并发现象。
# yield实验总结
总结:
- yield 只能实现单纯的切换函数和保存函数状态的功能
- 不能实现:当某一个函数遇到io阻塞时,自动的切换到另一个函数去执行
- 目标是:当某一个函数中遇到IO阻塞时,程序能自动的切换到另一个函数去执行
- 如果能实现这个功能,那么每个函数都是一个协程
- 但是 协程的本质还是主要依靠于yield去实现的。
- 如果只是拿yield去单纯的实现一个切换的现象,你会发现,跟本没有程序串行执行效率高
- 就算使用了yield来简单实现了IO切换跟保存任务状态,但是这种切换也需要时间
注意:yield是无法做到IO阻塞时自动切换的
# 串行执行比上一个实验速度更快
import time
def pro():
l = []
for i in range(10000):
l.append(i)
return l
def consumer(l):
for i in l:
print(i)
start = time.time()
l = pro()
consumer(l)
print(time.time() - start)
执行结果:
9998
9999
0.04886507987976074
# 为什么要有协程?
因为想要在单线程内实现并发的效果:
因为CPthon有GIL锁,限制了在同一个时间点,只能执行一个线程
所以想要在执行一个线程的期间,充分的利用CPU的性能
所以才有了想在单线程内实现并发的效果。
- 并发:切换+保存状态
- cpu是为什么要切换?
- 因为某个程序阻塞了
- 因为某个程序用完了时间片
- cpu是为什么要切换?
- 并发:切换+保存状态
很明显 解决GIL锁这个问题才能提高效率
所以想要实现单线程的并发,就要解决在单线程内,多个任务函数中,某个任务函数遇见IO操作,马上自动切换到其他任务函数去执行。
# 协程总结
对于单线程下,我们不可避免程序中出现io操作,但如果我们能在自己的程序中(即用户程序级别,而非操作系统级别)
控制单线程下的多个任务能在一个任务遇到io阻塞时就切换到另外一个任务去计算,这样就保证了该线程能够最大限度地处于就绪态,即随时都可以被cpu执行的状态,相当于我们在用户程序级别将自己的io操作最大限度地隐藏起来,从而可以迷惑操作系统,
让其看到:该线程好像是一直在计算,io比较少,从而更多的将cpu的执行权限分配给我们的线程。
协程的本质就是在单线程下,由用户自己控制一个任务遇到io阻塞了就切换另外一个任务去执行,以此来提升效率。为了实现它,我们需要找寻一种可以同时满足以下条件的解决方案:
- 可以控制多个任务之间的切换,切换之前将任务的状态保存下来,以便重新运行时,可以基于暂停的位置继续执行。
- 作为1的补充:可以检测io操作,在遇到io操作的情况下才发生切换
对比操作系统控制线程的切换,用户在单线程内控制协程的切换,优点如下:
- 协程的切换开销更小,属于程序级别的切换,操作系统完全感知不到,因而更加轻量级
- 单线程内就可以实现并发的效果,最大限度地利用cpu
总结协程特点:
- 必须在只有一个单线程里实现并发
- 修改共享数据不需加锁
- 用户程序里自己保存多个控制流的上下文栈
- 附加:一个协程遇到IO操作自动切换到其它协程(如何实现检测IO,yield、greenlet都无法实现,就用到了gevent模块(select机制))
- 协程:是一个比线程更加轻量级的单位,是组成线程的各个函数,协程本身没有实体
# Greenlet模块 - 函数跟函数之间切换
能简单的实现函数与函数之间的切换,但是遇到IO操作,不能自动切换到其他函数中
需要安装 :greenlet模块
# Greenlet的简单功能介绍
from greenlet import greenlet
f1 = greenlet(func) ## 注册一下函数func,将函数注册成一个对象f1
f1.switch() ## 调用func,如果func需要传参,就在switch这里传即可
# Greenlet的简单实验
from greenlet import greenlet
import time
def eat(name):
print('%s吃炸鸡'%name)
time.sleep(2)
f2.switch('小雪2') ## 当执行到这行,就切换到drink函数去,并传一个参数
print('%s吃雪糕'%name)
f2.switch() ## 当执行到这行,就切换到drink函数去
def drink(name):
print('%s喝啤酒'%name)
f1.switch() ## 当执行到这行,就切换到eat函数去
print('%s喝可乐'%name)
f1 = greenlet(eat) ## 实验化对象
f2 = greenlet(drink) ## 实验化对象
f1.switch('小雪')
执行结果:
小雪吃炸鸡
小雪2喝啤酒
小雪吃雪糕
小雪2喝可乐
# greenlet 只是可以实现一个简单的切换功能,还是不能做到遇到IO就切换
# g1 = greenlet(func) 实例化一个对象
# g1.switch() 用这种方式去调用func函数
# 当使用switch调用func的时候,什么时候func会停止运行?
# 1 要么return 2 要么在func内部又遇到 switch
但是如果遇到IO阻塞时无法自动切换到另一个程度去,让另一个程序去执行
# Gevent模块
可以实现在某函数内部遇到IO操作,就自动的切换到其他函数内部去执行
需要安装:gevent模块
注意:gevent不能识别其他的IO操作,只能识别自己认识的IO,自己的IO:gevent.sleep(1)
# Gevent的简单功能介绍
g = gevent.spawn(func,参数) ## 注册一下函数func,返回一个对象g
gevent.join(g) ## 等待g指向的函数func执行完毕,如果在执行过程中,遇到IO,就切换
gevent.joinall([g1,g2,g3]) ## 等待g1 g2 g3指向的函数func执行完毕,需要一个列表类型,里面放置需要等待的对象
gevent.sleep(1) ## 暂停1秒,gevent内部的状态阻塞器
# 实验 - 遇到IO操作时1
遇到gevent自带的IO操作时会自动切换
import gevent
def func1():
print('1 2 3 4')
gevent.sleep(1)
print('3 2 3 4')
def func2():
print('2 2 3 4')
gevent.sleep(1)
print('再来一次')
g1 = gevent.spawn(func1)
g2 = gevent.spawn(func2)
g1.join()# 等待g1指向的任务执行结束
g2.join()# 等待g2指向的任务执行结
执行结果:
1 2 3 4
2 2 3 4
3 2 3 4
再来一次
# 实验 - 遇到IO操作时2
遇到不是gevent自带的IO操作时不会自动切换
import gevent
import time
def func1():
print('1 2 3 4')
time.sleep(1) # gevent不能识别其他的IO操作,只能识别自己认识的IO
print('3 2 3 4')
def func2():
print('2 2 3 4')
time.sleep(1) # gevent不能识别其他的IO操作,只能识别自己认识的IO
print('再来一次')
g1 = gevent.spawn(func1)
g2 = gevent.spawn(func2)
g1.join()# 等待g1指向的任务执行结
g2.join()# 等待g2指向的任务执行结
执行结果:
1 2 3 4
3 2 3 4
2 2 3 4
再来一次
## 通过执行结果就能看出,执行的顺序是,先执行func1函数后在执行func2函数
# 实验 - 使用patch_all()方法能识别另种IO操作
使用patch_all()方法能识别大部分的IO操作
from gevent import monkey
monkey.patch_all() # 可以让gevent识别大部分常用的IO操作
import time
def func1():
print('1 2 3 4')
time.sleep(1)
print('3 2 3 4')
def func2():
print('2 2 3 4')
time.sleep(1)
print('再来一次')
g1 = gevent.spawn(func1)
g2 = gevent.spawn(func2)
g1.join() # 等待g1指向的任务执行结束
g2.join() # 等待g2指向的任务执行结
执行结果:
1 2 3 4
2 2 3 4
3 2 3 4
再来一次
# 实验 - 爬虫示例 - 串行跟协程的区别
串行 == 同步
协程 == 异步
from gevent import monkey
monkey.patch_all()
import gevent
import time
import requests
def get_result(url):# 任务函数
res = requests.get(url)
print(url,res.status_code,len(res.text))
url_l = ['http://www.baidu.com',
'https://www.jd.com',
'http://www.apache.com',
'http://www.taobao.com',
'http://www.qq.com',
'http://www.mi.com',
'http://www.cnblogs.com']
def sync_func(url_l):
'''同步调用'''
for url in url_l:# 串行执行任务函数
get_result(url)
def async_func(url_l):
'''异步'''
l = []
for url in url_l:
g = gevent.spawn(get_result,url)# 使用gevent,协程去并发实现执行任务函数
# 当遇见请求某个网页发生比较大的网络延迟(IO),马上会切换到其他的任务函数
l.append(g)
gevent.joinall(l)# 等待所有任务函数执行结束
start = time.time()
sync_func(url_l)
print('串行:',time.time() - start)
start = time.time()
async_func(url_l)
print('协程:',time.time() - start)
执行结果:
http://www.baidu.com 200 2381
https://www.jd.com 200 103519
http://www.apache.com 403 27610
http://www.taobao.com 200 138584
http://www.qq.com 200 221984
http://www.mi.com 200 277187
http://www.cnblogs.com 200 42823
串行: 2.7305195331573486
http://www.baidu.com 200 2381
https://www.jd.com 200 103519
http://www.qq.com 200 221984
http://www.taobao.com 200 138616
http://www.mi.com 200 277187
http://www.cnblogs.com 200 42823
http://www.apache.com 403 27610
协程: 1.8869080543518066