Asyncio Concurrent Tasks

April 11, 2024 Python Asyncio

We can execute asyncio tasks and coroutines concurrently, a main benefit of using asyncio.

There are four main ways that we can achieve this, including issuing coroutines as independent tasks and awaiting them directly, awaiting them automatically via a TaskGroup, using asyncio.wait() or using asyncio.gather().

In this tutorial, you will discover how to execute asyncio tasks and coroutines concurrently in Python.

Let's get started.

What is a Coroutine

A coroutine represents a special type of function that can pause its execution at specific points without blocking other tasks.

It allows for concurrent and non-blocking operations, enabling asynchronous programming.

Coroutines are declared using the "async def" expression in Python, distinguishing them from regular functions.

For example:

# define a coroutine
async def coro():
	# ...

They can be paused or suspended using the await expression within their body, allowing other coroutines or tasks to run while waiting for potentially blocking operations like I/O or delays.

Coroutines are a fundamental building block in asynchronous programming with Python, enabling efficient handling of concurrent tasks without resorting to thread-based parallelism.

They facilitate cooperative multitasking and make it easier to write asynchronous code that remains responsive and scalable.

You can learn more about coroutines in the tutorial:

What is an Asyncio Task

An asyncio task in Python refers to a unit of work that runs asynchronously within an event loop.

It represents a coroutine wrapped in an asyncio.Task object, allowing the asyncio event loop to manage its execution.

This can be achieved via the asyncio.create_task() function.

For example:

...
# create and issue tasks
task = asyncio.create_task(coro())

Tasks enable concurrent execution of coroutines and provide features for monitoring and controlling their execution, such as cancellation, waiting for completion, and handling exceptions.

Tasks are fundamental in asyncio programming, allowing us to schedule and manage multiple asynchronous operations efficiently.

You can learn more about asyncio tasks in the tutorial:

How to Run Tasks Concurrently

There are 4 ways to run coroutines and concurrently, they are:

  1. Issue As Concurrent Tasks And Wait Manually
  2. Issue As Concurrent Tasks And Wait Via TaskGroup
  3. Issue As Concurrent Tasks And asyncio.wait()
  4. Use asyncio.gather()

Let's take a closer look at each in turn.

Concurrent Tasks And Wait Manually

A simple approach to execute asyncio coroutines concurrently is to issue each as an independent task, and then wait for each task to complete.

For example, we can create and issue an asyncio.Task for each coroutine and save the asyncio.Task objects in a list via a list comprehension:

...
# issue coroutines as background tasks
tasks = [asyncio.create_task(coro(i)) for i in range(20)]

This will allow all tasks to be scheduled for concurrent execution in the background.

We can then manually await each task, allowing all tasks an opportunity to run.

For example:

...
# wait for tasks to complete, manually
for task in tasks:
    await task

Concurrent Tasks And Wait Via TaskGroup

Another similar approach is to create and schedule each coroutine to run as a background task via the asyncio.TaskGroup.

The benefit of the TaskGroup is that it uses an asynchronous context manager interface. Once we exit the block of the context manager, the asyncio.TaskGroup will automatically wait for all issued tasks to be done.

This uses less code than the above example.

For example:

...
# create the task group
async with asyncio.TaskGroup() as group:
    # issue coroutines as background tasks
    tasks = [group.create_task(coro(i)) for i in range(20)]
# wait for tasks to complete...

You can learn more about how to use the asyncio.TaskGroup in the tutorial:

Concurrent Tasks And asyncio.wait()

A related approach is to issue each coroutine as a background task and then call the asyncio.wait() method to wait for all tasks to complete.

This is a simpler version of waiting for all tasks to be done that uses less code, e.g. a single function call.

For example:

...
# issue coroutines as background tasks
tasks = [asyncio.create_task(coro(i)) for i in range(20)]
# wait for tasks to complete
_ = await asyncio.wait(tasks)

You can learn more about the asyncio.wait() function in the tutorial:

Concurrency With asyncio.gather()

Perhaps the most common approach to execute coroutines concurrently is to use the asyncio.gather() function.

This function takes one or more coroutines directly, or tasks and will return once all provided awaitables are done.

It also returns an iterator of return values from all issued coroutines.

For example:

...
# create coroutines
coros = [coro(i) for i in range(20)]
# wait for coroutines to complete
_ = await asyncio.gather(*coros)

You can learn more about how to use the asyncio.gather() function in the tutorial:

Now that we know how to execute asyncio coroutines and tasks concurrently, let's look at some worked examples.

Example of Concurrent Tasks and Wait Manually

We can explore an example of issuing coroutines as tasks and manually waiting for them to complete.

In this case, we will define a simple work() coroutine that takes an integer argument, sleeps for one second, and then reports its unique argument.

