Concurrency and Coroutines with Asyncio: A Deep Dive!

The asyncio module provides a powerful framework for writing asynchronous code, thereby allowing developers to create high-performance and responsive applications

Traditional synchronous programming may work wonders for certain tasks, but when it comes to handling multiple concurrent operations, things can get a bit tricky.

That’s where asyncio swoops in to save the day!

The asyncio module allows you to write concurrent programs by using coroutines, event loops, and non-blocking I/O operations.

In this blog post, you will learn the fundamental concepts of asyncio, including event loops, coroutines, and tasks.

You will also learn how to write and execute asynchronous code and gracefully manage exceptions.

Understanding Asynchronous Programming

Before delving into asyncio, let’s quickly grasp the concept of asynchronous programming.

In traditional synchronous programming, tasks are executed one after another.

Each task blocks the execution of other tasks until it has been completed.

On the other hand, asynchronous programming allows tasks to run concurrently without blocking each other.

With this, you can initiate multiple tasks and proceed to the next line of code without waiting for each task to complete.

Introducing asyncio

The asyncio module makes it possible to write programs that execute multiple tasks concurrently.

It is built on top of the async and await keywords, which were introduced in Python 3.5.

The core of asyncio is the event loop.

This acts as a scheduler for managing and executing coroutines.

which is a special type of function that can be paused and resumed during execution.

A coroutine is a special type of function that can be paused and resumed during execution.

It is defined using the async keyword and is executed by the event loop.

Take a look at this example:

import asyncio
async def greeting():
    print('hello')
    await asyncio.sleep(1)
    print('completed')

asyncio.run(greeting())

Output:

hello
completed

In the above example, the greeting() is a coroutine and is defined by preceding the async keyword before the def keyword in the function definition.

The coroutine is executed using the run() function of the asyncio module.

Key Components of asyncio

The asyncio (Asynchronous I/O) module in Python provides a framework for writing asynchronous code using coroutines, event loops, and non-blocking I/O operations.

The key components of asyncio include:

1. Event Loop

The event loop is the heart of asyncio and is responsible for executing coroutines and managing callbacks.

It continuously runs and waits for tasks to complete or for new events to occur.

The event loop can be considered a message queue that processes tasks in a non-blocking manner.

You need an event loop before you can run any coroutine and below is how to create one.

loop = asyncio.get_event_loop()

2. Coroutines

Coroutines are functions defined with the async keyword.

They can be paused and resumed using the await keyword and allows other coroutines to run concurrently.

Coroutines are the building blocks of asynchronous code in asyncio and are responsible for performing I/O operations, computations, or any other tasks.

They are like normal functions except that they start with async.

async def func():
    pass

In the above example, func() is a coroutine function.

If you assign a variable to a coroutine function, the variable becomes a coroutine.

f = func()

With this assignment,  f becomes a coroutine.

Hence, a coroutine is an object that encapsulates the ability to resume an underlying function that has been suspended before completion.

3. Tasks

A task represents a coroutine scheduled for execution in the event loop.

It encapsulates the coroutine and provides additional methods for control and monitoring.

Tasks can be created using the create_task() function, which schedules the coroutine to run in the event loop.

task = loop.create_task(coro)
#coro stands for coroutine

You can monitor or even cancel the task using task.cancel() function or keep the coroutine running until it is completed using loop.run_until_complete() function.

Also, you can group tasks to work continuously together using asyncio.gather().

This means that you cannot cancel a task if you run it as a group.

group = asyncio.gather(task1, task2, task3)

4. Futures

Futures are objects that represent the result of a coroutine before it completes.

They make it possible to retrieve the result of a coroutine once it finishes running.

Futures can be used to chain coroutines together or to wait for multiple coroutines to complete concurrently.

Future objects represent the eventual outcome of an asynchronous operation and act as placeholders for results that will be available in the future.

Writing Asynchronous Code with asyncio

Writing an asynchronous program in Python using asyncio can be summarized as follows:

  • Starting asyncio event loop
  • Creating async/await functions
  • Creating tasks to be run on the loop
  • Indicating multiple tasks to complete
  • Closing the loop after all concurrent tasks have been completed.

