Create your own
Lesson illustration

Understanding `async def` and Coroutines

Hello! Welcome to the third lesson in our course on asyncio.

Introduction

In our last session, we established a crucial principle: asyncio is the ideal tool for managing I/O-bound tasks. We saw how it allows a program to perform other work while waiting for slow operations like network requests or disk access, significantly improving overall efficiency.

Today, we move from the why to the how. We will explore the fundamental building block of any asyncio program: the coroutine. The goal of this lesson is to define what a coroutine is, how to create one using the async def syntax, and to explain its role as a cooperative unit of work. This concept is the cornerstone upon which all asynchronous operations in Python are built.

1. The Core Idea: A Pausable Function

Before diving into the modern async def syntax, it's helpful to understand the core concept of a coroutine from first principles. At its heart, a coroutine is a function that can be paused and resumed.

Given your solid Python background, you're already familiar with a similar concept: generators. A generator function, using the yield keyword, can pause its execution, yield a value, and then be resumed later, picking up right where it left off. This ability to pause and resume is the essence of cooperative multitasking.

The following video provides an excellent demonstration of building a simple cooperative function using generators. This will give you a strong mental model for how coroutines work under the hood.

Coroutine Concurrency in Python 3 with asyncio - Robert Smallshire

In this segment from 'Coroutine Concurrency in Python 3 with asyncio' by Robert Smallshire, he demonstrates how to turn a regular, blocking function into a cooperative one by simply introducing the yield keyword. This illustrates the core idea of an interruptible function.

Watch the section from 10:16 to 17:31. Pay close attention to how the search function is transformed into async_search. Notice how calling next() on the generator object advances the function one step at a time, allowing other code (like print('hello world')) to run in between. This is a concrete example of cooperative multitasking.

As the video shows, the generator-based async_search function voluntarily gives up control with each yield, allowing the main program to decide when to resume it by calling next(). It "cooperates" with the calling code to interleave its execution with other tasks.

2. Modern Coroutines: The async def Syntax

While generators provide the conceptual foundation, modern Python provides a dedicated and more powerful syntax for creating coroutines: the async def statement.

A function defined with async def is called a coroutine function.

There is a critical difference between a regular function (def) and a coroutine function (async def):

  • Calling a regular function executes its code immediately and returns a result.
  • Calling a coroutine function does not execute its code. Instead, it immediately returns a coroutine object.

This is a frequent point of confusion for newcomers, so it's vital to grasp this distinction early. The coroutine object is essentially a blueprint for the execution of your asynchronous code, but it doesn't do anything on its own.

Let's clarify this with a few resources.

First, this article provides a very precise definition of the terminology.

Python Asyncio Part 2 – Awaitables, Tasks, and Futures

The article 'Python Asyncio Part 2 – Awaitables, Tasks, and Futures' from the BBC R&D blog clearly explains the syntax and behavior of async def.

Please read the sections 'Writing Asynchronous Code' and 'Differences between def and async def'. Focus on the bullet points that contrast the behavior of calling a def function versus an async def function. The note on terminology is also particularly useful.

Next, watch this short video which provides a live demonstration of this behavior.

Asyncio in Python - Full Tutorial

This clip from 'Asyncio in Python - Full Tutorial' by Tech With Tim visually demonstrates what happens when you call a coroutine function.

Watch from 03:32 to 06:16. Notice that when the main function is called directly, it doesn't print anything but instead produces a <coroutine object ...> and a RuntimeWarning. This reinforces that the coroutine object must be passed to an entry point like asyncio.run() to actually be executed.

To summarize with a code example:

import asyncio

# This is a coroutine function
async def my_coroutine():
    print("Executing the coroutine")
    return 42

# Calling it returns a coroutine object, but does NOT print the message
coro_obj = my_coroutine() 
print(f"Created a coroutine object: {coro_obj}")

# To run it, you need an event loop, typically via asyncio.run()
result = asyncio.run(coro_obj)
print(f"Coroutine executed and returned: {result}")

# Expected Output:
# Created a coroutine object: <coroutine object my_coroutine at 0x...>
# Executing the coroutine
# Coroutine executed and returned: 42

3. The Coroutine's Role: A Cooperative Unit of Work

We've now defined a coroutine and seen how to create one. But what makes it a "cooperative unit of work" in the asyncio framework?

The cooperation happens through the await keyword.

When a coroutine encounters an await expression, it's a signal to the asyncio event loop. The coroutine is saying: "I am about to perform an operation that might take a while (e.g., an I/O call). I will now pause my execution and yield control back to you, the event loop. You are free to run other tasks while I wait."

Once the awaited operation is complete, the event loop can schedule the paused coroutine to resume execution from where it left off. This voluntary act of pausing and yielding control is the "cooperation" that allows asyncio to handle many tasks concurrently on a single thread.

The following article provides a clear definition and a simple, practical example.

Python's asyncio: A Hands-On Walkthrough

The article 'Python's asyncio: A Hands-On Walkthrough' from Real Python gives a formal definition of the async and await keywords and their relationship.

Read the sections 'Coroutines and Coroutine Functions' and 'The async and await Keywords'. The countasync.py example is particularly illustrative. The line await asyncio.sleep(1) is where the count coroutine cooperatively yields control to the event loop, allowing other instances of count to run.

In the countasync.py example, each count() coroutine doesn't block the entire program when it sleeps. Instead, it awaits asyncio.sleep(), pauses itself, and lets the event loop run another count() instance. This is why three tasks that each take two seconds can finish in a total of two seconds, not six. They are cooperating to share execution time.

Conclusion

In this lesson, we have defined the core component of asyncio. You now have a solid conceptual and practical understanding of what a coroutine is and the role it plays.

Key Takeaways:

  • A coroutine is a special function that can be paused and resumed, making it a "cooperative unit of work."
  • Conceptually, coroutines evolved from generators, which also have pause/resume capabilities via yield.
  • Modern coroutines are defined using the async def syntax, creating a coroutine function.
  • Calling a coroutine function does not execute it; it returns a coroutine object. This object must be scheduled on an event loop (e.g., with asyncio.run()) to be executed.
  • Coroutines cooperate by using the await keyword to pause their execution at I/O-bound (or otherwise blocking) points, yielding control back to the event loop so other tasks can run.

Preview of the Next Lesson:

We've introduced await as the mechanism that makes coroutines cooperative. In our next lesson, we will dive deeper into the await keyword. We will explore what makes an object "awaitable" and examine the crucial role of the asyncio event loop in managing the suspension and resumption of coroutines.

Can't find a good explanation? Sign up and we'll make it for you

Sign up