# coroutine to run concurrently
async def work(value):
    # suspend a moment
    await asyncio.sleep(1)
    # report a message
    print(f'>Task {value} done')

We can then execute 20 instances of this coroutine concurrently.

In this case, we will issue each as an independent task via the asyncio.create_task() function and collect the asyncio.Task objects into a list using a list comprehension.

...
# issue coroutines as background tasks
tasks = [asyncio.create_task(work(i)) for i in range(20)]

We will then iterate over the list and await each task in turn.

This will allow all coroutines to be executed concurrently in the asyncio event loop. Once the loop is complete, we know that all tasks have been done.

...
# wait for tasks to complete, manually
for task in tasks:
    await task

We can then report a final message.

Tying this together, the complete example is listed below.

# SuperFastPython.com
# example of concurrent coroutines as tasks and wait manually
import asyncio

# coroutine to run concurrently
async def work(value):
    # suspend a moment
    await asyncio.sleep(1)
    # report a message
    print(f'>Task {value} done')

# main coroutine
async def main():
    # issue coroutines as background tasks
    tasks = [asyncio.create_task(work(i)) for i in range(20)]
    # wait for tasks to complete, manually
    for task in tasks:
        await task
    # report a final message
    print('Done.')

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

Running the example first starts the asyncio event loop and runs our main() coroutine as the entry point.

The main() coroutine runs and creates a list of 20 coroutines, issued as independent tasks.

The main() coroutine then iterates over the list of tasks, awaiting each in turn. This suspends the main() coroutine, allowing the issued tasks an opportunity to run.

Each task is given an opportunity to run in the event loop and sleeps immediately. Tasks finish their sleep, resume, report a message, and terminate.

The loop in the main() coroutine completes and a final message is reported.

This highlights how we can execute many coroutines concurrently in asyncio by issuing them as independent tasks and awaiting them manually.

>Task 0 done
>Task 1 done
>Task 2 done
>Task 3 done
>Task 4 done
>Task 5 done
>Task 6 done
>Task 7 done
>Task 8 done
>Task 9 done
>Task 10 done
>Task 11 done
>Task 12 done
>Task 13 done
>Task 14 done
>Task 15 done
>Task 16 done
>Task 17 done
>Task 18 done
>Task 19 done
Done.

Next, let's explore how we might await the issued tasks automatically with a TaskGroup.

Example of Concurrent Tasks and Wait via TaskGroup

In this case, we can issue coroutines as independent tasks using an asyncio.TaskGroup, then automatically waits for all issued tasks to be done.

In this case, we can update the above example to create and use an asyncio.TaskGroup via its asynchronous context manager. This can be achieved via the "async with" expression and the task group can be assigned to an argument "group".

This group can then be used to issue our coroutines as tasks by calling the create_task() method on the asyncio.TaskGroup instance.

For example:

...
# create the task group
async with asyncio.TaskGroup() as group:
    # issue coroutines as background tasks
    tasks = [group.create_task(work(i)) for i in range(20)]

Exiting the asynchronous context manager will automatically await all tasks issued using the asyncio.TaskGroup.

If you are new to asynchronous context managers, you can learn more about how they work in the tutorial:

Tying this together, the complete example is listed below.

# SuperFastPython.com
# example of concurrent coroutines as tasks and wait via taskgroup
import asyncio

# coroutine to run concurrently
async def work(value):
    # suspend a moment
    await asyncio.sleep(1)
    # report a message
    print(f'>Task {value} done')

# main coroutine
async def main():
    # create the task group
    async with asyncio.TaskGroup() as group:
        # issue coroutines as background tasks
        tasks = [group.create_task(work(i)) for i in range(20)]
    # wait for tasks to complete...
    # report a final message
    print('Done.')

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

Running the example first starts the asyncio event loop and runs our main() coroutine as the entry point.

The main() coroutine runs and creates an asyncio.TaskGroup via the asynchronous context manager interface.

The asyncio.TaskGroup is then used to create a list of 20 coroutines, issued as independent tasks.

The block of the asyncio.TaskGroup context manager is exited. This suspends the main() coroutine, allowing all issued tasks to run.

Each task is given an opportunity to run in the event loop and sleeps immediately. Tasks finish their sleep, resume, report a message, and terminate.

Once all tasks are done, the main() coroutine resumes and reports its final message.

This highlights how we can execute many coroutines concurrently in asyncio by issuing them as independent tasks via the asyncio.TaskGroup and automatically waits for the tasks to be done.

>Task 0 done
>Task 1 done
>Task 2 done
>Task 3 done
>Task 4 done
>Task 5 done
>Task 6 done
>Task 7 done
>Task 8 done
>Task 9 done
>Task 10 done
>Task 11 done
>Task 12 done
>Task 13 done
>Task 14 done
>Task 15 done
>Task 16 done
>Task 17 done
>Task 18 done
>Task 19 done
Done.

