Python 生成器中用到的 yield item
具有 2 个含义:产出和让步。
yield item
这行代码会产出一个值,提供给 next()
调用方。此外还会做出让步,即暂停执行生成器,让调用方继续工作,直到需要使用另一个值时,才会回到生成器上次退出的地方继续执行。
从句法上看,协程与生成器类似,都是包含 yield
关键字的函数。但在协程中,yield
表达式通常为: data = yield
。可以产出值,也可以不产出(如果 yield
关键字后面没有表达式,那么生成器产出 None
)。
此外,协程通常会从调用方接收数据,调用方把数据提供给协程使用 .send(data)
方法。yield
关键字甚至还可以不接收或传出数据。
不管数据如何流动,yield
都是一种流程控制工具,使用它可以实现协作式多任务:协程可以把控制器让步给中心调度程序,从而激活其它的协程。
一. 协程的概念
协程(Coroutine),又称微线程,纤程,但协程本质上是一个线程在运行。线程比进程轻量,而协程比线程还要轻量。多线程在同一个进程中运行,而协程通常也在在同一个线程中运行。
由于 CPython 解析器的 GIL 原因,多线程的效率受到了很大制约,并且在类 *inux
系统中,创建线程的开销并不比进程小。
后来人们发现通过 yield
来中断代码片段的执行, 同时交出了 CPU 的使用权,于是协程的概念产生了,并在 Python3.4 中正式引入。协程通过应用程序,记录上下文栈区,实现在程序执行过程中的跳跃执行。由此可以选择不阻塞的部分执行以提升运行效率。
和多线程相比,协程具有如下优点:
- 线程是系统级别的它们由操作系统调度,而协程则是程序级别的由程序根据需要自己调度;
- 资源消耗少,无需多线程那样进行多核间的切换;
- 无需同步互斥操作;
- 没有 C10K 问题,IO 并发性好,一个 CPU 支持上万的协程都不是问题,所以很适合用于高并发处理。
协程的缺点:
- 无法利用多核资源:协程的本质是个单线程,它不能同时将单个 CPU 的多个核用上,协程需要和进程配合才能运行在多CPU上。当然我们日常所编写的绝大部分应用都没有这个必要,除非是 CPU 密集型应用。
- 进行阻塞(Blocking)操作(如 IO 时)会阻塞掉整个程序。
二. 使用yield实现协程
2.1 用作协程的生成器的基本行为
生成器 API 新增的方法 / 特性 | 说明 |
---|---|
send(value) | 调用方可以使用 .send() 方法发送数据,发送的数据会成为生成器函数中 yield 表达式的值。 |
throw(...) | 调用方抛出异常,并在生成器中处理。 |
close() | 终止生成器。 |
return | 生成器可以使用 return 返回一个值。 |
yield from | 把复杂的生成器重构成小型的嵌套生成器,省去了之前把生成器工作委托给子生成器所需的大量样板代码。 |
使用生成器函数定义协程的例子:
def simple_coroutine():
print('-> coroutine started')
x = yield
print('-> coroutine received:', x)
coro = simple_coroutine()
next(coro)
coro.send('this message from caller.')
运行结果:示例中 yield
关键字右边没有表达式,所以该协程只从调用者那里接受数据,yield
产出返回给调用者的值为 None
,即 next(coro)
获取的是 None
。
调用包含 yield
关键字的函数 simple_coroutine
之后,得到一个生成器对象 coro
。此时生成器还没启动,没在 yield
语句处暂停,所以一直无法发送数据。
调用 next
方法以后,协程定义体中的 yield
方法被挂起,控制权回到调用方,执行 coro.send(...)
后,协程会恢复,一直运行到下一个 yield
表达式或终止。
此是,yield
表达式将 this message form caller
赋值给 x
,控制权流动到协程定义体的末尾,导致生成器抛出 StopIteration
异常。
协程可身处下面 4 个状态中的一个,当前状态可使用 inspect.getgeneratorstate(...)
函数获取,该函数返回的字符串及含义如下:
inspect.getgeneratorstate(...) 函数返回值 |
含义 |
---|---|
GEN_CREATED |
等待开始执行 |
GEN_RUNNING |
解释器正在执行。只有在多线程应用中才能看到这个状态。 |
GEN_SUSPENDED |
在 yield 表达式处暂停。因为 send 方法的参数会作为 yield 表达式的值,所以,仅当程序处于暂停状态时才能调用 send 方法。 |
GEN_CLOSED |
执行结束。 |
一个简单的例子来说明生成器(协程)的状态:
from inspect import getgeneratorstate
from time import sleep
def corotine():
for i in range(3):
sleep(0.5)
x = yield i+1
print('-> corotine x=', x)
coro = corotine()
while True:
try:
print(f'corotine status:{getgeneratorstate(coro)}')
coro.send(100)
next(coro)
print(f'corotine status:{getgeneratorstate(coro)}')
next(coro)
print(f'corotine status:{getgeneratorstate(coro)}')
except TypeError as e:
print(e)
coro = corotine()
next(coro) # 激活生成器
continue
except StopIteration:
print('coroutine is finished.')
print(f'corotine status:{getgeneratorstate(coro)}')
break
运行结果:进入循环后,生成器处于 GEN_CREATED
状态,尚未激活,因此 send(100)
将触发 TypeError
异常,异常信息非常清晰。
最先调用 next(coro)
函数这一步通常视为激活协程,即让协程向前执行至第一个 yield
表达式,准备好作为活跃的协程使用。
下面再举个例子,该示例定义一个产出两个值的协程:
from inspect import getgeneratorstate
def sample_coro2(a):
print('-> Started: a=', a)
b = yield a
print('-> Received: b=', b)
c = yield a + b
print('-> Received: c=', c)
coro = sample_coro2(2)
try:
print(getgeneratorstate(coro))
print('调用方:', next(coro))
print(getgeneratorstate(coro))
print('调用方:', coro.send(4))
print('调用方:', coro.send(8))
except StopIteration:
print(getgeneratorstate(coro))
运行结果:协程函数调用时并不会立即执行,所以首先打印的是 GEN_CREATED
,当执行 next(coro)
后,协程被激活,开始运行,执行到 b=yield a
后,暂停协程,让步 CPU 的使用权给调用方。
调用方此刻打印 coro
的状态为 GEN_SUSPENDED
状态,即协程在 yield
表达式处暂停状态。
继续向下执行 coro.send(4)
,又将调用权给到协程,协程表达式 yield a
的返回值即 coro.send(4)
发送的4,因此打印 b=4
。
协程继续向下执行到 c=yield a+b
,再次将 CPU
的使用权让步给调用方,调用方获取到 yield a+b
产出的值 6
。
当协程函数执行完毕,将抛出 StopIteration
异常,调用方捕获此异常,打印 coro
的状态发现,协程已处于 GEN_CLOSED
状态。
2.2 一个协程应用的例子
下面我们介绍一个关于协程略微复杂的例子:使用协程计算平均值。
def average():
total = 0
count = 0
average = None
while True:
term = yield average
total += term
count += 1
average = total / count
这个无限循环表明,只要调用方不断把值发给这个协程,它就会一直接收值,然后生成结果。仅当调用方在协程上调用 .close()
方法,或者没有协程的引用而被垃圾回收程序回收时,这个协程才会终止。
这里的 yield
表达式用于暂停执行协程,把结果发给调用方;还用于接收调用方后面发给协程的值,恢复无限循环。
使用协程的好处是,total
和 count
声明为局部变量即可,无需使用实例属性或闭包在多次调用之间保存上下文。
运行结果:调用 next(coro_ava)
函数后,协程会向前执行到 yield
表达式,产出 average
变量的初始值 None
,因此不会出现在控制台中。
此时,yield
在协程表达式处暂停,等待调用方发送值。 coro_ava.send(10)
发送一个值,激活协程,把发送的值赋给 term
,并更新 total
、count
、average
三个变量的值,然后开始 while
循环的下一次迭代,产出 average
变量的值,等待下一次为 term
变量赋值。