目录
~ 什么是协程?
~ 单线程能实现并发吗?
~ 思考:上面的单线程并发(协程)有意义吗?
~ 如何自动检测 IO 实现有意义的协程?
~ 简单介绍一下 Greenlet 类
~ 协程的优点和缺点
~ Coroutines and Tasks
什么是协程?
答:单线程实现并发,每一个并发的任务就是一个协程。协程又称微线程,纤程,它的关键之处在于自动监测 IO 然后在协程间进行切换。它存在的目的是为了增加程序的执行效率。一个进程至少有一个线程,而一个线程至少有一个协程。
单线程能实现并发吗?
答:那首先得明确一下什么是并发。并发是指多个任务轮流使用资源,并发的关键核心是任务之间的切换和状态的保存。那么可以认为,能实现多任务切换并保存每个任务的执行状态,那么就是能够实现并发。
现在回到原问题,单线程能够实现并发吗?答案是肯定的!(不然我写这篇博客干嘛)Python 中有一个关键字能自动保存函数的执行状态——yield。函数中使用 yield 语句从函数返回,并保存它的执行状态。然后可以利用 next() 或 send() 函数重新启动函数(send() 方法还可以往函数内传递参数)。实际上这就实现了不同任务的切换,在主流程和任务函数之间的切换,即实现了并发。
给个代码看看
实际上这与生成器的概念一样,关于生成器可参阅:Python学习笔记 生成式和生成器
def work():
print('切换至另一任务执行 1')
yield
print('切换至另一任务执行 2')
yield
print('另一任务结束')
def main():
task = work()
print('一个任务开始')
next(task)
print('一个任务执行中')
next(task)
print('一个任务继续执行')
try:
next(task)
except StopIteration:
# 生成器对象结束时产生的异常,放在 for 中会自动处理
# 这里把它忽略掉
pass
print('一个任务结束')
if __name__ == '__main__':
main()
从上面的代码执行结果可以看到,两个任务你一句我一句的输出,实现了多任务的切换,即并发。
思考:上面的单线程并发(协程)有意义吗?
参考多线程实现并发,在单线程实现并发(即协程)的目的是为了提高程序的执行效率。多线程时,发生线程切换的原因有两种,第一种是一个线程遇到 IO 时切换至另一线程。这种切换避免了 CPU 在一个线程 IO 时的无意义等待,提高了 CPU 的利用率,所以能够提高程序的执行效率;第二种切换是线程的执行时间达到阈值系统进行强制切换。这种切换只能使 CPU 做到对各个线程雨露均沾,而不能提高程序的执行效率。对于计算密集型的任务,过多的无意义切换反而会降低执行效率。
简单来说,IO 时切换才有意义。那么 yield 实现的协程有意义吗?答案是否定的。yield 不能检测 IO,遇到 IO 时,yield 会等待 IO 而不是切换至其他协程。下面给个示例感受一下:
import time
def work():
print('切换至另一任务执行 1')
time.sleep(100)
# 利用 time 模块模拟 IO
yield
def main():
task = work()
print('一个任务开始')
try:
next(task)
except StopIteration:
# 生成器对象结束时产生的异常,放在 for 中会自动处理
# 这里把它忽略掉
pass
print('任务结束')
if __name__ == '__main__':
main()
你会发现“一个任务开始后”,你要等100多秒才回到看“任务结束”。
如何自动检测 IO 实现有意义的协程?
答:需要用到 gevent 模块。该模块不是内置的,需要安装。安装方法是打开 cmd ,然后输入:pip3 install greenlet,最后回车即可。如下图:
gevent 模块能帮我们自动检测 IO 并切换至另一个协程。协程与线程和进程的模式相似:线程模块里有 Thread 类,用于创建线程对象;进程模块里有 Process 类,用于创建进程对象;而 gevent 模块中有 Greenlet 类,用于创建协程对象。进程、线程和协程都有 start() 和 join() 方法,且功能类似。
简单介绍一下 Greenlet 类
Greenlet 是 gevent 模块提供的一个类,它是 greenlet 的子类,用于构造一个轻量级的任务协作执行单元。greenlet 类是定义在 greenlet 模块中的一个类,利用 greenlet 对象可以实现与 yield 一样的功能,即多任务切换,而且 greenlet 对象比 yield 用起来更简单方便。但 greenlet 对象也不能自动检测 IO。Greenlet 在 greenlet 的基础上实现了对 IO 的自动检测。
Greenlet 构造函数
class gevent.Greenlet(run=None, *args, **kwargs)
功能:创建一个新的 Greenlet 对象,并将其安排为运行 run(*args, **kwargs)
参数:run 是一个可调用对象,表示协程要执行的任务
*args 表示 run 的位置参数
**kwargs 表示 run 的关键字参数
返回值:一个新的 Greenlet 对象
Greenlet 属性
minimal_ident
功能:获取协程的唯一标识编号,该编号在协程死亡后会被重用,类似于 Thread.ident
返回值:一个小的整数
说明:这是一个静态属性
name:是一个字符串,表示协程的名字,默认使用 minimal_ident 构造一个名字,可修改
value:表示 run 的返回值,与 get() 方法效果一样
Greenlet 对象常用方法及解析
start()
功能:安排 greenlet 在此循环迭代中运行
参数:无
返回值:None
说明:并不是立即开始执行,而是加到循环迭代中,相当于是获取了切换的资格
join(timeout=None)
功能:阻塞至协程结束或超时
参数:timout 表示阻塞的秒数,默认无限制
返回值:始终为 None
ready()
功能:判断协程是否执行完毕
参数:无
返回值:当且仅当执行完时返回 True
说明:该方法仅保证返回表示 true 或 false 的 values,不一定返回 True 或 False
successful()
功能:仅成功完成时返回 True,成功是指执行过程中没有出错
参数:无
返回值:True 或 False
get(block=True, timeout=None)
功能:获取协程返回的结果或重新引发协程引发的异常
参数:block 表示是否阻塞,timeout 表示阻塞的秒数
返回值:Greenlet 对象的执行结果
说明:当无法获取到 Greenlet 的结果时(如 block=False时协程没有执行完;block=True时阻塞时间结束但协程仍没有执行完)将引发 gevent.Timeout 异常
如果在 Greenlet 执行过程中引发了异常,该方法将会再次引发此异常
特别介绍(重点)
spawn(func, *args, **kwargs)
功能:创建一个新的 Greenlet 对象,并将其安排为运行 function(*args, **kwargs)
参数:func 是一个可调用对象,表示协程要执行的任务
*args 和 **kwargs 是传入 func 的位置参数和关键字参数
返回值:一个 Greenlet 对象
说明:这是一个类方法,可用作 gevent.spawn() 和 Greenlet.spawn()
相当于 g1 = Greenlet()
g1.start()
joinall(greenlets, timeout=None, raise_error=False, count=None)
功能:等待 greenlets 序列中的所有 Greenlet 对象结束或超时
参数:greenlet 表示一个序列
timeout 表示阻塞的时间
返回值:超时前所完成的 Greenlet 的执行结果序列(如果有)
说明:1、每一个结果都是一个 Greenlet 对象,可调用 get() 方法
2、这是 gevent 模块中的函数,可用作 gevent.joinall() 。
代码示例
import gevent
def work():
print('切换至另一任务执行')
gevent.sleep(3)
print('另一任务结束')
return 'work_over'
def main():
print('一个任务开始')
gevent.sleep(1)
print('一个任务执行中')
gevent.sleep(2)
print('一个任务结束')
return 'main_over'
if __name__ == '__main__':
# g1 = gevent.Greenlet(run=main)
# g2 = gevent.Greenlet(run=work)
# g1.start()
# g2.start()
g1 = gevent.spawn(main)
g2 = gevent.spawn(work)
# 两句代替四句
lis = gevent.joinall([g1, g2])
for item in lis:
print(item.name, '执行结果:', item.get())
上面的代码已经能自动检测 IO 并切换了,但它其实还没什么实际意义,因为它只能检测 gevent 模块内的 IO(如 gevent.sleep())。要想让它识别 gevent 模块以外的 IO 阻塞,还需要打一个补丁,即导入 gevet 模块下的 monkey,并调用 monkey.patch_all() 方法。该方法必须在所有被打补丁的模块导入之前调用(如 time 模块),它的作用是将所有阻塞 IO 转化为 非阻塞 IO。下面是修改后的代码
import gevent
from gevent import monkey
monkey.patch_all()
import time
def work():
print('切换至另一任务执行')
time.sleep(3)
print('另一任务结束')
return 'work_over'
def main():
print('一个任务开始')
time.sleep(1)
print('一个任务执行中')
time.sleep(2)
print('一个任务结束')
return 'main_over'
if __name__ == '__main__':
# g1 = gevent.Greenlet(run=main)
# g2 = gevent.Greenlet(run=work)
# g1.start()
# g2.start()
g1 = gevent.spawn(main)
g2 = gevent.spawn(work)
# 两句代替四句
lis = gevent.joinall([g1, g2])
for item in lis:
print(item.name, '执行结果:', item.get())
协程的优点和缺点
优点
-
协程具有极高的执行效率。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。
-
不需要多线程的锁机制。因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。
-
增加了程序并发度。协程时在单个线程内的并发执行,所以在整体上增加了整个程序的并发度
缺点
- 协程的本质是单线程,无法利用多核,但可以是一个程序开启多个进程,每个进程内开启多个线程,每个线程内开启协程
- 协程指的是单个线程,因而一旦协程出现阻塞,将会阻塞整个线程
Coroutines and Tasks
贴个网址先,有空再看。官方文档