# 协程进化史

# IO上下文

如果协程中涉及 IO 操作,则需要在 IO 未就绪时通过 yield 让出执行权。在让出执行权的同时,还需要将 IO 上下文提交给事件循环,由它协助处理。那么,IO 上下文需要包含哪些信息呢?

IOContext 需要保存哪些信息取决于封装程度,但至少要包括协程需要等待的 文件描述符 以及感兴趣的 事件

class IOContext:
  
    def __init__(self, fileno, events):
        self.fileno = fileno
        self.events = events

现在我们开始编写一个带 IO 操作的协程,它负责从监听套接字接收新客户端连接:

def accept_client(sock):
    while True:
        try:
            return sock.accept()
        except BlockingIOError:
            pass
    
        yield IOContext(sock.fileno(), select.EPOLLIN)

协程主体逻辑是一个循环,它先调用 accept 尝试接收新连接。如果没有连接就绪,accept 会抛 BlockingIOError 异常。 这时,yield 语句让出执行权,并将 IOContext 提交给事件循环。注意到,协程对套接字上的读事件感兴趣。

现在我们创建一个这样的协程,并扮演事件循环,来体会协程调度过程。如果套接字 s 没有就绪连接,send 将收到协程返回的 IOContext ,表明协程期待哪些事件发生:

>>> co = accept_client(s)
>>> context = co.send(None)
>>> context
<__main__.IOContext object at 0x7fcd58e3ef70>
>>> context.fileno
3
>>> context.events
1

事件循环接到上下文后,需要将当前协程保存到上下文中,并将需要订阅的事件注册到 epoll :

>>> context.co = co
>>> ep.register(context.fileno, context.events)

接着,事件循环在 epoll 上等待相关事件到达:

>>> ep.poll()

poll 将保持阻塞,直到有注册事件出现。因此,用 telnet 命令再次连接 s 套接字,poll 将返回:

>>> ep.poll()
[(3, 1)]

根据 poll 返回的文件描述符 3 ,我们知道 context 这次 IO 操作已经就绪了。这时,可以接着调度对应的协程:

>>> context.co.send(None)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration: (<socket.socket fd=4, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 55555), raddr=('127.0.0.1', 51528)>, ('127.0.0.1', 51528))

至此,协程 co 成功接收了一个客户端连接,并退出了。

# yield

开始研究有 IO 操作的协程之前,我们先拿一个纯计算协程练练手。这是一个只做加法运算的协程:

def add(a, b):
    if False:
        yield
    return a + b

if 语句永远不会执行,它只是为了引入 yield 语句,让 Python 将 add 编译成生成器。

现在我们创建一个新协程,并调用 send 方法把它调度起来:

>>> co = add(1, 2)
>>> co.send(None)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration: 3

正如前面提到的那样,协程一把梭哈到底,StopIteration 异常告诉我们它已经执行完毕,结果是 3 。注意到,协程执行结果 (函数返回值) 保存在 StopIteration 的 value 属性:

>>> import sys
>>> e = sys.last_value
>>> e
StopIteration(3)
>>> e.value
3

我们还可以写一个函数来调度协程,函数只需调用 send 方法,并在协程执行完毕后输出一些提示:

def schedule_coroutine(co):
    try:
        co.send(None)
    except StopIteration as e:
        print('coroutine {} completed with result {}'.format(co.__name__, e.value))
>>> co = add(2, 3)
>>> schedule_coroutine(co)
coroutine add completed with result 5

# yield from

注意,这里还没有涉及到事件队列。

现有一个用于计算圆面积的协程,它没有涉及 IO 操作:

import math

def circle_area(r):
    if False:
        yield
    return math.pi * r ** 2

创建一个这样的协程来计算半径为 2 的圆的面积,并调用 send 方法来调度它,协程执行完毕后将返回结果:

>>> co = circle_area(2)
>>> co.send(None)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration: 12.566370614359172

现在,让我们利用这个协程来计算圆柱体积:

def cylindrical_volume(r, h):
    return circle_area(r) * h

这样显然是不行的,因为调用 circle_area 返回的是一个代表协程的生成器,需要调度它才能获得计算结果。不过没关系,我们可以这么写:

def cylindrical_volume(r, h):
    co = circle_area(r)
    while True:
        try:
            yield co.send(None)
        except StopIteration as e:
            floorage = e.value
            return floorage * h

这个是一个协程函数,它先创建一个子协程用于计算底面积,然后用一个永久循环驱动子协程执行。

每次循环时,它先调用 send 方法将执行权交给子协程。如果子协程用 yield 语句归还执行权,这里同样用 yield 将执行权交给调用者,yield 值也一并向上传递。如果子协程退出,它将取出子协程执行结果并完成计算。

>>> co = cylindrical_volume(2, 3)
>>> co.send(None)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration: 37.69911184307752

图片描述

因此,cylindrical_volume 就像一个中间人,在调用者和子协程之间来回传递执行权。函数调用很常见,如果涉及协程的函数调用都需要用样板代码传递执行权,那简直就是一个噩梦!为此,Python 引入 yield from :

def cylindrical_volume(r, h):
    floorage = yield from circle_area(r)
    return floorage * h

例子中 yield from 的作用相当于上一例子中的 while 循环,因此这两个例子是完全等价的。与业务逻辑无关的样板代码消除后,新函数变得简洁纯粹,更加清晰易懂了!

# async await

# 协程对象

直接使用生成器实现协程,虽然逻辑上可行,但语义上有点令人摸不着头脑:

>>> co = circle_area(1)
>>> co
<generator object circle_area at 0x10500db50>

为突显协程语义,Python 引入了 async 关键字:

async def circle_area(r):
    return math.pi * r ** 2

被 async 关键字标识的函数会被编译成异步函数,调用后得到一个 coroutine 对象:

>>> co = circle_area(1)
>>> co
<coroutine object circle_area at 0x1050f7050>

coroutine 对象与 generator 对象类似,我们可以调用 send 方法来调度 coroutine 对象:

>>> co.send(None)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration: 3.141592653589793

coroutine 对象的语义更加准确,而且我们再也不需要在函数代码中显式编写 yield 语句了,这未免有点画蛇添足。

# 协程执行

青出于蓝而胜于蓝,如果 coroutine 没执行完毕便被意外销毁,Python 将输出警告信息:

>>> co = circle_area(2)
>>> del co
__main__:1: RuntimeWarning: coroutine 'circle_area' was never awaited
RuntimeWarning: Enable tracemalloc to get the object allocation traceback

此外,Python 还引入了 await 关键字,代替前面提到的 yield from 语句。与 yield from 类似,await 将执行权交给子协程,并等待它退出。如果子协程需要暂时归还执行权,await 同样承担起中间人角色,在调用者与子协程间来回接棒。

async def cylindrical_volume(r, h):
    floorage = await circle_area(r)
    return floorage * h

无须多言,await 的语义也比 yield from 准确。另外,Python 还引入了 可等待对象 ( awaitable )。例子如下:

class Job:
  
    def __await__(self):
        print('step 1')
        yield
        print('step 2')
        yield
        print('step 3')
        return 'coding-fan'

可等待对象需要提供 await 魔术方法,实现成普通生成器即可。然后,await 就可以驱动生成器的执行:

async def do_job(job):
    value = await job
    print('job is done with value {}'.format(value))
>>> co = do_job(Job())
>>> co.send(None)
step 1
>>> co.send(None)
step 2
>>> co.send(None)
step 3
job is done with value coding-fan
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

如果你觉得可等待对象 Job 难以理解,可以将它想象成等价的 yield from 形式,便豁然开朗了:

def do_job(job):
    value = yield from job.__await__()
    print('job is done with value {}'.format(value))

await 本无法驱动普通生成器,可等待对象却另辟蹊径,因而它在协程库中有重要作用。