Here’s an example code that demonstrates the usage of the asyncio in asynchronous programming in Python:

import asyncio

async def hello():
    print("Hello")
    await asyncio.sleep(1)
    print("World")

loop = asyncio.get_event_loop()
tasks = [hello(), hello()]  # Create a list of coroutines

try:
    loop.run_until_complete(asyncio.gather(*tasks))
finally:
    loop.close()

In this example,  hello() is an asynchronous function.

It prints “Hello,” and then waits for 1 second using asyncio.sleep(), before printing “World.”

The asyncio.get_event_loop()is used to retrieve the event loop while the loop.run_until_complete() is used to run the event loop until all the tasks are complete.

The asyncio.gather() function is used to gather and schedule the coroutines for execution.

Finally, the loop.close() is used to close the event loop to release any resources associated with it.

When you run this code, you should see the output:

 
Hello
Hello
World
World

Fetching data from multiple websites concurrently using asyncio

Let’s write a simple program that fetches data from multiple websites concurrently.

First, you need to import the asyncio module.

Then, you define a coroutine that fetches the content of a website and create a list of tasks by calling asyncio.create_task() for each website URL.

The fetch_website() coroutine is scheduled to run concurrently for each URL and the asyncio.gather() is used to await the completion of all tasks.

import asyncio

async def fetch_data(url):
    print(f"Fetching data from {url}")
    await asyncio.sleep(2)
    if url == "https://example.com/page2":
        raise ValueError("Invalid URL")
    return f"Data from {url}"

async def main():
    try:
        tasks = [
            asyncio.create_task(fetch_data("https://example.com/page1")),
            asyncio.create_task(fetch_data("https://example.com/page2")),
            asyncio.create_task(fetch_data("https://example.com/page3"))
        ]

        results = await asyncio.gather(*tasks)
        for result in results:
            print(result)
    except ValueError as e:
        print(f"Error occurred: {str(e)}")

# Create and run the event loop
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()

Asyncio program with future

asyncio provides the asyncio.Future class to create and manage future objects.

A future object can be in one of the following states:

  1. Pending: The initial state of a future. It represents an ongoing operation whose result is not yet available.
  2. Running: The future is currently being executed.
  3. Done: The future has been completed either successfully or with an exception.
  4. Cancelled: The future has been cancelled before completion.

Futures have several methods and properties that provide functionality for managing their states and accessing their results:

  • result(): Returns the result of the future if it is done. If the future is not done, this method raises an exception or blocks until the result is available if the timeout parameter is specified.
  • exception(): Returns the exception that caused the future to complete with an error, or None if the future was completed successfully.
  • done(): Returns True if the future is done, either by completing or being cancelled.
  • cancelled(): Returns True if the future was cancelled.
  • add_done_callback(callback): Adds a callback function that will be called when the future is done. The callback function takes the future as its only argument.
  • cancel(): Attempts to cancel the future. If successful, the future is marked as cancelled, and its callbacks are not called. If the future is already done or cancelled, this method has no effect.

Here’s an example that demonstrates the usage of future objects in asyncio:

import asyncio

async def long_running_task():
    await asyncio.sleep(2)
    return "Task completed"

async def main():
    future = asyncio.Future()
    asyncio.create_task(long_running_task()).add_done_callback(
        lambda fut: future.set_result(fut.result())
    )

    result = await future
    print(f"Awaited result: {result}")

# Run the event loop
asyncio.run(main())

In this example, a future object is defined using asyncio.Future().

The asyncio.create_task() is used to create a task for the long_running_task() coroutine.

A callback function is attached to the task using add_done_callback().

The callback sets the result of the task to the future using future.set_result().

By awaiting the future with await future, the execution of the main coroutine is suspended until the future is done.

The result of the future is stored in the result variable and printed.

Conclusion

Asyncio is a powerful framework that brings the benefits of asynchronous programming to Python.

By leveraging coroutines and the event loop, developers can write high-performance and responsive code that efficiently handles concurrent tasks.

The built-in asyncio module provides a rich set of tools and utilities for managing I/O operations, allowing developers to harness the full potential of asynchronous programming in Python.

Leave a Reply

Your email address will not be published. Required fields are marked *