Python Asynchronous Programming Basics

Published: September 6, 2021

In the world of programming, we often encounter situations where tasks need to be performed simultaneously or where we want to run certain code while other code is waiting. This is where asynchronous programming comes into play. Asynchronously means we don't do things sequentially as we would in synchronous code.

A co-routine is a wrapped version of a function that allows it to run asynchronously. To create a co-routine, define a function and prepend the async keyword before its definition.

import asyncio
 
async def main():
    print('shav')

By writing async before the function definition, we create a wrapper around this function. Now when you call this function, it returns a co-routine object - this is just like a function and can be executed.

The Asynchronous Event Loop

To execute a co-routine, we await its execution. To use the await keyword, it needs to be inside of an asynchronous function. This means, this will NOT work:

await main()

The reason its not running our code is because we haven't created the async event loop. In Python, the event loop is a crucial construct that waits for and dispatches events within a program. We must start one in whatever thread we're running this asynchronous code in. To create an event loop and execute the co-routine, we use the asyncio.run() function:

asyncio.run(main())

What we did here is create the event loop with asyncio.run() and added the co-routine in and it ran. Another example with await:

async def foo(text):
    print(text)
    await asyncio.sleep(1)

Here we needed to await the asyncio.sleep(1) as it is required to run/execute a co-routine. asyncio.sleep(1) returns something similar to a co-routine. What if now we had:

import asyncio
 
async def main():
    print('shav')
    await foo('text')
    print('finished')
 
async def foo(text):
    print(text)
    await asyncio.sleep(1)
 
asyncio.run(main())
 

This will return: shav, text, finished

Concurrent Execution with Tasks

To prevent blocking of co-routines, we can create tasks for our co-routines. Tasks enable us to run multiple co-routines concurrently within the same event loop so that printing finished is not blocked by the foo() co-routine.

import asyncio
 
async def main():
    print('shav')
    task = asyncio.create_task(foo('text'))
    print('finished')
 
async def foo(text):
    print(text)
    await asyncio.sleep(1)
 
asyncio.run(main())
 

This will return: shav, finished, text

When this task is created, asyncio starts executing it as soon as it can, and allows other code to run while that task is stalling. While it is not running. It executed the finished statement before the foo(text). When the task was created, the main() function was still running.

Using await with Tasks

When the task was created, python cannot run the task until execution of whatever function/whatever it is doing takes a break and stops. With Asyncio, only 1 thing can happen at the exact same time. It is just, when something is not doing something, execution can be paused and given back to another function. We can await tasks to make sure it finishes:

import asyncio
 
async def main():
    print('shav')
    task = asyncio.create_task(foo('text'))
    await task
    print('finished')
 
async def foo(text):
    print(text)
    await asyncio.sleep(1)
 
asyncio.run(main())

This blocks us from moving forward in the program until the task is complete. This will return: shav, text, finished because we awaited the task.

Instead, if we added some sleep to the main function, we can see how the task behaves:

import asyncio
 
async def main():
    print('shav')
    task = asyncio.create_task(foo('text'))
    await asyncio.sleep(2)
    print('finished')
 
async def foo(text):
    print(text)
    await asyncio.sleep(1)
 
asyncio.run(main())
 

Here, as soon as we hit await asyncio.sleep(2) the task will start executing. Because we created the task, we run it as soon as we possibly we can. Since we are awaiting something else inside of the function we are currently running (main()), we have a delay. As soon as Asyncio sees the delay, it pauses the execution of the main() function and allows the execution of the task that wants to run. This means we don't have the processor waiting around doing nothing. Now we get: shav, finished, text

What if:

import asyncio
 
async def main():
    print('shav')
    task = asyncio.create_task(foo('text'))
    await asyncio.sleep(0.5)
    print('finished')
 
async def foo(text):
    print(text)
    await asyncio.sleep(10)
 
asyncio.run(main())
 

Here:

  1. As soon as we hit await asyncio.sleep(0.5), the task executes as soon as it can. Because we see the delay with await, we pause the execution of this function, and allow the task that wants to run to run.
  2. The task is allowed to runs
  3. Asyncio hits another await asyncio.sleep(10) inside the foo co-routine object. So it allows the executioner to give resource back to the other function.
  4. finished is printed

We have run things concurrently. We don't have to wait for it to be completely done before we carry on.

Working with Futures

When a co-routine returns a value, Python creates a future for it. A future acts like a promise or placeholder for the value that will be available in the future. Before accessing the value, we must ensure that the co-routine has finished execution, and this is where await comes into play.

import asyncio
 
async def fetch_data():
    print('start fetching')
    await asyncio.sleep(2)
    print('done fetching')
    return|{'data': 1}
 
async def print_numbers():
    for i in range(10):
    print(i)
    await asyncio.sleep(6.25)
 
async def main():
    task1 = asyncio.create_task(fetch_data())
    task2 = asyncio.create_task(print_numbers())
 

Here, when main() is ran, task1 will return a future. When you create a task, and the co-routine you defined returns a value, this creates a future.

async def main():
    task1 = asyncio.create_task(fetch_data())
    task2 = asyncio.create_task(print_numbers())
 
    value = await task1

By awaiting the task, we guarantee that the co-routine has finished execution, and we can now access its returned value.

Summary

  • async creates a co-routine, allowing concurrent execution of tasks.
  • To execute a co-routine, you need to await it inside of an async event loop - this is something we need to define
  • await keyword can only be used inside an async function - inside an actual co-routine.
  • To prevent blocking of co-routines, you create tasks and place your co-routine inside of it. This will add it to the event loop
    • When this task is created, asyncio starts executing it as soon as it can, and allows other code to run while that task is stalling.
    • Now we can run code concurrently
  • When you create a task and the co-routine you defined returns a value, this creates a future. Before you access the value, you would await that task:

resources: