我所不能理解的Python中的Asyncio模块

Python部落(python.freelycode.com)组织翻译,禁止转载,欢迎转发。

写于2016年10月30日,星期日
最近我开始稍微深入研究Python的新模块asyncio。之所以这样做是因为我需要做一些事来更好的处理IO事件,并且我想应该尝试一下Python中的新热点。从该实践中主要了解到:这是个比我料想的复杂的多的系统,并且此时我确实不知道如何正确地使用它。
它的概念并不难理解,并且很多借鉴了Twisted,但是它有如此多的元素,以至于我不确定个别部分应该如何一起使用。由于我不够聪明来提出更好的建议,只是分享困惑我的问题,那么其他人可能能够使用它,有一定的能力理解它。
原语
asyncio在协程的帮助下应该实现异步IO。最初作为库围绕yield和yield表达式来实现,现在随着语言的发展复杂得多。以下是目前存在,你需要知道的:

  • 事件循环

  • 事件循环策略

  • awaitables

  • 协程函数

  • 旧式风格的协程函数

  • 协程

  • 协程包装器

  • 生成器

  • futures

  • 并行futures

  • 任务

  • 句柄

  • 执行器

  • 传输

  • 协议

另外,该语言增加了新的特殊方法:

  • __aenter____aexit__用于异步with块

  • __aiter__ __anext__用于异步迭代器(异步循环和异步推导式)。为了另找乐子,协议已经改变。在Python3.5中,它返回一个awaitable对象(一个协程),3.6中它将返回一个新的异步生成器。

  • __await__用于自定义awaitable对象

很少文档覆盖这些。为了更好的理解它们,我做了下面的笔记:
事件循环
asyncio的事件循环和你第一眼看到后预料的有点不一样。表面上看起来,每个线程都有一个事件循环,但是工作起来并不如此。下面是我认为它是如何工作的:

  • 主线程中调用asyncio.get_event_loop()时创建事件循环

  • 其他线程中调用asyncio.get_event_loop()引发运行时错误

  • 任何地方可以调用asyncio.set_event_loop()绑定一个事件循环到当前线程。asyncio.new_event_loop() 函数可以创建这个事件循环。事件循环没有绑定到当前线程也可以使用。

  • asyncio.get_event_loop()返回线程绑定的事件循环,不是返回当前运行的事件循环。

因为一些原因,这些行为的组合超级混乱。首先,你需要知道这些函数是基本事件循环策略的委托,它们在全局设置。默认绑定事件循环到线程。另外,理论上可以绑定事件循环到greenlet或与greenlet类似的。然而,重要的是要知道库代码不控制这个策略,并且asyncio将控制在线程内。
其次,根据策略asyncio不需要事件循环绑定到上下文。一个孤立的事件循环可以很好地工作。然而这是第一个问题,对于协程或类似的库代码不知道哪一个事件循环负责调度它。这意味着,如果在一个协程内部调用 asyncio.get_event_loop(),可能得不到运行的事件循环。这也是所有的API有一个可选的显式循环参数的原因。如下找出哪个协程正在运行一个无法调用的循环:
def get_task():
    loop = asyncio.get_event_loop()
    try:
        return asyncio.Task.get_current(loop)
    except RuntimeError:
        return None

