最新帖子 精华区 社区服务 会员列表 统计排行
  • 802阅读
  • 0回复

Python 3.5 协程究竟是个啥 (2)

楼层直达
级别: 论坛版主

2016-09-28 Python开发者

英文:snarky
译文:Yushneng
链接:www.jianshu.com/p/8cd05a23822e

async 和 await 是如何运作的

Python 3.4 中的方式

在 Python 3.3 中出现的生成器与之后以 asyncio 的形式出现的事件循环之间,Python 3.4 通过并发编程的形式已经对异步编程有了足够的支持。异步编程简单来说就是代码执行的顺序在程序运行前是未知的(因此才称为异步而非同步)。并发编程是代码的执行不依赖于其他部分,即便是全都在同一个线程内执行(并发不是并行)。例如,下面 Python 3.4 的代码分别以异步和并发的函数调用实现按秒倒计时。

import asyncio

# Borrowed from http://curio.readthedocs.org/en/latest/tutorial.html.
@asyncio.coroutine
def countdown(number, n):
    while n > 0:
        print('T-minus', n, '({})'.format(number))
        yield from asyncio.sleep(1)
        n -= 1

loop = asyncio.get_event_loop()
tasks = [
    asyncio.ensure_future(countdown("A", 2)),
    asyncio.ensure_future(countdown("B", 3))]
loop.run_until_complete(asyncio.wait(tasks))
loop.close()

Python 3.4 中,asyncio.coroutine 修饰器用来标记作为协程的函数,这里的协程是和asyncio及其事件循环一起使用的。这赋予了 Python 第一个对于协程的明确定义:实现了PEP 342添加到生成器中的这一方法的对象,并通过[collections.abc.Coroutine这一抽象基类]表征的对象。这意味着突然之间所有实现了协程接口的生成器,即便它们并不是要以协程方式应用,都符合这一定义。为了修正这一点,asyncio 要求所有要用作协程的生成器必须由asyncio.coroutine修饰。

有了对协程明确的定义(能够匹配生成器所提供的API),你可以对任何asyncio.Future对象使用 yield from,从而将其传递给事件循环,暂停协程的执行来等待某些事情的发生( future 对象并不重要,只是asyncio细节的实现)。一旦 future 对象获取了事件循环,它会一直在那里监听,直到完成它需要做的一切。当 future 完成自己的任务之后,事件循环会察觉到,暂停并等待在那里的协程会通过send()方法获取future对象的返回值并开始继续执行。

以上面的代码为例。事件循环启动每一个 countdown() 协程,一直执行到遇见其中一个协程的 yield from 和 asyncio.sleep() 。这样会返回一个 asyncio.Future对象并将其传递给事件循环,同时暂停这一协程的执行。事件循环会监控这一future对象,直到倒计时1秒钟之后(同时也会检查其它正在监控的对象,比如像其它协程)。1秒钟的时间一到,事件循环会选择刚刚传递了future对象并暂停了的 countdown() 协程,将future对象的结果返回给协程,然后协程可以继续执行。这一过程会一直持续到所有的 countdown() 协程执行完毕,事件循环也被清空。稍后我会给你展示一个完整的例子,用来说明协程/事件循环之类的这些东西究竟是如何运作的,但是首先我想要解释一下async和await。

Python 3.5 从 yield from 到 await

在 Python 3.4 中,用于异步编程并被标记为协程的函数看起来是这样的:

# This also works in Python 3.5.
@asyncio.coroutine
def py34_coro():
    yield from stuff()

Python 3.5 添加了types.coroutine 修饰器,也可以像 asyncio.coroutine 一样将生成器标记为协程。你可以用 async def 来定义一个协程函数,虽然这个函数不能包含任何形式的 yield 语句;只有 return 和 await 可以从协程中返回值。

async def py35_coro():
    await stuff()

虽然 async 和 types.coroutine 的关键作用在于巩固了协程的定义,但是它将协程从一个简单的接口变成了一个实际的类型,也使得一个普通生成器和用作协程的生成器之间的差别变得更加明确(inspect.iscoroutine() 函数 甚至明确规定必须使用 async 的方式定义才行)。

你将发现不仅仅是 async,Python 3.5 还引入 await 表达式(只能用于async def中)。虽然await的使用和yield from很像,但await可以接受的对象却是不同的。await 当然可以接受协程,因为协程的概念是所有这一切的基础。但是当你使用 await 时,其接受的对象必须是awaitable 对象:必须是定义了__await__()方法且这一方法必须返回一个不是协程的迭代器。协程本身也被认为是 awaitable 对象(这也是collections.abc.Coroutine 继承 collections.abc.Awaitable的原因)。这一定义遵循 Python 将大部分语法结构在底层转化成方法调用的传统,就像 a + b 实际上是a.__add__(b) 或者 b.__radd__(a)。

