pythonpython-asyncio

asyncio.create_task executed even without any awaits in the program


This is a followup question to What does asyncio.create_task() do?

There, as well in other places, asyncio.create_task(c) is described as "immediately" running the coroutine c, when compared to simply calling the coroutine, which is then only executed when it is awaited.

It makes sense if you interpret "immediately" as "without having to await it", but in fact a created task is not executed until we run some await (possibly for other coroutines) (in the original question, slow_coro started being executed only when we await fast_coro).

However, if we do not run any await at all, the tasks are still executed (only one step, not to completion) at the end of the program:

import asyncio

async def counter_loop(x, n):
    for i in range(1, n + 1):
        print(f"Counter {x}: {i}")
        await asyncio.sleep(0.5)
    return f"Finished {x} in {n}"


async def main():
    slow_task = asyncio.create_task(counter_loop("Slow", 4))
    fast_coro = asyncio.create_task(counter_loop("Fast", 2))
    print("Created tasks")
    for _ in range(1000):
        pass
    print("main ended")

asyncio.run(main())
print("program ended")

the output is

Created tasks
main ended
Counter Slow: 1
Counter Fast: 1
program ended

I am curious: why are the two created tasks executed at all if there was no await being run anywhere?


Solution

  • Let's try to put it in these words: the event loop enters execution with one task to be performed: the main(). When "main()" is complete, there are other two tasks ready to be processed - so before returning to main() caller, those are executed up the next await statement inside each one. (either an await or an async for or async with).

    For the next loop iteration, as the main task is "over", the loop cancels the remaining tasks, and shuts down. This happens because what signals the loop that the "main" task is over is a callback that is set when loop.run_until_complete (called by asyncio.run) is done: it is this callback that signals that the loop should stop. But the callback itself will be executed only on the next loop iteration, after the main co-routine is done. And the loop iteration, even though gets a mark that the asyncio loop should stop there, will only actually shutdown after running once over all pending tasks - this implies in advancing each created task to the next await point.

    This is done by throwing asyncio.CancelledError into the tasks code, at the await statement. So if you have a try/except/finally clause encompassing the await, you can still clean-up your task before the loop ends.

    All of that is not documented in "English" - it is rather the current code behavior in the asyncio implementation. One have to follow the code at the asyncio/base_events.py file to understand it.