Python学习笔记33:协程

Python学习笔记33:协程

老实说,这部分内容是我学习Python以来遇到的最大挑战,堪比以前学习多线程时候的经历,有种脑袋要爆炸的感觉。

所以还是那句话,把这部分内容提炼总结出来相当困难,如有疏漏在所难免,望多多包涵。

废话不多说了,GO!

yield与生成器函数

前边在Python学习笔记31:迭代技术中我们介绍了生成器函数,生成器函数本质上是通过yield语句来产生一个值提供给调用程序,然后挂起,并等待下一次调用,不断执行这一个过程的特殊函数。

这其中yield语句除了用于向调用方生成数据以外,还肩负着控制生成器函数内的执行流程的作用,毕竟当yield语句被执行后,控制流程就会转到调用程序,生成器函数会被挂起。

这就很有意思了,而更有意思的是,yield不仅仅能产生数据,还能接收数据。

而接收数据,恰恰就是生成器函数向协程进化的一大关键,我们先来看调用程序如何向生成器函数“动态”传递数据。

传入数据

我们看一个简单的例子:

def simpleCoroutine():
    i = yield
    print("coroutine received {!s}".format(i))

sc = simpleCoroutine()
next(sc)
print("coroutine wailt")
sc.send(11)
# coroutine wailt
# coroutine received 11
# Traceback (most recent call last):
#   File "D:\workspace\python\python-learning-notes\note33\test.py", line 8, in <module>
#     sc.send(11)
# StopIteration

在这个例子中,先创建了一个生成器函数实例sc,然后通过执行next(sc)让程序执行生成器函数simpleCoroutine,并且在yield语句处挂起。

不同于之前介绍生成器函数时候的例子,这里的yield语句后没有跟任何变量,所以此时不会产生任何值,如果此时接收next产生的值,将获得一个None

而且很容易注意到,此时的yield前面还有一个赋值语句。这时候我们在外部的调用程序中就可以使用sc.send的方式将数据传入,传入后的生成器函数会通过赋值语句获取数据,并继续执行。当然在这个简单示例中是执行到尾部然后抛出StopIteration异常,这点和之前我们介绍的生成器函数的特点完全一致。

现在生成器函数不仅仅是单向产生数据了,还能接收数据,这样的生成器函数我们称之为协程。

下面我们系统介绍一下协程。

协程

image-20210515175014538

协程,对应的单词是Coroutine,意思是协同程序。总的来说,协程很像是单线程中的一种“子程序”,所以它经常用来用来进行一些仿真程序,通过设置多组协程来模拟某种场景下的事件驱动等仿真结果。

状态

协程在运行的时候会有一些状态,这里通过一个简单程序说明:

import inspect

def simpleCoroutine():
    print("coroutine start")
    print("coroutine running")
    print("coroutine wait")
    i = yield
    print("coroutine running")

def showCoroutineStatus(coroutine):
    print("coroutine status is {!s}".format(inspect.getgeneratorstate(coroutine)))

sc = simpleCoroutine()
showCoroutineStatus(sc)
print("start coroutine")
next(sc)
showCoroutineStatus(sc)
print("send data to coroutine")
try:
    sc.send(11)
except StopIteration:
    pass
showCoroutineStatus(sc)
# coroutine status is GEN_CREATED
# start coroutine
# coroutine start
# coroutine running
# coroutine wait
# coroutine status is GEN_SUSPENDED
# send data to coroutine
# coroutine running
# coroutine status is GEN_CLOSED

这里使用inspect.getgeneratorstate获取协程状态。

刚创建的协程实例sc处于GEN_CREATED状态,我们通过next(sc)可以对协程进行"激活",此时协程会进入运行状态,直到执行到第一个yield处进行挂起,此时协程将处于GEN_SUSPENDED状态,然后我们通过sc.send(11)给协程传入数据,协程就从挂起状态恢复为运行状态,直到运行结束后抛出StopInteration异常。此时协程处于GEN_CLOSED状态。

