What Actually Triggers the Asyncio Tasks?

Question:

Trying to understand python asyncio coming from some background on multithreading with concurrent.futures. Here is the sample script

#!/usr/bin/env python3
# encoding: utf-8
"""Sample script to test asyncio functionality."""

import asyncio
import logging
from time import sleep  # noqa
logging.basicConfig(format='%(asctime)s | %(levelname)s: %(message)s',
                    level=logging.INFO)

async def wait(i: int) -> None:
    """The main function to run asynchronously"""
    logging.info(msg=f'Entering wait {i}')
    await asyncio.sleep(5)
    logging.info(msg=f'Leaving wait {i}')  # This does not show because all pending tasks are SIGKILLed?

async def main() -> None:
    """The main."""
    [asyncio.create_task(
        coro=wait(i)) for i in range(10)]
    logging.info(msg='Created tasks, waiting before await.')
    sleep(5)  # This is meant to verify the tasks do not start by the create_task call.
    # What changes after the sleep command, i.e. here?
    # If the tasks did not start before the sleep, why would they start after the sleep?

if __name__ == '__main__':
    asyncio.run(main=main())

The technology stack, if relevant, is python 3.10 on Ubuntu 22.04. Here is my terminal output.

2023-05-03 12:30:45,297 | INFO: Created tasks, waiting before await.
2023-05-03 12:30:50,302 | INFO: Entering wait 0
2023-05-03 12:30:50,304 | INFO: Entering wait 1
2023-05-03 12:30:50,304 | INFO: Entering wait 2
2023-05-03 12:30:50,304 | INFO: Entering wait 3
2023-05-03 12:30:50,304 | INFO: Entering wait 4
2023-05-03 12:30:50,304 | INFO: Entering wait 5
2023-05-03 12:30:50,304 | INFO: Entering wait 6
2023-05-03 12:30:50,304 | INFO: Entering wait 7
2023-05-03 12:30:50,304 | INFO: Entering wait 8
2023-05-03 12:30:50,304 | INFO: Entering wait 9

So two related questions based on this snippet.

  1. What exactly is triggering the wait async task here (which just logs two lines at the console upon entry and exit)? Clearly, creating the tasks is not really making them run, as I am waiting for long enough after creating them in main. Even the timestamps show they are run after the blocking sleep in main.
    Yet, just as the main function seems to finish its sleep, and exit, the tasks seem to be triggered. Should not the main thread just exit at this point?
  2. The exit log is never printed (commented in the code). Does it mean the subprocess are just started after the main thread exits, and then immediately killed?
Asked By: Della

||

Answers:

Q1:

From docs:

asyncio.create_task(coro, *, name=None, context=None)

Wrap the coro coroutine into a Task and schedule its execution. Return the Task object.

The task is executed in the loop returned by get_running_loop(), RuntimeError is raised if there is no running loop in current thread.

And this scheduled task execution happens in so-called
Checkpoints – to quote from another great async library, trio’s docs:

A checkpoint is two things:

  1. A point where Trio checks for cancellation.
  2. A point where Trio scheduler checks if it’s good time to switch to another task.

Which is (most of time) what await keyword is.

(This need additional explanations to fully understand – it’s after Q2)


Q2:

To illustrate what happened in sequence after asyncio.create_task(~~):

  1. Tasks(Task is not a subprocess, but more roughly like coroutine – a suspendable function.) were just created and queued for execution.

  2. But after asyncio.create_task(~~) call you called Synchronous IO Operation time.sleep(5) and suspended(blocked) Main Thread for 5 seconds. Therefore, nothing happened – no tasks actually even started.

  3. After time.sleep(5) unblocks, scheduled Tasks all starts running and hits checkpoint await asyncio.sleep(5).

  4. But there’s no more await keyword in main() that would allow scheduler to switch to tasks and run. Therefore, after all Synchronous codes, main() finishes without giving back control to scheduler, non of created tasks ran yet.

  5. Since main() finished, now scheduler runs tasks that was scheduled long ago.

  6. All of each scheduled tasks runs and hit checkpoint await asyncio.sleep(5), yield control back to scheduler and awaiting for re-schedule at (current time + 5). Then scheduler runs next pending tasks, repeats this process.

  7. All tasks ran and is pending to be re-scheduled, but since main task main() already exited, scheduler cancels all remaining tasks.

  8. Now nothing is pending asyncio‘s loop stops and exit gracefully.

This can be demonstrated with a bit more logging/printing:

"""Sample script to test asyncio functionality."""

import asyncio
import time


async def wait(i: int) -> None:
    print(f"Task {i} started!")

    try:
        await asyncio.sleep(5)
    except (asyncio.CancelledError, KeyboardInterrupt):
        print(f"Task {i} canceled!")
        raise

    print(f"Task {i} done!")


async def main() -> None:
    """The main."""
    tasks = [asyncio.create_task(wait(i)) for i in range(10)]

    print("Blocking main thread for 5 sec!")
    time.sleep(5)

    print("End of main!")


if __name__ == '__main__':
    asyncio.run(main=main())
    print("asyncio loop stopped!")

Outputs:

Blocking main thread for 5 sec!
End of main!
Task 0 started!
Task 1 started!
Task 2 started!
Task 3 started!
Task 4 started!
Task 5 started!
Task 6 started!
Task 7 started!
Task 8 started!
Task 9 started!
Task 9 canceled!
Task 4 canceled!
Task 0 canceled!
Task 1 canceled!
Task 7 canceled!
Task 2 canceled!
Task 5 canceled!
Task 8 canceled!
Task 3 canceled!
Task 6 canceled!
asyncio loop stopped!

Try following above sequence with this!


Extra explanation

Usual bottleneck in Application/Script is not CPU, but is IO. IO includes writing, reading, waiting, etc. Accessing HDD or sending web request is a very IO-intensive workload which all CPU does is waiting.

To illustrate, IO works are basically like this:

"Hey OS, please do this IO works for me. Wake me up when it’s done."
Thread 1 goes to sleep

Some time later, OS punches Thread 1
"Your IO Operation is done, take this and get back to work."

This is why you see in many applications & frameworks uses Threading to improve throughput despite the existence of Global Interpreter Lock(GIL) limiting python code to be ran in only 1 thread at any given time.

GIL is released while waiting for OS’s interruption, allowing CPU do something more useful than waiting. Thus CPU can do other thread’s work meanwhile.

But thread isn’t free: despite much lighter than Process, it still generate quite a overhead creating threads.

Asynchronous IO Frameworks’ main idea is to benefit from blocking IO works(read, write, wait, etc..) out of synchronous code, but without that Thread overheads.

They use something called Awaitables instead of threads – which is roughly some sort of coroutines like futures you previously used.


Let me know if there’s any error – writing in mobile should’ve made quite some mistakes I think – double checked though!

Answered By: jupiterbjy