Practical Asyncio - Part 1
4 min read
# Introduction
asyncio is the best way to write asynchronous code in Python. I wanted to learn more about the nuts and bolts of this library. This article aims to do that.
If you haven’t already, I recommend reading this high level overview of the library before jumping into this article.
A brief summary of the terminology:
Coroutine Function
This is simply the asynchronous function definition itself.
async def example_function(): print("Async Hello there!")Coroutine
This is the object returned by initializing the coroutine function
coroutine = example_function()This returns a coroutine object that is typed as Coroutine[Any, Any, None]. Take a look at the reference here for more information.
Event loop
Orchestrator of tasks. Kind of like schedulers in operating systems.
Task
A task is a coroutine tied to an event loop.
Future
A primitive which Task subclasses.
Callback
A function that is passed as an argument to another function, and called by it.
def f(p: Callable): print("Doing some work!") p() print("Called function")Here, p is the callback.
# Creating (and running) tasks
There are multiple ways to do this. The simplest way is to use the create_task method from asyncio.
import asyncio
async def sample_task(): print("Hello, World!") await asyncio.sleep(1) print("Goodbye, World!")
async def main(): task = asyncio.create_task(sample_task()) await task
asyncio.run(main())The above:
- Defines a
sample_taskcoroutine function, that prints, sleeps for 1 second, and prints again. - Defines a
mainmethod that creates a task out ofsample_taskcoroutine function. - Runs the coroutine in the event loop. This is the output
> Hello World!> Goodbye, World!Now, here’s a few interesting experiments:
- What happens when you comment out the
await task? I.e., just create the task, but don’t await it. - What happens when you add an
await asyncio.sleep(2)after the task has been created, while still commenting outawait task?
Awaiting the task here yields control back to the event loop. Then the event loop will decide at some point to run that function again to completion. If the task was never awaited, then the event loop determines that there are no other tasks to perform, and shuts down.
# Sleeping beauty
Have you ever seen a bunch of asyncio.sleep(0) and wondered how it magically makes your code run, but wondered why it happens?
Take a look at the below snippet (heavily inspired from here):
import asyncio
async def count_numbers(label: str, iterations: int): for i in range(iterations): print(f"Running from {label}: {i}") await asyncio.sleep(0.5)
async def main():
print("Running f1") f1 = count_numbers("f1", 3) f2 = asyncio.create_task(count_numbers("f2", 3))
await f1
print("Running f2") await f2
asyncio.run(main())When I first saw this, my first instinct was to say:
- The coroutine is awaited, and since coroutines do not yield to the event loop, the
count_numbersshould complete first. - The task begins execution only at
await f2
While the first statement is correct, when digging further into asyncio.sleep, you’ll notice this snippet:
async def sleep(delay, result=None): if delay <= 0: await __sleep0() return result
# rest of the execution##
@types.coroutinedef __sleep0(): yieldSo sleeping essentially yields to the event loop. You can verify this by commenting out the asyncio.sleep(0.5). Then all of f1 runs first, and then f2.
Regarding #2, awaiting the task ensures that it runs to completion. For example, running something like this:
async def main():
print("Running f1") f1 = count_numbers("f1", 3) f2 = asyncio.create_task(count_numbers("f2", 10))
await f1
print("Running f2")will result in the event loop shutting down, even though there are tasks that haven’t completed.
# Callables
In the below example, when await task is executed, a callback to resume running_code is added to some_coroutine’s list of callbacks.
async def running_code(): task = asyncio.create_task(some_coroutine()) await taskOne interesting point to note is that each task stores a list of callbacks, as opposed to just a single element.
import asyncio
async def some_expensive_task() -> int: print("Starting expensive task") await asyncio.sleep(2) print("Finished expensive task") return 42
async def task1(task: asyncio.Task): print("Task1 waiting for expensive task to complete") await task
async def task2(task: asyncio.Task): print("Task2 waiting for expensive task to complete") await task
async def task3(task: asyncio.Task): print("Task3 waiting for expensive task to complete") await task
async def main():
task = asyncio.create_task(some_expensive_task()) await asyncio.gather(task1(task), task2(task), task3(task))
asyncio.run(main())In the above snippet:
- A coroutine function
some_expensive_taskimitates an action that takes a while - DB operations, computations, etc. - Other coroutine functions that depend on the expensive task to complete -
task1,task2,task3. - The
mainloop schedulessome_expensive_taskto run and kicks off three other tasks at the same time.
Since three tasks are waiting for some_expensive_task, 3 callbacks will be added to some_expensive_task’s list of callbacks.
Try printing task._callbacks in each caller task’s definition to actually see what callbacks are stored.
Thanks for reading! Want to read more like this? Read Just take the L