Next, let's explore how we may achieve a similar result using the asyncio.wait() function.

Example of Concurrent Tasks and asyncio.wait()

We can explore an example of how to issue coroutines as independent background tasks and wait for them to be done via the asyncio.wait() function.

In this case, we can update the above example to create and issue a task for each coroutine and store the results in a list as we did in the first example.

...
# issue coroutines as background tasks
tasks = [asyncio.create_task(work(i)) for i in range(20)]

We can then pass the list of tasks to the asyncio.wait() function and await it directly.

This will be suspended until all tasks in the list are done.

...
# wait for tasks to complete
_ = await asyncio.wait(tasks)

Tying this together, the complete example is listed below.

# SuperFastPython.com
# example of concurrent coroutines as tasks and asyncio.wait()
import asyncio

# coroutine to run concurrently
async def work(value):
    # suspend a moment
    await asyncio.sleep(1)
    # report a message
    print(f'>Task {value} done')

# main coroutine
async def main():
    # issue coroutines as background tasks
    tasks = [asyncio.create_task(work(i)) for i in range(20)]
    # wait for tasks to complete
    _ = await asyncio.wait(tasks)
    # report a final message
    print('Done.')

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

Running the example first starts the asyncio event loop and runs our main() coroutine as the entry point.

The main() coroutine runs and creates a list of 20 coroutines, issued as independent tasks.

The list of tasks is then provided to the asyncio.wait() function and the main() coroutine suspends until all tasks are done.

Each task is given an opportunity to run in the event loop and sleeps immediately. Tasks finish their sleep, resume, report a message, and terminate.

Once all tasks are done, the main() coroutine resumes and reports its final message.

This highlights how we can execute many coroutines concurrently in asyncio by issuing them as independent tasks and waiting for the tasks to be done via the asyncio.wait() function.

>Task 0 done
>Task 1 done
>Task 2 done
>Task 3 done
>Task 4 done
>Task 5 done
>Task 6 done
>Task 7 done
>Task 8 done
>Task 9 done
>Task 10 done
>Task 11 done
>Task 12 done
>Task 13 done
>Task 14 done
>Task 15 done
>Task 16 done
>Task 17 done
>Task 18 done
>Task 19 done
Done.

Next, let's explore how we can execute coroutines concurrently using asyncio.gather().

Example of Concurrent Coroutines With asyncio.gather()

We can explore an example of executing asyncio coroutines concurrently using the asyncio.gather() function.

In this case, we will update the above example to create a list of coroutines, not tasks, and pass the list of coroutines directly to the asyncio.gather() function.

This function takes awaitable expressions directly, it does not take a collection of awaitables.

This means, that if we create a list of coroutine objects, we can expand the list into expressions for the asyncio.gather() function using the star operator (*).

For example:

...
# create coroutines
coros = [work(i) for i in range(20)]
# wait for coroutines to complete
_ = await asyncio.gather(*coros)

Tying this together, the complete example is listed below.

# SuperFastPython.com
# example of concurrent coroutines and wait via asyncio.gather()
import asyncio

# coroutine to run concurrently
async def work(value):
    # suspend a moment
    await asyncio.sleep(1)
    # report a message
    print(f'>Task {value} done')

# main coroutine
async def main():
    # create coroutines
    coros = [work(i) for i in range(20)]
    # wait for coroutines to complete
    _ = await asyncio.gather(*coros)
    # report a final message
    print('Done.')

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

Running the example first starts the asyncio event loop and runs our main() coroutine as the entry point.

The main() coroutine runs and creates a list of 20 coroutines, not tasks. We could create tasks if we wanted, but is not required in this case.

The list of coroutines is then provided to the asyncio.gather() function and the main() coroutine suspends until all coroutines are done.

Each coroutine is given an opportunity to run in the event loop and sleeps immediately. Coroutines finish their sleep, resume, report a message, and terminate.

Once all coroutines are done, the main() coroutine resumes and reports its final message.

This highlights how we can execute many coroutines concurrently in asyncio by passing them to the asyncio.gather() function.

>Task 0 done
>Task 1 done
>Task 2 done
>Task 3 done
>Task 4 done
>Task 5 done
>Task 6 done
>Task 7 done
>Task 8 done
>Task 9 done
>Task 10 done
>Task 11 done
>Task 12 done
>Task 13 done
>Task 14 done
>Task 15 done
>Task 16 done
>Task 17 done
>Task 18 done
>Task 19 done
Done.

Takeaways

You now know how to execute asyncio tasks and coroutines concurrently in Python.



If you enjoyed this tutorial, you will love my book: Python Asyncio Jump-Start. It covers everything you need to master the topic with hands-on examples and clear explanations.