python:协程
一、生成器回顾
1、什么是生成器
在上一篇文章中,我们讲到了python中的强大工具——生成器,那么所谓生成器就是特殊的迭代器,并且生成器中包含了一定的算法逻辑,你需要什么数据,我们就使用生成器生成一些有规律的数据,这样就解决了多余数据占用内存资源的问题
2、如何创建生成器
方法一:
# 就是将我们的列表推导式中的[] 改为 (),那么一个简单的生成器就制作完成了,并且可以使用for遍历来
# 生成1-5的整数
my_generator = (i for i in range(1,6))
# 并且该生成器和列表推导式的输出是完全不同的
# 列表推导式输出是一个列表
# 而生成器输出的是一个generator生成器对象
方法二:
# 使用一个函数和 yield 关键字配合,创建一个生成器对象,那么只要一个函数中有yield关键字,那么他将不再是函数,而是一个生成器
# 比如说使用生成器生成斐波那契数列
def func(n):
i, a, b = 1, 0, 1
while i <= n:
a, b = b, a + b
yield a
i += 1
else:
return f"生成器超出生成范围"
if __name__ == '__main__':
generator = func(6)
# 正常迭代出生成器生成的数据
for item in generator:
print(item)
# 无限迭代生成的数据,当超出范围打印终止迭代的异常数据
try:
while True:
print(next(generator))
except StopIteration as s:
print(s.value)
3、那么为什么要在学习协程之前带领大家回顾生成器呢?
因为生成器和我们目前学习的多任务,并发的第三种方式——协程,有着一定的渊源,所以在学习协程之前,带领大家使用生成器来模拟一个协程并发的效果,这样对我们学习协程的时候有很大的帮助,那么使用生成器模拟并发,最重要的是yield关键字,那么来看一下吧
4、生成器模拟并发
import time
# 上传生成器
def upload():
while True:
# 延迟0.5秒
time.sleep(0.5)
print("开始上传")
# yield后不写默认返回None
yield
# 下载生成器
def download():
while True:
# 延迟0.5秒
time.sleep(0.5)
print("开始下载")
# yield后不写默认返回None
yield
if __name__ == '__main__':
up = upload()
down = download()
# 计算开始时间
start = time.time()
# 连续唤醒
for _ in range(5):
# 一次性唤醒
next(up)
down.send(None)
# 计算结束时间
end = time.time()
# 打印所用时间
print(f"所用时间:{'{:.0f}'.format(end-start)}秒")
总结:
这个过程实际上就是先让up运行,再让down运行,采用的是一次性唤醒的方式,让两个生成器同步执行,也就协调步调,挨个执行。为什么呢?因为每一次唤醒,到yield关键字就停止了(因为yield关键字会记住执行的位置,下一次再从这个地方执行),然后又开始执行下一个生成器,这样轮流交替执行,同样实现了我们的并发,并且这种并发的运行效率是极高的。
那么接下来我们学习实现多任务的第三种方式——协程
二、协程
1、什么是协程
协程:又叫微线程,或者纤程,是实现多任务的第三种方式,它是单线程下的并发,也就是说它在一个线程下并发,但是可以在多个不同的线程中并发。协程是比线程更小的执行单元,或者说占用的执行单位资源比线程更小,因为它自带CPU的上下文,这样,只要在合适的时机,我们可以把一个协程切换到另一个协程,只要这个过程中保存或恢复CPU上下文,那么程序还是可以运行,而协程就具备这样的能力。
理解:
在一个协程中的某个函数中,我们可以在任何地方保存当前函数的一些临时变量等信息,然后切换到另外一个函数中执行(我们这里并不是使用函数调用的方式),并且切换的次数以及什么时候再切换到原来的函数,都由开发者自己确定。
2、什么是CPU上下文
(1)并发:
我们都知道 ,Linux 是一个多任务的操作系统,它支持远大于CPU数量的任务同时运行。当然这些任务实际上并不是真正的在同时运行,而是系统在很短的时间内,将CPU的资源轮流分配给他们,这个速度是非常快的,所以造成的多任务同时运行的假象,那么这就是我们这几天一直学习的话题——并发。
(2)寄存器和程序计数器的功能
而在每个任务运行前,CPU需要直到这些任务从哪里加载,又从哪里开始运行,也就是说CPU它不知道,那么就需要系统事先帮CPU设置好 寄存器和程序计数器。
寄存器,是CPU内置的容量小,但是运行速度极快的内存。而程序计数器,则是用来存储 CPU 正在执行的指令位置,或者即将执行的下一条指令位置。他们两个都是在 CPU 运行任何任务前,必须的依赖环境,因此也叫做 CPU 上下文。
(3)理解
总的来说,就是 CPU 在运行任务前,都要依赖程序计数器来找到上一次运行终止的位置,或者下一次将要运行的位置,而 寄存器则是 CPU 快速运行任务的 依赖环境。换成白话文也就是说:CPU运行任务到哪里,我都知道位置,和下一次应该运行的位置,这就是CPU上下文。
3、协程与线程的差异
在实现多任务时,线程切换从系统层面来看,远不止保存和恢复CPU上下文这么简单,操作系统为了程序运行的高效性,每个线程都自己缓存Cache(计算中的高速缓存,是一种数据存储技术)等数据,操作系统还会帮我们做这些数据的恢复操作,所以线程的切换非常损耗性能。但是协程的切换单纯的操作CPU的上下文,也就是说切换CPU去不同的任务中从刚才停止的代码开始执行,所以一秒钟切换个上百万次是完全没有问题的。
那么咱们刚刚回顾我们的生成器的时候,使用yield关键字来回的切换两个生成器,实际上很容易就实现了我们协程的功能。
4、如何实现协程
(1)greenlet模块
步骤一:
#导入greenlet模块下的greenlet类
from greenlet import greenlet
# greenlet 译为 绿色小鸟
步骤二:
# 实例化协程对象
# 对象名 = greenlet(目标函数名)
步骤三:
# switch译为:开关,所以就是打开协程
# 对象名.switch(args, kwargs)
# args:以元组的方式给目标函数传参
# kwargs :以元组的方式给目标函数传参
实例:
from greenlet import greenlet
import time
def uploda():
while True:
time.sleep(0.5)
print("正在上传...")
# 打开download线程
g2.switch()
def download():
while True:
time.sleep(0.5)
print("正在下载")
# 打开upload线程
g1.switch()
if __name__ == '__main__':
g1 = greenlet(uploda)
g2 = greenlet(download )
# 打开upload线程
g1.switch()
总结:
在这里我们发现了一个问题,在使用 greenlet 实现协程的时候,如果遇到了大量的 IO(输入输出、网络访问、文件下载)等耗时操作的时候,那么就一直处在一个协程中,也就是发生了堵塞,只有当耗时操作执行完了才会切换到下一个协程,并且还要在每个协程完毕后都要开启下一个协程,所以说这对客户而言是很不理想的,并且也影响了我们的开发效率。
也就是说 greenlet 只是 提供了一种比 generator(生成器)更加便捷的一种方式,但当切换到一个任务执行时如果遇到了IO耗时操作,那就原地堵塞,并没有解决遇到IO自动切换来提升效率的问题。
(2)gevent 模块
python 中还要一个比 greenlet 模块更强大,并且能够在遇到 IO耗时操作时 ,自动切换任务的模块——gevent ,其原理是:当一个 gevent 遇到 IO耗时操作时,比如访问网络,它就自动切换到其他 gevent 上,当等到 IO 操作完成时,在适当的时候切换回来继续执行。
由于 IO 操作非常耗时,经常使程序处于等待状态,有了 gevent 为我们自动间切换协程,就保证了总有 gevent 在运行,而不是等待 IO 。
步骤一:
因为 gevent 是一个第三方库,所以 pycharm 中没有自带,那么我们第一步操作就是在 Ubuntu 系统下安装 gevent ,其安装方式是我们众所周知的在线安装,如下:
sudo apt-get install python3-gevent
步骤二:
# 导入该模块,gevent在有道翻译中译为:并行开发
import gevent
步骤三:
# 实例化gevent对象,spawn译为:产卵
对象名 = gevent.spawn(cls, args, kwargs)
# cls:表示目标函数
# args:以元组方式给目标函数传参
# kwargs:以字典方式给目标函数传参
步骤四:
# 耗时操作
gevent.time(秒数)
# 注意:这里不能使用time模块下的耗时操作,即time.sleep(),因为gevent无法识别
# 所以只能使用gevent可以识别的耗时操作
步骤三:
# 开启协程,并等待协程对象结束
协程对象.join()
# 有一种方法,不用实例化对象,直接开启join,或者说是连写的方式
# 注意:是列表的形式
gevent.joinall([
gevent.spawn(目标函数名称),
gevent.spawn(目标函数名称),
])
实例:
import gevent
import time
def upload():
print("开始上传...")
gevent.sleep(1)
print("上传成功")
def download():
print("开始下载...")
gevent.sleep(1)
print("下载成功")
if __name__ == '__main__':
up = gevent.spawn(upload)
down = gevent.spawn(download)
print(time.time())
up.join()
down.join()
print(time.time())
5、monkey补丁
如果说在项目开发中,你的协程中没有使用 gevent 可识别的耗时操作,而是使用了time等其他模块的耗时操作,那么该怎么办?总不肯去一个个找?重新修改?那绝对不可能,太浪费时间了。
第一步:
# 导入gevent模块中的一个mokey
from gevent import monkey
第二步:
# 在所有程序运行之前,打上一个补丁,patch译为:缝补
monkey.patch_all()
# 这行代码是专门针对于协程下不能识别的耗时操作,它会自动将这些不能识别的耗时操作,替换为它可以识别的耗时操作
实例:
# 导入gevent模块下的猴子
from gevent import monkey
import gevent
import time
def upload():
print("开始上传...")
time.sleep(1)
print("上传成功")
def download():
print("开始下载...")
time.sleep(1)
print("下载成功")
if __name__ == '__main__':
# 在所有程序运行之前使用猴子给所有协程下的耗时操作打补丁
monkey.patch_all()
print(time.time())
gevent.joinall([
gevent.spawn(upload),
gevent.spawn(download)
])
print(time.time())
6、总结协程的特点
(1)必须在一个单线程里实现并发,但是可以在不同的多个线程中实现并发
(2)修改全局变量不要用锁
(3)自动保存CPU上下文
(4)所有遇到 IO 操作就会切换到其他协程,在合适的时间再切换回来