Python: Asynchronous Programming

Asynchronous programming lets a single thread handle thousands of concurrent I/O operations without the overhead of creating threads or processes. Python's asyncio library, combined with the async and await keywords, gives you a structured, readable model for writing programs that wait efficiently. This tutorial builds the concept from first principles and covers every tool you need in practice.

Synchronous vs Asynchronous Code

In synchronous code, statements execute one after another. When a statement involves waiting, such as reading from a file or making a network request, the program stops and does nothing until the wait is over. This is simple to reason about, but it wastes time. A web scraper that fetches one page at a time, waiting for each response before starting the next request, leaves the CPU idle for the vast majority of its runtime.

Asynchronous code takes a different approach. Instead of blocking while waiting, a task announces that it is going to wait and hands control back to a scheduler. The scheduler finds another task that is ready to make progress and runs it. When the original wait completes, the first task is resumed from exactly where it left off. From the program's perspective, many things appear to happen at once, even though a single thread is doing the work.

Synchronous vs Asynchronous: Side by Side

Python

Both programs fetch three resources. The synchronous version waits for each; the async version overlaps the waits.

When Async Helps and When It Does Not

Async is not a universal speed-up. It excels in specific scenarios and adds complexity without benefit in others.

  • Best fit: I/O-bound programs with many concurrent waits: web scrapers, API clients, chat servers, file servers.
  • Best fit: network servers handling thousands of simultaneous connections with low memory overhead.
  • Poor fit: CPU-bound computation. Async does not bypass the GIL or add CPU cores. Use multiprocessing for that.
  • Poor fit: simple scripts where sequential execution is clear and performance is not a concern.

The Event Loop

The event loop is the engine at the center of every asyncio program. It maintains a queue of tasks (coroutines that have been scheduled to run) and a set of pending I/O operations it is monitoring. On each iteration of its loop, it does three things: it runs all tasks that are ready to make progress, it checks whether any pending I/O has completed, and it puts back to the ready queue any task that was waiting on that I/O.

The event loop runs on a single OS thread. This means that while a task is actively executing Python code, no other task can run. Concurrency only happens at suspension points, where a task uses await to yield control. This cooperative scheduling model is simpler and cheaper than preemptive threading, but it requires that you never block the event loop with long-running synchronous operations.

Accessing the Running Event Loop

Python

Inspecting the event loop from inside a running coroutine.

async and await Keywords

The async def syntax defines a coroutine function. Calling a coroutine function does not execute its body immediately; it returns a coroutine object. The body only runs when the coroutine is driven by the event loop, either by awaiting it or by wrapping it in a task.

The await keyword can only appear inside an async def function. It takes an awaitable object (a coroutine, a Task, or a Future) and suspends the current coroutine until the awaitable has a result. While the current coroutine is suspended, the event loop is free to run other tasks. When the awaited operation completes, the event loop resumes the suspended coroutine with the result.

Defining and Calling Coroutines

Python

What calling a coroutine function actually returns, and how the body gets executed.

What Can Be Awaited

The await keyword works with any object that implements the awaitable protocol. In practice, this is one of three things.

  • Coroutines: objects returned by calling an async def function.
  • Tasks: coroutines wrapped with asyncio.create_task(), scheduled to run concurrently.
  • Futures: low-level objects representing a result that will be set later, typically produced by library internals.

Coroutines

A coroutine is a function that can be paused and resumed. In Python, any async deffunction is a coroutine function, and calling it produces a coroutine object. Coroutines are the building blocks of async programs: they compose naturally by awaiting each other, they pass results up the call chain just like return values in normal functions, and they propagate exceptions in the same way.

Unlike threads, coroutines are cooperative: a coroutine only yields control when it explicitly awaits something. This means there are no race conditions on shared data between coroutines unless a suspension point separates the read and write. This property makes async code easier to reason about than multithreaded code for many patterns.

Coroutine Chaining

Python

Coroutines awaiting other coroutines, with results flowing up the chain.

The asyncio Module

The asyncio module is Python's standard library implementation of event-loop-based concurrency. It provides the event loop itself, coroutine scheduling, task management, synchronisation primitives (Lock, Semaphore, Event, Condition), async-aware queues, subprocess support, and network I/O through protocol and transport abstractions.

Most application code interacts with a relatively small surface of the module: running coroutines with asyncio.run(), creating tasks with asyncio.create_task(), running multiple coroutines concurrently with asyncio.gather(), and usingasyncio.sleep() for non-blocking delays. The lower-level transport and protocol APIs are typically used only when writing async libraries or network servers from scratch.