loop需要被显示传递。还需要显式传递loop到库代码的各个地方,否则将发生奇怪的事情。不确定该设计的想法是什么,但是如果这些不固定(如get_event_loop()返回实际运行的循环),那么唯一的讲的通的其它变化是明确地不允许显式循环传递,并且要求它被绑定到当前上下文(线程等)。
由于事件循环策略没有为当前上下文提供一个标识,库不可能以任何方式键入当前上下文。也不允许回调勾住拆卸这个上下文,这进一步限制实际可以做的。
Awaitables和协程
在我看来,Python中最大的设计错误就是过多重载迭代器。如今它们不仅用于迭代也用于各种类型的协程。Python中迭代器最大的设计错误之一是StopIteration。这可能会导致令人非常沮丧的问题,一个地方异常可能某处会引发生成器或协程其它地方终止。这是如JinJa需要应对的一个长期的问题。模板引擎内部渲染为一个生成器,并且当一个模板因为一些原因引发StopIteration 渲染在那里终结。
Python慢慢地吸取重载系统的教训。首先,Python 3.中asyncio模块有些有重载,并且没有语言支持。因此它的所有方式是装饰和生成器。为了从支持中实现yield和其它的,StopIterationwas再次重载。这导致奇怪的行为像这:
>>> def foo(n):
...  if n in (0, 1):
...   return [1]
...  for item in range(n):
...   yield item * 2
...
>>> list(foo(0))
[]
>>> list(foo(1))
[]
>>> list(foo(2))
[0, 2]
没有错误,没有警告。只是不是你期望的行为。这是因为函数返回值,是生成器引发单参数StopIteration产生的,这不是迭代器协议得到的而仅仅在协程代码里处理。
3.5和3.6中有很多变化,因为现在迭代器有协程对象。通过包装生成器产生协程,而不是单独的对象直接创造协程。通过给函数加async前缀来实现。例如async def x()将产生协程。现在在3.6中,将有单独的async生成器引发AsyncStopIteration 把它分开。另外,Python3.5及更新版本,引入future(generator_stop),将引发RuntimeError ,如果代码在迭代步骤中引发StopIteration 。
我为什么提到这一切?因为旧的东西并没有真正消失。生成器依旧有send 和throw ,而且协程依旧大部分行为像生成器。目前你需要知道很多东西,因为向前进还要相当长的一段时间。
要统一这些重复,现在Python中有更多的概念:

  • awaitable:一个对象含有__await__方法。通过本地协程、旧风格协程和其它实现的。

  • coroutinefunction:一个函数返回一个本地协程。不要与返回一个协程的函数混淆。

  • a coroutine:一个本地协程。注意,通过目前文档据我所知,旧asyncio协程不认为是协程。至少inspect.iscoroutine 不认为它是一个协程。然而,它由future或awaitable分支得到。

特别令人困惑的是asyncio.iscoroutinefunction inspect.iscoroutinefunction做不同的事情。而inspect.iscoroutineinspect.iscoroutinefunction相同。注意,即使在类型检查中不知道asycnio遗留的协程函数,但显然它们意识到了,当检查awaitable状态时,即使它不符合__await__
协程包装器
只要运行async def ,Python调用线程本地协程包装。通过sys.set_coroutine_wrapper设置,而且它是一个函数可以包装这。看起来像这样:
>>> import sys
>>> sys.set_coroutine_wrapper(lambda x: 42)
>>> async def foo():
...  pass
...
>>> foo()
__main__:1: RuntimeWarning: coroutine 'foo' was never awaited
42
在这种情况下,我从来不调用原函数,并且只是给你一个提示这能够做什么。据我所知,这总是线程本地,因此如果你交换事件循环策略,需要弄清楚如何用相同的上下文分别构成协程包装同步。新产生的线程将不会从父线程继承该标志。
这不要和asyncio协程包装代码混淆。
Awaitables 和 Futures
有些事情是awaitables。在我看来,下面的事情是awaitable:

  • 本地协程

  • CO_ITERABLE_COROUTINE标志的生成器(将覆盖这)

  • __await__方法的对象