yield from 和 await 在底层的差别是什么(也就是types.coroutine与async def的差别)?让我们看一下上面两则Python 3.5代码的例子所产生的字节码在本质上有何差异。py34_coro()的字节码是:

>>> dis.dis(py34_coro)
  2           0 LOAD_GLOBAL              0 (stuff)
              3 CALL_FUNCTION            0 (0 positional, 0 keyword pair)
              6 GET_YIELD_FROM_ITER
              7 LOAD_CONST               0 (None)
             10 YIELD_FROM
             11 POP_TOP
             12 LOAD_CONST               0 (None)
             15 RETURN_VALUE

py35_coro()的字节码是:

>>> dis.dis(py35_coro)
  1           0 LOAD_GLOBAL              0 (stuff)
              3 CALL_FUNCTION            0 (0 positional, 0 keyword pair)
              6 GET_AWAITABLE
              7 LOAD_CONST               0 (None)
             10 YIELD_FROM
             11 POP_TOP
             12 LOAD_CONST               0 (None)
             15 RETURN_VALUE

忽略由于py34_coro()的asyncio.coroutine 修饰器所带来的行号的差别,两者之间唯一可见的差异是GET_YIELD_FROM_ITER操作码 对比GET_AWAITABLE操作码。两个函数都被标记为协程,因此在这里没有差别。GET_YIELD_FROM_ITER 只是检查参数是生成器还是协程,否则将对其参数调用iter()方法(只有用在协程内部的时候yield from所对应的操作码才可以接受协程对象,在这个例子里要感谢types.coroutine修饰符将这个生成器在C语言层面标记为CO_ITERABLE_COROUTINE)。

但是 GET_AWAITABLE的做法不同,其字节码像GET_YIELD_FROM_ITER一样接受协程,但是不接受没有被标记为协程的生成器。就像前面讨论过的一样,除了协程以外,这一字节码还可以接受awaitable对象。这使得yield from和await表达式都接受协程但分别接受一般的生成器和awaitable对象。

你可能会想,为什么基于async的协程和基于生成器的协程会在对应的暂停表达式上面有所不同?主要原因是出于最优化Python性能的考虑,确保你不会将刚好有同样API的不同对象混为一谈。由于生成器默认实现协程的API,因此很有可能在你希望用协程的时候错用了一个生成器。而由于并不是所有的生成器都可以用在基于协程的控制流中,你需要避免错误地使用生成器。但是由于 Python 并不是静态编译的,它最好也只能在用基于生成器定义的协程时提供运行时检查。这意味着当用types.coroutine时,Python 的编译器将无法判断这个生成器是用作协程还是仅仅是普通的生成器(记住,仅仅因为types.coroutine这一语法的字面意思,并不意味着在此之前没有人做过types = spam的操作),因此编译器只能基于当前的情况生成有着不同限制的操作码。

关于基于生成器的协程和async定义的协程之间的差异,我想说明的关键点是只有基于生成器的协程可以真正的暂停执行并强制性返回给事件循环。你可能不了解这些重要的细节,因为通常你调用的像是asyncio.sleep() function 这种事件循环相关的函数,由于事件循环实现他们自己的API,而这些函数会处理这些小的细节。对于我们绝大多数人来说,我们只会跟事件循环打交道,而不需要处理这些细节,因此可以只用async定义的协程。但是如果你和我一样好奇为什么不能在async定义的协程中使用asyncio.sleep(),那么这里的解释应该可以让你顿悟。

总结

让我们用简单的话来总结一下。用async def可以定义得到协程。定义协程的另一种方式是通过types.coroutine修饰器 — 从技术实现的角度来说就是添加了 CO_ITERABLE_COROUTINE标记 — 或者是collections.abc.Coroutine的子类。你只能通过基于生成器的定义来实现协程的暂停。

awaitable 对象要么是一个协程要么是一个定义了__await__()方法的对象 — 也就是collections.abc.Awaitable — 且__await__()必须返回一个不是协程的迭代器。await表达式基本上与yield from相同但只能接受awaitable对象(普通迭代器不行)。async定义的函数要么包含return语句 — 包括所有Python函数缺省的return None — 和/或者 await表达式(yield表达式不行)。async函数的限制确保你不会将基于生成器的协程与普通的生成器混合使用,因为对这两种生成器的期望是非常不同的。

