在 Python 中进行协程尾调用时,使用协程还是避免协程更 Pythonic(and/or 性能)?

Is it more Pythonic (and/or performant) to use or to avoid coroutines when making coroutine tail calls in Python?

在 Python 3.5+ 中,我经常遇到这样的情况:我有很多嵌套协程只是为了调用一些深度协程的东西,其中 await 只是出现在尾部调用大部分函数,​​如下所示:

import asyncio

async def deep(time):
    await asyncio.sleep(time)
    return time

async def c(time):
    time *= 2
    return await deep(time)

async def b(time):
    time *= 2
    return await c(time)

async def a(time):
    time *= 2
    return await b(time)

async def test():
    print(await a(0.1))

loop = asyncio.get_event_loop()
loop.run_until_complete(test())
loop.close()

这些函数 abc 可以写成 return 协程的常规函数​​,而不是协程本身,如下所示:

import asyncio

async def deep(time):
    await asyncio.sleep(time)
    return time

def c(time):
    time *= 2
    return deep(time)

def b(time):
    time *= 2
    return c(time)

def a(time):
    time *= 2
    return b(time)

async def test():
    print(await a(0.1))

loop = asyncio.get_event_loop()
loop.run_until_complete(test())
loop.close()

哪种方式更Pythonic?哪种方式性能更高?哪种方式以后别人维护起来更方便?

编辑 — 绩效衡量

作为性能测试,我从 deep 中删除了 await asyncio.sleep(time) 行并计时了 await a(0.1) 的 1,000,000 次迭代。在我使用 CPython 3.5.2 的测试系统上,第一个版本花费了大约 2.4 秒,第二个版本花费了大约 1.6 秒。所以看起来让所有东西都成为协程可能会有性能损失,但这肯定不是一个数量级。也许具有更多分析 Python 代码经验的人可以创建适当的基准并明确解决性能问题。

使用第一个:您不仅可以明确显示可以暂停代码的位置(放置 await 的位置),而且可以获得所有相关的好处,例如显示有用的执行流程的回溯。

要查看差异,请更改 deep 协程以抛出一些错误:

async def deep(time):
    await asyncio.sleep(time)
    raise ValueError('some error happened')
    return time

对于第一个片段,您将看到此输出:

Traceback (most recent call last):
  File ".\tmp.py", line 116, in <module>
    loop.run_until_complete(test())
  File ".\Python36\lib\asyncio\base_events.py", line 466, in run_until_complete
    return future.result()
  File ".\tmp.py", line 113, in test
    print(await a(0.1))
  File ".\tmp.py", line 110, in a
    return await b(time)
  File ".\tmp.py", line 106, in b
    return await c(time)
  File ".\tmp.py", line 102, in c
    return await deep(time)
  File ".\tmp.py", line 97, in deep
    raise ValueError('some error happened')
ValueError: some error happened

但仅针对第二个片段:

Traceback (most recent call last):
  File ".\tmp.py", line 149, in <module>
    loop.run_until_complete(test())
  File ".\Python36\lib\asyncio\base_events.py", line 466, in run_until_complete
    return future.result()
  File ".\tmp.py", line 146, in test
    print(await a(0.1))
  File ".\tmp.py", line 130, in deep
    raise ValueError('some error happened')
ValueError: some error happened

如您所见,第一个回溯可以帮助您查看 "real"(并且很有帮助)执行流程,而第二个则不能。

第一种编写代码的方式也更易于维护:假设您曾经理解 b(time) 还应该包含一些异步调用,例如 await asyncio.sleep(time)。在第一个片段中,可以直接放置此调用而无需任何其他更改,但在第二个片段中,您将不得不重写代码的许多部分。

这是 "is it Pythonic?" 实际上 不是 意见型问题的罕见情况之一。尾调用优化正式非 Pythonic:

So let me defend my position (which is that I don't want [tail recursion elimination] in the language). If you want a short answer, it's simply unpythonic - the BDFL

(see also)