A caveat with awaiting futures that complete instantly

Recently I came across a bug in my code that took me a very long time to find. I believe that there’s some gap in my understanding of asyncio, and I would like to eliminate it.

There may be some Futures that complete instantly. For example, a coroutine like this one:

async def evil_coro():
    return

When working with them you can stumble upon some unexpected behaviour:

import asyncio

async def main():
    asyncio.create_task(ticker())
    while True:
        await evil_coro()

async def evil_coro():
    return

async def ticker():
    while True:
        print("Tick")
        await asyncio.sleep(0.5)

asyncio.run(main())

If you run this code you’ll notice the ticker never gets a chance to run. This seems very unexpected to me, because the while True loop is going, and there’s an await inside it which we hit over and over again. I’d think the asyncio loop would switch the control to the ticker task, at least sometimes. But nope.

The way you fix it is by adding an asyncio.sleep(0) inside the while True loop. This will help the asyncio loop switch the running task.

You can reproduce the same behaviour if you you’re awaiting an already complete Future in a loop:

fut = asyncio.Future()
fut.set_result(None)
while True:
    await fut

Is there something wrong with my expectations? And how do I avoid bugs that stem from this in the future?

2 Likes

Welcome to cooperative multitasking, where a single spinning task can prevent everything else from running. I’m not entirely surprised that awaiting an already-completed task doesn’t cause a task switch.

So the real question is: What can be done to prevent this? Or if you prefer: How can we get a bit of the safety of preemptive multitasking in an otherwise-cooperative system? One way - and probably the simplest - would be exactly what you said: add a sleep(0) into anything that could potentially be a tight loop.

1 Like

As @Rosuav also pointed out:

while True:
    await evil_coro()

will in this case behave as a tight loop because evil_coro doesn’t contain or generate any awaitables.
So, it’s easy to work-around that by always making sure that async functions await something and thus yield back control to the eventloop.

But I think this is still a bug – or at least a flaw – in the underlying implementation of coroutines: “evil_coro” is a perfectly valid coroutine (just like the variant with the Futures), and imo that’s all you should need to know about this, but its behavior is not consistent with “normal” coroutines, since it basically just works as a regular generator or as a regular function rather than a coroutine. It would be more consistent if every “await” statement guaranteed that at that point control is yielded back at least once to the eventloop - but this is not what happens here.

1 Like

Please check my understanding: the await doesn’t prompt a task-switch, it merely signals that the function being called may not complete immediately? Also, if evil_coro actually did a bunch of work, but never had an await, we would see the same behavior?

Perhaps instead of:

while True:

the idiom should be something like:

while looping():
    await blahblah()

where looping is

async def looping():
    await asyncio.sleep(0)

I’m trying to think of easy and precise ways of identifying those “tight loops”. Do you know any? The example I gave is probably conspicuous enough once you have stumbled upon this unexpected behaviour at least once. However, you can find examples where it’s harder to see this. For instance:

while True:
    await evil_coro()
    await evil_coro1()
    await evil_coro2()

We have three awaits inside a loop. You’d hope at least one of them is not “evil”, right?? But what if they all are?.. In fairness, this case should be pretty rare. However, when this actually happens you can spend a lot of time debugging it.

Perhaps, always putting an asyncio.sleep(0) in all loops will solve this. But that’d be very ugly.

What if you’re awaiting some function that somebody else wrote? You’ll need to check it, and then also check that the functions awaited inside aren’t “evil”, and so on. This is a massive amount of work.

Yes and yes. (Obviously this depends a bit on implementation; it’s theoretically possible to design an event loop for asyncio that DOES guarantee a task switch at every await point, but I think that would come with enough unnecessary overhead that it wouldn’t be worth doing.) It’s worth thinking about async functions as though they’re generators - in fact, they largely are. Would any values be yielded here? If not, there’s no guarantee that it’ll actually block or task switch.

Yeah, that’s a decent option. Although just to nitpick, it would need to be while await looping(): (only mentioning in case anyone copies and pastes the code). Whatever’s clearest for your code.

Yes - exactly. That’s why I consider it at least a flaw in the underlying implementation. I don’t think the PEPs about this really considered this edge case. Using the while await looping() kind of idiom it would still not be too hard to work around this, I think, but that’s a bit of a pain. Also, for “normal” coroutines it’s not necessary (while True works fine for those).

According to PEP429

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).

So, this doesn’t seems true in this (edge) case, and that’s the inconsistency. There seems to be a hidden assumption that all coroutines will always (or need always to) have some further “await” call.

Hm, going from memory having implemented my own toy event loop, I don’t think this is true. I think this logic is somewhere in the interpreter machinery.

Is that relevant here? Is it relevant how a task switch is triggered? (Not a rhethorical question)

If any awaitable contains an implicit 1-bit counter, which starts as 0 and is set to 1 after any actual call, then it seems this could be done. It would return as done, when both the actual result is ready and the counter is 1, otherwise it would trigger a task switch. No?

Ah okay, my bad. Anyhow, it’s definitely the case with the default event loop.

IMO this isn’t a flaw with async/await, but with the tight loop. Normally, you would want to avoid any sort of tight loop, and instead return to the main event loop. Can you show what was happening in your code when you came across this?

1 Like

Yeah - that tight loop is problematic… and should always be called out. But in that case it would be nice if asyncio provide a way to do this (similar to having an asyncio.sleep function as separate from time.sleep) - a built-in way to do for instance

with await asyncio.looping():
    ...