基本上所有对象有__await__方法,除了生成器因为遗留原因没有。CO_ITERABLE_COROUTINE标志来自哪里?它来自协程装饰器(与sys.set_coroutine_wrapper混用)@asyncio.coroutine。通过间接方式,使用types.coroutine(与types.CoroutineTypeasyncio.coroutine混用)装饰生成器,用附加标识CO_ITERABLE_COROUTINE将重新创建内部代码对象。
所以现在我们知道这些是什么,什么是future?首先,我们需要澄清一件事:Python3中实际上有两种future类型(完全不兼容)asyncio.futures.Futureconcurrent.futures.Future。一个先于另一个,但是asyncio中它们两个都在使用。例如asyncio.run_coroutine_threadsafe()将分发一个线程给一个运行在另外线程的事件循环,但它将返回一个concurrent.futures.Future对象而不是一个asyncio.futures.Future 对象。这是明智的做法,因为只有concurrent.futures.Future对象是线程安全的。
现在我们知道有两个不兼容的future应该清楚asyncio中是哪一个。老实说,我不完全肯定它们的差异是什么,但是暂且我称之为“最终”。对象最终将有一个值,并且当它仍然在计算时,你可以对最终结果进行一些处理。这些变化被称为延迟,其它的被称为守时。我不懂它们之间确切的区别。
使用future可以干什么?可以绑定回调,它准备好时将被调用一次或者绑定回调,future失败时被调用。另外,可以等待(它实现了__await__ 因此可以等待),future可以被取消。
如何得到这个future?通过一个awaitable对象调用asyncio.ensure_future。这也将使一个好的旧生成器转变成这个future。如果你阅读这个文档,你将看到 asyncio.ensure_future实际上返回一个Task。那么什么是task?
任务
task对象是future对象的子类,将协程封装成task。task工作起来很像future,但是它有一些额外的方法来提取包含协程的当前栈。前面已经提到任务了,主要通过Task.get_current指明事件循环正在做什么。
如何取消task和future的工作是不同的,但这超出了本文的范围。取消的最大敌人仍然是它自己。协程中,你知道目前正在运行的task,可以通过提到的Task.get_current 得到它,但是这需要知道你分发的事件循环是不是线程绑定的一个。
协程不可能知道哪一个循环伴随它。Task通过公共API也不提供这些信息。然而,如果你确实设法弄清一个task,目前可以访问task._loop来找回事件循环。
句柄
除此以外还有句柄。句柄是等待执行的不透明对象,不可以等待,但它们可以取消。尤其是你用call_soon或者call_soon_threadsafe计划执行调用 ,得到句柄可以使用它取消执行,但不可以等待调用生效。
执行器
由于你可以有多个事件循环,但看不出来如何每个线程使用多个事件循环,明显假设是常见的设置有N个线程,并且每个线程有一个事件循环。如何通知别的事件循环做些事?不可以安排回调函数到别的线程的事件循环中并且得到结果。因此你需要使用执行器来代替。
执行器来自concurrent.futures ,它们允许你安排工作到线程,它本身不是事件。例如如果你使用run_in_executor在事件循环调度函数在别的线程被调用 。结果返回asyncio协程而不是像 run_coroutine_threadsafe 返回目前的协程。我还没有足够的才智弄明白这些API为什么存在,你会怎样在什么时候用哪一个。文档表明执行器用于构造多进程的东西。
传输和协议
我总是认为这些是困惑的东西,但它基本上是照搬Twisted中的概念。如果你想理解它们,阅读这些文档。
如何使用asyncio
目前我们大致了解了asyncio,我发现一些模式,人们写asyncio代码时似乎使用它们:

  • 传递事件循环到所有的协程。似乎部分团体这么做。让协程知道哪一个循环将安排它,使得协程知道它的任务。

  • 你需要循环绑定到线程。那也需要协程知道。理想情况下,两者都支持。

  • 如果你想使用上下文数据(如线程本地),你现在有点儿不走运了。最流行的解决方法显然是atlassian的aiolocals ,它基本上要求你手动传播上下文信息生成到协程,由于解释器不支持这。这意味着如果你有一个实用库生成协程,那么将失去上下文。

  • 忽略Python中已经存在的旧式协程。使用3.5中新的关键字async def。旧版本没有异步上下文管理,这表现为资源管理很有必要。

  • 学会用重启事件循环进行清理。这件事比我希望的花了我更长的事件来实现,但是处理清理逻辑最好的方式是编写异步代码重启事件循环几次,直到没有挂起。因为没有通用模式来处理这,有时你最终以讨厌的解决方法结束。如aiohttp的web支持也是这种模式,因此如果你想组合两个清理逻辑,可能必须重新实现它提供的实用助手,因为助手完全破坏了循环。这不是我见过的第一个这样做的库。:(

  • 运用子进程不明显。需要在主线程中有一个事件循环,我想是监听信号事件,然后分发给其它事件循环。这要求循环通过asyncio.get_child_watcher().attach_loop(...)通知。

  • 编写同时支持异步和同步的代码,注定要失败。当你开始变聪明并尝试在同一个对象支持with async with时,它也会很快变得危险。

  • 如果你想给一个协程更好的名字来找出它为什么不是可等待的,设置__name__没有用。需要设置__qualname__,它是错误消息打印机使用的。

  • 有时内部类型交互会让你心烦意乱。尤其是 asyncio.wait()函数会确保一切传递是future,这意味着如果你传递协程,你将很难找出协程结束还是待定,因为输入对象与输出对象不再匹配。在这种情况下,唯一真正明知的做法是确保前期一切都是future。

上下文数据
除了极度复杂和我缺乏对如何更好地编写API的理解,我最大的问题是完全缺乏对本地上下文数据的考虑。这是现在Node社区能学到的东西。continuation-local-storage已经存在,但太晚被接受为实施。可持续本地存储和相似概念在并发环境中一般被用于执行安全策略,信息的损坏会导致严重的安全问题。
事实上,Python甚至没有任何存储。我深入研究这主要是因为调查asyncio如何更好地支持Sentry的面包屑导航,我没有看到一个理智的方法去做。asyncio中没有上下文数据的概念,没有方法用通用代码中指明你正在使用的事件循环是哪个,没有猴子补丁这个信息将不可用。
Node目前正在经历寻找解决这个问题的长期解决方案的过程。这不是一个被忽略的问题,这是个在所有生态系统中反复出现的问题。JavaScript、Python和.NET环境都有这个问题。这个问题被称为异步上下文传播,并且解决方案很多。在Go语言中,需要使用上下文包,并且显式传递到所有goroutines(不是一个完美的解决方案,但至少有一个)。.NET用本地调用上下文的形式是最好的解决方案。它可以是一个线程上下文、一个网络请求上下文、或类似的东西,但是如果不抑制它会自动传播。这是追求目标的黄金准则。微软解决这一问题,已经超过15年。
个人想法

复杂的东西越来越复杂。我没有才智轻松使用asyncio。随着所有语言变化,它需要不断更新知识,并且它使语言极大地复杂化。令人印象深刻的是,一个生态系统围绕着它不断发展,但是我无能为力,只有这样的印象,距离它成为一个特别愉快和稳定的开发经验需要很长一段时间。
3.5(实际新协程对象)很棒。尤其是我希望在早期版本出现的变化。在我心中,重载生成器到协程的整个混乱是一个错误。至于asyncio中的任何内容我都不确定。这是个非常复杂的事情并且内部超级混乱。很难理解它是如何工作的所有细节。当传递一个生成器,当它成为一个真正的协程,什么是future,什么是任务,循环如何工作,这些甚至没有到实际的IO部分。
最糟糕的是asyncio不是特别快。David Beazley的现场演示asyncio的替代品有它两倍快。巨大的复杂性很难理解。我不知道该怎么想,但是我至少知道,我没有理解asyncio到足够自信给,人建议如何为它结构设计规范的地步。

英文原文:http://lucumr.pocoo.org/2016/10/30/i-dont-understand-asyncio/
译者:毛茸茸的向日葵
 

2月15日11:00到13:00网站停机维护,13:00前恢复
iPy智能助手 双击展开
查看更多聊天记录
(Ctrl+回车)换行