5

It's my understanding that asyncio.gather is intended to run its arguments concurrently and also that when a coroutine executes an await expression it provides an opportunity for the event loop to schedule other tasks. With that in mind, I was surprised to see that the following snippet ignores one of the inputs to 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())

As I understand it, the following things happen:

  1. asyncio.run(main()) does any necessary global initialization of the event loop and schedules main() for execution.
  2. main() schedules asyncio.gather(...) for execution and waits for its result
  3. asyncio.gather schedules the executions of forever('a') and forever('b')
  4. whichever of the those executes first, they immediately await aprint() and give the scheduler the opportunity to run another coroutine if desired (e.g. if we start with 'a' then we have a chance to start trying to evaluate 'b', which should already be scheduled for execution).
  5. In the output we'll see a stream of lines each containing 'a' or 'b', and the scheduler ought to be fair enough that we see at least one of each over a long enough period of time.

In practice this isn't what I observe. Instead, the entire program is equivalent to while True: print('a'). What I found extremely interesting is that even minor changes to the code seem to reintroduce fairness. E.g., if we instead have the following code then we get a roughly equal mix of 'a' and 'b' in the output.

async def forever(s):
    while True:
        await aprint(s)
        await asyncio.sleep(1.)

Verifying that it doesn't seem to have anything to do with how long we spend in vs out of the infinite loop I found that the following change also provides fairness.

async def forever(s):
    while True:
        await aprint(s)
        await asyncio.sleep(0.)

Does anyone know why this unfairness might happen and how to avoid it? I suppose when in doubt I could proactively add an empty sleep statement everywhere and hope that suffices, but it's incredibly non-obvious to me why the original code doesn't behave as expected.

In case it matters since asyncio seems to have gone through quite a few API changes, I'm using a vanilla installation of Python 3.8.4 on an Ubuntu box.

