Asynchronous Programming with Asyncio

Asynchronous Programming with Asyncio

In the programming world, the concept of "non-blocking" is pervasive. JavaScript developers often use the term "asynchronous" because it is one of JavaScript's strengths. However, to truly understand asynchronous programming, it's essential to grasp the concepts of concurrent and parallel programming.

Concurrent Programming

When several independent entities are working simultaneously, the programming is concurrent. It doesn't necessarily mean that these tasks are running at the exact same time. Instead, it means that tasks are making progress over time by sharing resources, such as CPU time. The main advantage of concurrent programming is its robustness: if one process crashes, the rest of your program continues to function.

Parallel Programming

If an algorithm can divide its work into several parts, it is parallel. The more processors you have, the more you benefit from parallelism. Efficient parallel programming optimizes the resources of modern machines for better performance.

Illustrating Concurrency vs. Parallelism with Cooking

Concurrency Example:

Imagine you are preparing a meal where you need to grill some meat and make a sauce. You start by putting the meat on the barbecue. While the meat is grilling, you chop the tomatoes and other vegetables for the sauce. Then, you begin boiling the sauce while occasionally checking on the meat. Here, both tasks (grilling the meat and making the sauce) are in progress, but you are switching your attention between them. This represents concurrency.

Parallelism Example:

Now, let's say you have a friend to help you. While you focus on grilling the meat, your friend takes care of making the sauce. Both tasks are being done simultaneously without the need to switch attention between them. This represents parallelism.

What is Asynchronous Programming?

Asynchronous programming involves handling input/output (I/O) operations that occur outside your program, such as user input, printing to a terminal, reading from a socket, or writing to disk. The key characteristics of asynchronous I/O are:

  • The time taken by the operation is not CPU-dependent. Instead, it depends on factors like disk speed, network latency, and other external conditions.

  • The program cannot predict when the operation will end.

For services with significant I/O (like web servers, databases, and deployment scripts), optimizing these operations can greatly improve performance.

Let's see examples of blocking code and non-blocking code.

Example of Blocking and Non-blocking Code

Consider a simple program:

import time

def task():
    time.sleep(2)
    print("Hello")

for _ in range(3):
    task()

In this synchronous program, each task waits for the previous one to finish, causing delays.

Now, let's look at an asynchronous version using asyncio:

import asyncio

async def task():
    await asyncio.sleep(2)
    print("Hello")

async def main():
    tasks = [task() for _ in range(3)]
    await asyncio.gather(*tasks)

asyncio.run(main())

In this asynchronous program, tasks run concurrently, reducing the total execution time. Let's explore the components of asynchronous programming.

Components of Asynchronous Programming

Event loops, coroutines, and futures are the essential elements of an asynchronous Python program.

  • Event Loop: Manages task switching and execution flow, keeping track of tasks to be run asynchronously.

  • Coroutines: Special functions that can be paused and resumed, allowing other tasks to run during the wait. A coroutine specifies where in the function the task-switching event should take place, returning control to the event loop. Coroutines are typically created by the event loop and stored internally in a task queue.

  • Futures: Placeholders for results from coroutines, storing the result or exceptions. As soon as the event loop initiates a coroutine, a corresponding future is created that stores the result of the coroutine, or an exception if one was thrown during the coroutine’s execution.

With the crucial parts of asynchronous programming in Python explained, let's write some code.

Writing Asynchronous Code

Now that you understand the asynchronous programming pattern, let's write a little script and analyze the execution. Here’s a simple asynchronous script:

import asyncio

async def task():
    await asyncio.sleep(2)
    print("Hello")

async def main():
    tasks = [task() for _ in range(3)]
    await asyncio.gather(*tasks)

asyncio.run(main())

In the code above, we are trying to continue the execution of other tasks even if another one executing is sleeping (blocking). Notice the async keyword in front of the task and main functions.

Those functions are now coroutines.

Coroutines functions in Python are preceded by the keyword async. The main() function here is the task coordinator or our single event loop, as it executes all tasks using the async.gather method. The asyncio.gather function runs awaitable objects concurrently.

Output:

Hello
Hello
Hello
Program executed in 2.01 seconds.

When each task reaches await asyncio.sleep(2), it simply goes to the next task and comes back when it's finished. It's like saying, "I am going to sleep for 2 seconds. Do something else."

Let's see the synchronous version for a quick comparison.

import time

def task():
    time.sleep(2)
    print("Hello")

for _ in range(3):
    task()

In the code above, we are going the traditional programming way in Python. You will notice that the execution of the process will take much more time.

Output:

Hello
Hello
Hello
Program executed in 6.01 seconds.

Now you can notice the execution time. Think of time.sleep() as a blocking task and asyncio.sleep() as a non-blocking or long task. In asynchronous programming, the benefit of awaiting something, like asyncio.sleep(), is that the surrounding function can temporarily cede control to another function that is ready to execute immediately.

With some basic examples of asynchronous programming in Python understood, let's explore the rules of asynchronous programming in Python.

Rules of Asyncio Programming

  1. Coroutines: Coroutines cannot be executed directly. If you try to run a coroutine function directly, it returns a coroutine object. Instead, use asyncio.run():

     import asyncio
    
     async def hello():
         await asyncio.sleep(1)
         print('Hello')
    
     asyncio.run(hello())
    
  2. Awaitable Objects: Coroutines, futures, and tasks are the main awaitable objects. Python coroutines are awaitables and can be awaited from other coroutines.

  3. Await Keyword:await can only be used within async functions.

     async def hello():
         await asyncio.sleep(1)
         print("Hello")
    
  4. Compatibility: Not all Python modules are compatible with asynchronous programming. For example, replacing await asyncio.sleep() with time.sleep() will cause an error. You can check the list of compatible and maintained modules here.

In the next section, we will explore a common use of asynchronous programming, HTTP requests.

Program Example: Asynchronous Requests

Let's take a look at the following piece of code:

import aiohttp
import asyncio

async def fetch(session, city):
    url = f"https://www.prevision-meteo.ch/services/json/{city}"
    async with session.get(url) as response:
        data = await response.json()
        print(f"Temperature at {city}: {data['current_condition']['tmp']} C")

async def main():
    async with aiohttp.ClientSession() as session:
        cities = ['paris', 'toulouse', 'marseille']
        tasks = [fetch(session, city) for city in cities]
        await asyncio.gather(*tasks)

asyncio.run(main())

In the code above, we create two asynchronous functions: one to fetch data from the prevision-meteo URL and a main function to execute the processes in the Python code. The goal is to send asynchronous HTTP GET requests to retrieve temperatures and print the responses.

In the main and fetch functions, we use async with. In the fetch function, async with ensures that the connection is closed properly. In the main function, it ensures that the ClientSession is closed after completing the requests. These practices are important in asynchronous coding in Python to manage resources efficiently and prevent leaks.

In the last line of the main function, we use await asyncio.gather(*tasks). In our case, it runs all tasks concurrently, allowing the program to send multiple HTTP requests simultaneously. Using await ensures that the program waits for all tasks to complete before proceeding.

Output:

Temperature at marseille: 25 C
Temperature at toulouse: 24 C
Temperature at paris: 18 C
Program executed in 5.86 seconds.

Synchronous Version for Comparison

Code:

import requests
import time

def fetch(city):
    url = f"https://www.prevision-meteo.ch/services/json/{city}"
    response = requests.get(url)
    data = response.json()
    print(f"Temperature at {city}: {data['current_condition']['tmp']} C")

def main():
    cities = ['paris', 'toulouse', 'marseille']
    for city in cities:
        fetch(city)

start_time = time.time()
main()
print(f"Program executed in {time.time() - start_time:.2f} seconds.")

Output:

Temperature at Paris: 18 C
Temperature at Toulouse: 24 C
Temperature at Marseille: 25 C
Program executed in 9.01 seconds.

When to Use Asynchronous Programming

The asynchronous model performs best when:

  • There are a large number of tasks, ensuring at least one task can always progress.

  • Tasks involve significant I/O, causing an asynchronous program to waste lots of time blocking when other tasks could be running.

  • Tasks are largely independent, minimizing inter-task communication (and thus for one task to wait upon another).

Conclusion

In this tutorial, we covered:

  • The concepts of asynchronous programming and related concepts.

  • Effective use of async/await.

  • Making asynchronous HTTP requests with aiohttp.

  • The benefits of asynchronous programming.

Thanks for reading. The second part will cover asynchronous programming with Django.

Resources