实际上协程状态还有GEN_RUNNING,但因为这里是单进程程序,所以并不能观察到此状态,所以只要清楚协程内部执行的时候协程是处于运行状态就可以了。

除了说明协程状态,这个示例还展示了如何使用next对协程进行激活以及使用send传递数据给协程。

使用send(None)也可以激活协程,但需要注意的是一定要传入None,如果试图对GEN_CREATED状态的协程传入非None值进行激活,会产生异常。

此外我们还可以给协程传入异常或者显式结束协程。这里我们使用一个计算动态平均数的程序进行说明。

动态平均数

import inspect
import random


def coroutineAverager():
    total = 0.0
    count = 0
    result = 0.0
    while True:
        newNumber = yield result
        total += newNumber
        count += 1
        result = total/count


avg = coroutineAverager()
next(avg)
numbers = [random.randint(1, 20) for i in range(5)]
countedNums = []
for i in numbers:
    result = avg.send(i)
    countedNums.append(i)
    print(countedNums, "average:", result)
avg.close()
print(inspect.getgeneratorstate(avg))
# [17] average: 17.0
# [17, 2] average: 9.5
# [17, 2, 4] average: 7.666666666666667
# [17, 2, 4, 7] average: 7.5
# [17, 2, 4, 7, 9] average: 7.8
# GEN_CLOSED

这里展示了一个用于计算动态平均数的协程,协程中通过一个死循环来不断读取数据,并在计算后返回平均值。调用程序在计算完所有数据的平均数后调用close来显式关闭协程。

值得注意的是,这里的yield语句相当于完全体,它会将右侧的表达式计算的值生产给外部的调用程序,并且在挂起后会等待调用程序传入一个值传递给左侧的赋值语句,这里的yield相当于协程和外部的门户,同时承担数据的产生和接收。

这里使用图示的方式说明:

image-20210516110553540

在协程中,每次都会在红线标示的部分挂起并等待调用方传入数据。

事实上在调用close的时候,解释器会传递给协程一个GeneratorExit异常,协程会在挂起的yield语句处抛出此异常,然后解释器捕获该异常并结束掉协程。

这里展示用异常来控制关闭协程:

import inspect
import random


def coroutineAverager():
    total = 0.0
    count = 0
    result = 0.0
    while True:
        try:
            newNumber = yield result
        except GeneratorExit:
            break
        total += newNumber
        count += 1
        result = total/count


avg = coroutineAverager()
next(avg)
numbers = [random.randint(1, 20) for i in range(5)]
countedNums = []
for i in numbers:
    result = avg.send(i)
    countedNums.append(i)
    print(countedNums, "average:", result)
# avg.close()
try:
    avg.throw(GeneratorExit)
except StopIteration:
    pass
print(inspect.getgeneratorstate(avg))
# [4] average: 4.0
# [4, 15] average: 9.5
# [4, 15, 14] average: 11.0
# [4, 15, 14, 7] average: 10.0
# [4, 15, 14, 7, 1] average: 8.2
# GEN_CLOSED

如果我们是通过throw来传入GeneratorExit异常,就需要在协程中的yield语句处捕获并处理异常,并且在退出循环体后会向上抛出StopIteration异常,要在调用程序中处理。

对于这个例子,其实可以通过一种更简单的方式让协程退出,比如通过send传递一个不太常见的值,作为关闭标识,比如None

import inspect
import random


def coroutineAverager():
    total = 0.0
    count = 0
    result = 0.0
    while True:
        newNumber = yield result
        if newNumber is None:
            break
        total += newNumber
        count += 1
        result = total/count


avg = coroutineAverager()
next(avg)
numbers = [random.randint(1, 20) for i in range(5)]
countedNums = []
for i in numbers:
    result = avg.send(i)
    countedNums.append(i)
    print(countedNums, "average:", result)
try:
    avg.send(None)
except StopIteration:
    pass
# [13] average: 13.0
# [13, 8] average: 10.5
# [13, 8, 17] average: 12.666666666666666
# [13, 8, 17, 18] average: 14.0
# [13, 8, 17, 18, 5] average: 12.2

但此时已然需要调用程序处理StopIteration异常,对比一下发现还是调用close更方便。

return

直到现在为止,协程还只是通过yield语句将内部产生的数据传递给调用程序,事实上现在Python也可以通过return将协程内的数据返回给外部程序。

import inspect
import random


def coroutineAverager():
    total = 0.0
    count = 0
    result = 0.0
    while True:
        newNumber = yield
        if newNumber is None:
            break
        total += newNumber
        count += 1
        result = total/count
    return result


avg = coroutineAverager()
next(avg)
numbers = [random.randint(1, 20) for i in range(5)]
for i in numbers:
    avg.send(i)
try:
    avg.send(None)
except StopIteration as e:
    print(numbers)
    print("avg:", e.value)
# [3, 18, 10, 9, 13]
# avg: 10.6

可以看到,外部程序是通过StopIteration异常实例的value属性获取到协程返回的值,这或许有点怪异,但协程的确是通过把返回值赋值给StopIteration.value的方式传递的。

使用装饰器进行激活

我们在前面已经说过了,使用协程之前要先用next或者send(None)的方式对协程进行激活,让协程处于GEN_SUSPECT状态,这时候才可以传递数据进行进一步使用。

除了每次都next以外,我们还有一个额外选项,构建一个进行“预激活”的装饰器,用这个装饰器来装饰协程,这样我们就不需要进行“手动激活”了。


import inspect


def coroutineDecorator(func):
    import functools
    functools.wraps(func)

    def coroutineWrap(*avgs, **kwAvgs):
        coroutine = func(*avgs, **kwAvgs)
        next(coroutine)
        return coroutine
    return coroutineWrap


@coroutineDecorator
def simpleCoroutine():
    i = yield
    print("receive {!s}".format(i))


sc = simpleCoroutine()
print(inspect.getgeneratorstate(sc))
try:
    sc.send(11)
except StopIteration:
    pass
print(inspect.getgeneratorstate(sc))

需要说明的是,虽然使用这种装饰器进行“预激活”很方便,但是有一些Python内建组件,比如后边会展示的yield from语句本身就具有“预激活”装饰器的作用,此时就不能使用我们自定义的“预激活”功能,否则就会出问题。

yield from

原理

我们在Python学习笔记31:迭代技术中简单介绍过yield from语句,这个语句最浅显的用途是可以将生成器函数中遍历迭代器的功能“委托”给迭代器并直接生成数据提供给调用程序:

def chain(*iterators):
    for iterator in iterators:
        yield from iterator

l = list(chain(range(5),"abc"))
print(l)
# [0, 1, 2, 3, 4, 'a', 'b', 'c']

事实上,yield from比表面上的用途要复杂的多,yield from的真实运作机制其实是将外部的调用程序和协程中的子协程直接“连通”,此时的协程相当于一个委托程序,外部调用程序通过调用委托协程的nextsend来直接调用到委托程序中的子协程的相应方法,并且通过委托协程中转后直接获得生成的数据。

计算多组动态平均数

下面通过一个示例程序进行说明:

import pprint
import random


def coroutineAverager():
    total = 0.0
    count = 0
    result = 0.0
    while True:
        newNumber = yield
        if newNumber is None:
            break
        total += newNumber
        count += 1
        result = total/count
    return result


def dealDataCoroutine(result, kind):
    result[kind] = yield from coroutineAverager()