Hans Musgrave
  • 6,613
  • 1
  • 18
  • 37
  • 1
    Does this answer your question? [Does await always give other tasks a chance to execute?](https://stackoverflow.com/questions/59996493/does-await-always-give-other-tasks-a-chance-to-execute) – MisterMiyagi Aug 17 '20 at 19:50
  • 1
    Does this answer your question? [How does asyncio actually work?](https://stackoverflow.com/questions/49005651/how-does-asyncio-actually-work) – MisterMiyagi Aug 17 '20 at 19:52
  • @MisterMiyagi Yes it does, thank you. When you know exactly what to search for everything's a duplicate on this site isn't it ;) – Hans Musgrave Aug 17 '20 at 19:52
  • Just recommending some duplicates – they're actually meant as suggestions, not as dupe hammers. ;) Feel free to pick what you see as appropriate. – MisterMiyagi Aug 17 '20 at 19:54
  • Oh sorry, it was clear that you weren't assigning the dupe hammer (especially without closure flags). I was moreso commenting on how knowing where to look and what to search for can be the entire battle, and I really appreciated the links. – Hans Musgrave Aug 17 '20 at 22:38

2 Answers2

6
  1. whichever of the those executes first, they immediately await aprint() and give the scheduler the opportunity to run another coroutine if desired

This part is a common misconception. Python's await doesn't mean "yield control to the event loop", it means "start executing the awaitable, allowing it to suspend us along with it". So yes, if the awaited object chooses to suspend, the current coroutine will suspend as well, and so will the coroutine that awaits it and so on, all the way to the event loop. But if the awaited object doesn't choose to suspend, as is the case with aprint, neither will the coroutine that awaits it. This is occasionally a source of bugs, as seen here or here.

Does anyone know why this unfairness might happen and how to avoid it?

Fortunately this effect is most pronounced in toy examples that don't really communicate with the outside world. And although you can fix them by adding await asyncio.sleep(0) to strategic places (which is even documented to force a context switch), you probably shouldn't do that in production code.

A real program will depend on input from the outside world, be it data coming from the network, from a local database, or from a work queue populated by another thread or process. Actual data will rarely arrive so fast to starve the rest of the program, and if it does, the starvation will likely be temporary because the program will eventually suspend due to backpressure from its output side. In the rare possibility that the program receives data from one source faster than it can process it, but still needs to observe data coming from another source, you could have a starvation issue, but that can be fixed with forced context switches if it is ever shown to occur. (I haven't heard of anyone encountering it in production.)

Aside from bugs mentioned above, what happens much more often is that a coroutine invokes CPU-heavy or legacy blocking code, and that ends up hogging the event loop. Such situations should be handled by passing the CPU/blocking part to run_in_executor.

user4815162342
  • 141,790
  • 18
  • 296
  • 355
1

I would like to draw attention to PEP 492, that says:

await, similarly to yield from, suspends execution of [...] coroutine until [...] awaitable completes and returns the result data.

It uses the yield from implementation with an extra step of validating its argument.

Any yield from chain of calls ends with a yield. This is a fundamental mechanism of how Futures are implemented. Since, internally, coroutines are a special kind of generators, every await is suspended by a yield somewhere down the chain of await calls (please refer to PEP 3156 for a detailed explanation).

But in your case async def aprint() does not yield, that is, it does not call any event function like I/O or just await sleep(0), which, if we look at it's source code, just does yield:

@types.coroutine
def __sleep0():
    """Skip one event loop run cycle.

    This is a private helper for 'asyncio.sleep()', used
    when the 'delay' is set to 0.  It uses a bare 'yield'
    expression (which Task.__step knows how to handle)
    instead of creating a Future object.
    """
    yield


async def sleep(delay, result=None, *, loop=None):
    """Coroutine that completes after a given time (in seconds)."""
    if delay <= 0:
        await __sleep0()
        return result
...

Thus, because of the forever while True:, we could say, you make a chain of yield from that does not end with a yield.

alex_noname
  • 26,459
  • 5
  • 69
  • 86
  • 1
    The wording of the first sentence quoted from PEP 492 is a bit sloppy. To readers familiar with event loops "suspends execution" strongly suggests that `await` first suspends the coroutine, and then just schedules the sub-coroutine to run - which is precisely what the OP described in #4. A more appropriate description is one used in [PEP 380](https://www.python.org/dev/peps/pep-0380/)), which is that `await` and `yield from` _delegate_ execution to an awaitable/iterator. (Also see [refactoring principle](https://www.python.org/dev/peps/pep-0380/#the-refactoring-principle).) – user4815162342 Aug 17 '20 at 20:30
  • But in case `await foo()`, when `async def foo()` is inherently not a coroutine, because it does not make any `await` call within. Will the current coroutine be suspended and the sub-coroutine `foo()` scheduled? In my opinion, there will be a normal call as it is done in the case of the `@asyncio.coroutine` [decorator](https://github.com/python/cpython/blob/c3dd7e45cc5d36bbe2295c2840faabb5c75d83e4/Lib/asyncio/coroutines.py#L124) without passing control to event loop. – alex_noname Aug 18 '20 at 18:59
  • Correct, no "scheduling" will happen on until `foo()` (or one of its awaitees, and so on) chooses to suspend. This is why the PEP 492 wording is confusing, and the last paragraph is simply false - it's not true that "any `yield from` chain ends with a `yield`" - the OP wrote one that doesn't in the very question. And even if it does end with a `yield`, no suspension occurs until the first `yield` is actually encountered. – user4815162342 Aug 18 '20 at 19:04
  • And, in case you were wondering, the `async def` equivalent of a naked `yield` is `future = loop.create_future(); await future`. The execution will immedaitely drop to the event loop, and the current task will be scheduled to resume when someone calls `future.set_result()`, where the provided value will be returned by `await`. Also, since `yield` (equivalent to `yield None`) guarantees immediate resumption, a more precise `await` equivalent of the naked `yield` is: `future = loop.create_future(); loop.call_soon(future.set_result, None); await future`. – user4815162342 Aug 18 '20 at 19:06
  • I understand the last paragraph is too categorical, this is rather the preferred behavior. Thanks for the clarification – alex_noname Aug 18 '20 at 19:19