asyncio Module Overview

Python

A quick tour of the most commonly used functions and objects.

asyncio.run()

asyncio.run(coroutine) is the standard entry point for async programs. It creates a new event loop, runs the given coroutine until it completes, closes the loop, and returns the coroutine's return value. If the coroutine raises an exception, asyncio.run()re-raises it in the calling context.

You should call asyncio.run() exactly once, at the top level of your program, passing your main entry-point coroutine. You cannot call asyncio.run() from within a running event loop (such as from inside another coroutine), because it tries to create a new loop in an environment that already has one running. Inside a running event loop, use await or asyncio.create_task() instead.

asyncio.run(): Entry Point and Return Value

Python

The canonical way to start an async program and retrieve its result.

asyncio.gather() and asyncio.wait()

Both functions run multiple awaitables concurrently, but they have different interfaces and different use cases. asyncio.gather() accepts coroutines or awaitables as positional arguments and returns a list of results in the same order as the inputs, regardless of completion order. If any coroutine raises an exception, it is re-raised at the gather call by default.

asyncio.wait() accepts a collection of tasks or futures and returns two sets: one containing completed tasks and one containing tasks still pending. It gives you fine-grained control through the return_when parameter, which lets you return as soon as the first task finishes (FIRST_COMPLETED), as soon as the first exception occurs (FIRST_EXCEPTION), or when all tasks are done (ALL_COMPLETED). Unlikegather(), wait() requires actual Task objects, not raw coroutines.

asyncio.gather(): Running Multiple Coroutines Concurrently

Python

All three fetches start immediately; gather returns when all are done.

asyncio.gather() with return_exceptions

Python

Collecting all results even when some coroutines raise exceptions.

asyncio.wait(): Waiting for the First Completion

Python

Returning as soon as one task finishes, then cancelling the others.

asyncio.create_task()

When you await a coroutine directly, the current coroutine suspends and waits for it to finish before continuing. If you want the coroutine to run concurrently with the current one rather than sequentially, you wrap it in a Task withasyncio.create_task(). This schedules the coroutine to run on the event loop and returns a Task object immediately. The caller can continue doing other work and await the task later to retrieve its result.

Tasks can be cancelled with task.cancel(), which injects a CancelledErrorat the next await point inside the task. You can check whether a task is done withtask.done() and retrieve its result or exception with task.result() ortask.exception() after it has completed.

create_task(): Concurrent Work Without gather()

Python

Starting multiple tasks and awaiting them individually or together.

Cancelling a Task

Python

Injecting CancelledError into a running task and handling it gracefully.

Async Context Managers

A regular context manager runs its __enter__ and __exit__ methods synchronously. When setup or teardown involves awaitable operations, such as opening a database connection pool or acquiring an async lock, you need an async context manager. The async with statement awaits __aenter__ on entry and __aexit__on exit, allowing both methods to perform async work.

You implement an async context manager by defining async def __aenter__(self) andasync def __aexit__(self, exc_type, exc_val, exc_tb) on a class. Alternatively, the contextlib.asynccontextmanager decorator lets you write an async generator function with a single yield instead.

Async Context Manager: Class-based

Python

A database connection that needs async setup and teardown.

Async Context Manager: asynccontextmanager Decorator

Python

A more concise way to write async context managers using a generator.

Async Generators and Async Comprehensions

An async generator is an async def function that contains a yieldstatement. It produces values asynchronously and is consumed with async for. This is the natural way to model a stream of data arriving over a network connection, paginated API responses, or any sequence where each item requires an async fetch.

Async comprehensions extend the familiar list, set, and dictionary comprehension syntax to work with async iterables. An async for clause inside a comprehension works exactly like it does in a loop, and an await expression can appear inside the value expression. Async comprehensions can only be used inside an async deffunction.

Async Generator: Streaming Paginated Data

Python

Yielding batches of records as each page is fetched, without loading everything into memory.

Async Comprehensions

Python

Building a list from an async iterable and using await inside the value expression.

asyncio.Queue

asyncio.Queue is the async equivalent of queue.Queue from the threading module. It is the standard way to implement producer-consumer patterns between coroutines.await queue.put(item) adds an item, blocking if the queue is full.await queue.get() removes and returns an item, blocking if the queue is empty.queue.task_done() signals that the item retrieved by get() has been processed, and await queue.join() blocks until every item that was ever put into the queue has had task_done() called on it.

Because all coroutines run on the same thread, you do not need locks around queue operations. The queue internally handles synchronisation by suspending coroutines at the await points.

