asyncio 的默认调度程序什么时候公平?

Han*_*ave 5 python python-asyncio

据我了解,目的是同时asyncio.gather运行其参数,并且当协程执行等待表达式时,它为事件循环提供了安排其他任务的机会。考虑到这一点,我惊讶地发现以下代码片段忽略了.asyncio.gather

import asyncio                                                             
  
async def aprint(s):
    print(s)

async def forever(s):
    while True:
        await aprint(s)

async def main():
    await asyncio.gather(forever('a'), forever('b'))

asyncio.run(main())
Run Code Online (Sandbox Code Playgroud)

据我了解,会发生以下情况:

  1. asyncio.run(main()) 对事件循环进行任何必要的全局初始化并安排 main() 执行。
  2. main() 安排 asyncio.gather(...) 执行并等待其结果
  3. asyncio.gather 安排forever('a') 和forever('b') 的执行
  4. 无论其中哪一个先执行,它们都会立即等待 aprint() 并让调度程序有机会在需要时运行另一个协程(例如,如果我们从“a”开始,那么我们就有机会开始尝试评估“b”,这应该已安排执行)。
  5. 在输出中,我们将看到一串行,每行都包含“a”或“b”,并且调度程序应该足够公平,以便我们在足够长的时间内至少看到其中的一行。

实际上,这不是我所观察到的。相反,整个程序相当于while True: print('a'). 我发现非常有趣的是,即使对代码进行微小的更改似乎也会重新引入公平性。例如,如果我们使用以下代码,那么我们会在输出中得到大致相等的“a”和“b”混合。

async def forever(s):
    while True:
        await aprint(s)
        await asyncio.sleep(1.)
Run Code Online (Sandbox Code Playgroud)

验证它似乎与我们在无限循环中和在无限循环外花费的时间没有任何关系,我发现以下更改也提供了公平性。

async def forever(s):
    while True:
        await aprint(s)
        await asyncio.sleep(0.)
Run Code Online (Sandbox Code Playgroud)

有谁知道为什么会发生这种不公平现象以及如何避免它?我想,当有疑问时,我可以主动在各处添加一个空的睡眠语句,并希望这足够了,但对我来说,为什么原始代码的行为不符合预期,这对我来说非常不明显。

以防万一,因为 asyncio 似乎已经经历了相当多的 API 更改,我在 Ubuntu 机器上使用 Python 3.8.4 的普通安装。

use*_*342 7

  1. 无论哪一个先执行,它们都会立即执行await aprint(),并为调度程序提供运行另一个协程的机会(如果需要)

这部分是一个常见的误解。Python 的await意思并不意味着“让出对事件循环的控制”,它的意思是“开始执行等待,允许它一起挂起我们”。所以是的,如果等待的对象选择挂起,当前的协程也会挂起,等待它的协程也会挂起,依此类推,一直到事件循环。但是,如果等待的对象选择挂起(如 的情况)aprint,则等待它的协程也不会选择挂起。这有时是错误的来源,如此此处所示。

有谁知道为什么会发生这种不公平现象以及如何避免它?

幸运的是,这种效果在不真正与外界交流的玩具示例中最为明显。尽管您可以通过添加await asyncio.sleep(0)到战略位置来修复它们(甚至记录为强制上下文切换),但您可能不应该在生产代码中这样做。

真正的程序将依赖于来自外部世界的输入,无论是来自网络、本地数据库的数据,还是来自由另一个线程或进程填充的工作队列的数据。实际数据很少会如此快地到达而导致程序的其余部分陷入饥饿,如果确实如此,饥饿可能是暂时的,因为程序最终将由于其输出端的背压而挂起。在极少数情况下,程序从一个源接收数据的速度比处理数据的速度快,但仍然需要观察来自另一个源的数据,您可能会遇到饥饿问题,但如果出现这种情况,可以通过强制上下文切换解决显示发生。(我还没有听说有人在生产中遇到过它。)

除了上面提到的错误之外,更常见的情况是协程调用 CPU 密集型或遗留的阻塞代码,最终会占用事件循环。这种情况应该通过将 CPU/阻塞部分传递给 来处理run_in_executor