Coroutines in Python

Akhilesh MalthiAkhilesh Malthi
5 min read

Beginner: What's the Big Idea?

Imagine you're baking a cake. You put the cake in the oven, and it needs to bake for 30 minutes. What do you do in the meantime? You don't just stand there staring at the oven. You can prepare the frosting, wash the dishes, or do other tasks. You pause your cake-baking task, do something else, and then resume it when the timer goes off.

A coroutine is like that baker. It's a special type of function that can "pause" its execution. When it pauses, it gives control back to the program, allowing the program to do other things. Later, the program can tell the coroutine to "resume" right from where it left off. This is a powerful idea because it lets you handle multiple tasks without having to wait for one to finish completely before starting the next. This is what's known as concurrency.

Key terms:

  • async def: This is how you define a coroutine in Python. The async keyword tells Python that this is a special function that can be paused.

  • await: This keyword is the magic pause button. When you see await, it means "I'm going to pause here and wait for something to finish." It's used to call other coroutines.

  • Event Loop: This is the manager of the entire process. It's like the kitchen timer that keeps track of all the tasks. It knows which tasks are paused, which are ready to run, and when a paused task can be resumed.


Intermediate: Stepping into the Code

Let's look at a simple example to see how this works in practice.

Python

import asyncio

async def bake_cake():
    print("Preheating the oven...")
    await asyncio.sleep(2) # Pause for 2 seconds, allowing other tasks to run
    print("Putting the cake in the oven.")
    await asyncio.sleep(5) # Pause for 5 seconds
    print("Cake is done! It smells delicious.")
    return "Cake is ready"

async def prepare_frosting():
    print("Gathering frosting ingredients.")
    await asyncio.sleep(3) # Pause for 3 seconds
    print("Mixing the frosting. It's so smooth!")
    return "Frosting is ready"

async def main():
    # This runs the two coroutines concurrently.
    cake_task = asyncio.create_task(bake_cake())
    frosting_task = asyncio.create_task(prepare_frosting())

    cake_result = await cake_task
    frosting_result = await frosting_task

    print(f"The final results: {cake_result} and {frosting_result}.")

# Run the main coroutine
asyncio.run(main())

In this example, main tells the event loop to start both bake_cake and prepare_frosting at the same time. When bake_cake hits await asyncio.sleep(2), it pauses. The event loop then checks if prepare_frosting can run. It does, and so prepare_frosting starts. When prepare_frosting pauses, the event loop goes back to bake_cake, which has been "sleeping" for a while and is now ready to resume.

Notice how the output shows the messages from bake_cake and prepare_frosting interleaved. This is the essence of concurrency: they're running in a cooperative, not a sequential, manner. One doesn't have to wait for the other to finish before it can start.


Advanced: Under the Hood and Asynchronous Patterns

Coroutines are part of a larger ecosystem in Python called asyncio. This library provides the tools to build concurrent applications. Unlike multithreading, which uses multiple threads of execution to achieve concurrency (which can be a bit messy due to the Global Interpreter Lock or GIL), asyncio uses a single-threaded, cooperative model. This means your code voluntarily gives up control, which is much easier to manage and debug.

The Event Loop: The Master Orchestrator

The event loop is the heart of asyncio. It maintains a queue of tasks. When a task hits an await statement, it gets put to "sleep" and is added to a list of "pending" or "waiting" tasks. The event loop then picks the next available task from its queue and starts running it. When a waiting task (like asyncio.sleep's timer) is done, the event loop puts it back in the ready queue to be resumed. This cycle continues until all tasks are complete.

Common Asynchronous Patterns

  • async with: Used for asynchronous context managers. For example, when you want to open a file or a network connection and ensure it's closed properly, even if errors occur.

  • async for: Used for asynchronous iterators. This lets you iterate over a stream of data (e.g., from a network connection) without blocking the entire program.

  • gather(): A function that lets you run multiple coroutines and wait for them all to complete. This is similar to our main function example but is a more formal way to do it. It's great for when you need to run several independent tasks at once.

  • Non-blocking I/O: The real power of coroutines shines in I/O-bound tasks. These are tasks that spend most of their time waiting for input or output, such as network requests, database queries, or file operations. Because a coroutine can "await" the I/O operation to complete, the event loop is free to handle other tasks instead of just sitting there and waiting. This makes your program incredibly efficient.

Coroutines vs. Generators

You might notice a similarity between coroutines and generators. Both can pause their execution. However, they have different purposes. A generator pauses and yields a value (yield), and it's used to produce a sequence of values. A coroutine, on the other hand, can await on other coroutines and is primarily used for asynchronous concurrency. In fact, modern Python coroutines are a more advanced evolution of generator-based coroutines from earlier Python versions.

In summary, coroutines are a fundamental building block of modern concurrent programming in Python. They allow you to write efficient, responsive, and readable code, especially for tasks that involve a lot of waiting.

0
Subscribe to my newsletter

Read articles from Akhilesh Malthi directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Akhilesh Malthi
Akhilesh Malthi