data = {}
data["boy_height"] = [random.randint(160, 195) for i in range(10)]
data["girl_height"] = [random.randint(150, 185) for i in range(10)]
data["boy_weight"] = [random.randint(60, 100) for i in range(10)]
data["girl_weight"] = [random.randint(40, 80) for i in range(10)]
result = {}
for kind, kindData in data.items():
    ddc = dealDataCoroutine(result, kind)
    next(ddc)
    for item in kindData:
        ddc.send(item)
    try:
        ddc.send(None)
    except StopIteration:
        pass
pprint.pprint(data)
print(result)
# {'boy_height': [189, 192, 174, 163, 160, 171, 173, 170, 194, 186],
#  'boy_weight': [79, 78, 76, 98, 86, 81, 78, 68, 97, 68],
#  'girl_height': [165, 170, 167, 169, 174, 177, 161, 154, 177, 167],
#  'girl_weight': [51, 52, 65, 46, 78, 71, 75, 63, 58, 65]}
# {'boy_height': 177.2, 'girl_height': 168.1, 'boy_weight': 80.9, 'girl_weight': 62.4}

这里利用了前面计算动态平均数的协程,并使用一个委托协程dealDataCoroutine进行调用。

测试数据是四组男女同学的身高和体重。

首先我们对于每组数据,通过ddc = dealDataCoroutine(result, kind)创建一个委托协程,并且使用next(ddc)进行激活。

需要注意的是,激活委托协程以后,委托协程的yield from语句会将委托协程挂起,并且激活子协程coroutineAverager(),事实上此时子协程已经执行到newNumber = yield语句并挂起,并等待外部程序传递数据。此后外部程序所有的nextsend调用都将直接作用于子协程,所以ddc.send(item)是直接传递数据给计算动态平均数的子协程进行累积计算,这期间委托协程一直处于挂起状态。一直到该组数据全部传递完毕,我们调用了send(None),此时子协程将跳出内部循环,并将return返回的值附加给StopIteration异常,然后抛出。幸运的是这种情况下yield from语句可以自动捕获StopIteration异常,并且将其value属性赋值给前边的result[kind],所以在委托协程中我们没有显式处理StopIteration异常。但是当委托协程解除挂起并执行完赋值语句后,就会退出,并且抛出StopIteration,所以我们在外部程序需要处理该异常。

这一段是最难理解,也是最核心的内容,比较烧脑,建议结合代码多读几遍。

其他几组数据的执行过程与上边所说的完全一致,只不过是新创建了一个委托协程。

我绘制了一张时序图,可能会对理解有所帮助:

image-20210516113627056

可以看到,当作为委托协程的dealDataCoroutine创建并激活了子协程coroutineAverager后,子协程和主程序就直接建立了联系,此后就是主程序和子协程直接交互,期间委托协程都处于挂起的状态,直到子协程收到特定标识退出内部循环并返回StopIteration异常,此时委托协程恢复运行,并存入关键数据,然后同样向主协程抛出异常。

这里需要指出的是,时序图只是展示了逻辑上的交互,事实上委托协程之所以可以以隐形的方式上下沟通主程序和子协程,这完全是yield from语句的功劳,它涵盖了所有接收主程序指令并调用子协程然后向主协程返回信息的所有功能,此外还可以正确处理各种异常,并且还可以获取子协程通过return返回的数据。

PEP-492

协程这一技术在伴随着Python的版本更迭不断发生变化,在PEP 492 – Coroutines with async and await syntax中,可以用新的关键字创建协程(已经在Python3.5实装):

async def read_data(db):
    pass

并搭配新的关键字来控制协程,这点等我阅读完PEP-492后再来补全。

今天花了点时间阅读并翻译了PEP-492,感兴趣的可以移步阅读。

老实说,相比《Fluent Python》原文,这里的说明相当简单,我不知道能否阐述明白协程的运作机制,事实上使用时序图应该对理解yield from很有帮助,这点我需要思考一下如何绘制。

目前就这样了,有空会完善这篇有难度的文章,谢谢阅读。

本系列文章的代码都存放在Github项目:python-learning-notes

协程

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值