asyncio.Queue: Producer-Consumer Pattern

Python

Multiple producers feeding multiple consumers through a bounded queue.

asyncio.sleep() vs time.sleep()

This distinction matters more than it might first appear. time.sleep(seconds) is a blocking system call. It puts the entire OS thread to sleep. Because the event loop runs on a single thread, calling time.sleep() inside a coroutine freezes the event loop completely for the duration of the sleep. No other coroutine can run, no I/O callbacks can fire, and no scheduled tasks can progress. The program is effectively stuck.

await asyncio.sleep(seconds) suspends only the current coroutine and tells the event loop how long to wait before resuming it. The event loop is free to run other tasks during that time. await asyncio.sleep(0) is also useful: it suspends the current coroutine for the minimum possible time, yielding control to the event loop for one iteration, which lets other pending tasks get a turn before the current one resumes.

time.sleep() Blocks the Event Loop

Python

Demonstrating why blocking calls destroy async concurrency.

asyncio.sleep(0): Yielding to the Event Loop

Python

Using a zero-duration sleep to cooperatively share the event loop in a tight loop.

Running Blocking Code with run_in_executor

Real programs often need to call code that cannot be made async: legacy libraries, CPU-bound computations, or third-party packages that have only synchronous interfaces. You cannot simply call these functions directly from a coroutine without blocking the event loop. The solution is to run them in a thread pool (or process pool) executor so that the event loop remains free while they execute.

loop.run_in_executor(executor, func, *args) submits the function to the given executor and returns a future that resolves when the function completes. PassingNone as the executor uses the default thread pool executor. Python 3.9 addedasyncio.to_thread(func, *args, **kwargs) as a more convenient shorthand for the common case of running a function in the default thread pool.

asyncio.to_thread(): Running Blocking Code in a Thread

Python

Integrating a blocking library call into an async program without freezing the event loop.

run_in_executor() with a ProcessPoolExecutor

Python

Offloading CPU-bound work to a process pool to bypass the GIL.

Choosing the Right Executor

The executor you choose for run_in_executor determines what kind of work is accelerated.

  • Default (None) or ThreadPoolExecutor: best for blocking I/O in libraries that do not have async versions, such as legacy database drivers or synchronous HTTP clients.
  • ProcessPoolExecutor: best for CPU-bound work that needs to bypass the GIL, such as image processing, cryptography, or heavy numerical computation.
  • asyncio.to_thread(): a convenient shorthand for ThreadPoolExecutor with None, available from Python 3.9.

Error Handling in Async Code

Exception handling in async code follows the same try/except/finally syntax as synchronous code, and exceptions propagate up the await chain just as they propagate up the call stack in regular functions. There are, however, a few async-specific scenarios that require extra care.

When a task is cancelled, a CancelledError is injected at its next suspension point. The task should catch it, perform any necessary cleanup, and then re-raise it so the cancellation propagates correctly. Swallowing CancelledError silently is an antipattern that leads to tasks getting stuck in an inconsistent state.

An ExceptionGroup (introduced in Python 3.11) can be raised byasyncio.gather() when return_exceptions=False and multiple tasks fail. The except* syntax (also Python 3.11) lets you handle individual exception types from a group in separate clauses.

try/except Inside a Coroutine

Python

Standard exception handling works exactly as in synchronous code.

Handling CancelledError Correctly

Python

Cleaning up inside a cancelled task and re-raising so the cancellation takes effect.

Exception Groups (Python 3.11+)

Python

Handling multiple concurrent failures using except* syntax.

Quiz - Test Your Knowledge

Ten questions covering the synchronous versus asynchronous distinction, the event loop, coroutines, gather versus wait, task creation and cancellation, async context managers, async generators, the sleep trap, run_in_executor, and exception handling. Several questions address subtle behaviour, so consider each option carefully before answering.

Knowledge Check

1. What is the fundamental difference between synchronous and asynchronous code execution?

2. What is the event loop in asyncio?

3. What does the await keyword do inside a coroutine?

4. What is the purpose of asyncio.run()?

5. What is the key difference between asyncio.gather() and asyncio.wait()?

6. What does asyncio.create_task() do, and how does it differ from awaiting a coroutine directly?

7. What is an async context manager, and what methods must a class implement to become one?

8. What makes an async generator different from a regular async coroutine?

9. Why does calling time.sleep() inside a coroutine cause problems, and what should you use instead?

10. What does loop.run_in_executor() (or asyncio.to_thread()) do, and when would you use it?