将 async/await 看做异步编程的 API

我想要重点指出的地方实际上在我看David Beazley’s Python Brasil 2015 keynote之前还没有深入思考过。在他的演讲中,David 指出 async/await 实际上是异步编程的 API (他在 Twitter 上向我重申过)。David 的意思是人们不应该将async/await等同于asyncio,而应该将asyncio看作是一个利用async/await API 进行异步编程的框架。

David 将 async/await 看作是异步编程的API创建了 curio 项目来实现他自己的事件循环。这帮助我弄清楚 async/await 是 Python 创建异步编程的原料,同时又不会将你束缚在特定的事件循环中也无需与底层的细节打交道(不像其他编程语言将事件循环直接整合到语言中)。这允许像 curio 一样的项目不仅可以在较低层面上拥有不同的操作方式(例如 asyncio 利用 future 对象作为与事件循环交流的 API,而 curio 用的是元组),同时也可以集中解决不同的问题,实现不同的性能特性(例如 asyncio 拥有一整套框架来实现运输层和协议层,从而使其变得可扩展,而 curio 只是简单地让用户来考虑这些但同时也让它运行地更快)。

考虑到 Python 异步编程的(短暂)历史,可以理解人们会误认为 async/await == asyncio。我是说asyncio帮助我们可以在 Python 3.4 中实现异步编程,同时也是 Python 3.5 中引入async/await的推动因素。但是async/await 的设计意图就是为了让其足够灵活从而不需要依赖asyncio或者仅仅是为了适应这一框架而扭曲关键的设计决策。换句话说,async/await 延续了 Python 设计尽可能灵活的传统同时又非常易于使用(实现)。

我对未来的希望和梦想

现在我理解了 Python 中的异步编程是如何运作的了,我想要一直用它!这是如此绝妙的概念,比你之前用过的线程好太多了。但是问题在于 Python 3.5 还太新了,async/await也太新了。这意味着还没有太多库支持这样的异步编程。例如,为了实现 HTTP 请求你要么不得不自己徒手构建 ,要么用像是 aiohttp 之类的框架 将 HTTP 添加在另外一个事件循环的顶端,或者寄希望于更多像hyper 库一样的项目不停涌现,可以提供对于 HTTP 之类的抽象,可以让你随便用任何 I/O 库 来实现你的需求(虽然可惜的是 hyper目前只支持 HTTP/2)。

对于我个人来说,我希望更多像hyper一样的项目可以脱颖而出,这样我们就可以在从 I/O中读取与解读二进制数据之间做出明确区分。这样的抽象非常重要,因为Python多数 I/O 库中处理 I/O 和处理数据是紧紧耦合在一起的。Python 的标准库 http就有这样的问题,它不提供 HTTP解析而只有一个连接对象为你处理所有的 I/O。而如果你寄希望于requests可以支持异步编程,那你的希望已经破灭了,因为 requests 的同步 I/O 已经烙进它的设计中了。Python 在网络堆栈上很多层都缺少抽象定义,异步编程能力的改进使得 Python 社区有机会对此作出修复。我们可以很方便地让异步代码像同步一样执行,这样一些填补异步编程空白的工具可以安全地运行在两种环境中。

我希望 Python 可以让 async 协程支持 yield。或者需要用一个新的关键词来实现(可能像 anticipate之类?),因为不能仅靠async就实现事件循环让我很困扰。幸运的是,我不是唯一一个这么想的人,而且PEP 492的作者也和我意见一致,我觉得还是有机会可以移除掉这点小瑕疵。

结论

基本上 async 和 await 产生神奇的生成器,我们称之为协程,同时需要一些额外的支持例如 awaitable 对象以及将普通生成器转化为协程。所有这些加到一起来支持并发,这样才使得 Python 更好地支持异步编程。相比类似功能的线程,这是一个更妙也更简单的方法。我写了一个完整的异步编程例子,算上注释只用了不到100行 Python 代码 — 但仍然非常灵活与快速(curio FAQ 指出它比 twisted 要快 30-40%,但是要比 gevent 慢 10-15%,而且全部都是有纯粹的 Python 实现的;记住Python 2 + Twisted 内存消耗更少同时比Go更容易调试(https://news.ycombinator.com/item?id=10402307),想象一下这些能帮你实现什么吧!)。我非常高兴这些能够在 Python 3 中成为现实,我也非常期待 Python 社区可以接纳并将其推广到各种库和框架中区,可以使我们都能够受益于 Python 异步编程带来的好处!
快速回复

限200 字节
 
认证码:
上一个 下一个