The biggest performance gains in asynchronous programming aren’t from making one thing faster, but from enabling many things to happen concurrently without blocking each other.

Let’s see this in action. Imagine a web server handling requests. In a synchronous model, one request ties up a thread until it’s fully processed. If that request involves waiting for a database query or an external API call, the thread sits idle, useless.

# Synchronous Example (Simplified)
import time
import requests

def handle_request_sync(request_id):
    print(f"Handling request {request_id}...")
    # Simulate a slow external API call
    response = requests.get("https://httpbin.org/delay/2", timeout=3)
    print(f"Request {request_id} completed after {response.elapsed.total_seconds():.1f} seconds.")
    return f"Response for {request_id}"

# If we had 3 concurrent requests, they'd run sequentially.
# Each takes ~2 seconds, so total time would be ~6 seconds.
# for i in range(1, 4):
#     handle_request_sync(i)

Now, let’s switch to an asynchronous model using Python’s asyncio. The key is async and await. async defines a coroutine (a function that can be paused and resumed), and await pauses the current coroutine, yielding control back to the event loop, allowing other tasks to run.

# Asynchronous Example (Simplified)
import asyncio
import httpx # A modern, async-compatible HTTP client

async def handle_request_async(request_id):
    print(f"Handling request {request_id}...")
    # Simulate a slow external API call using httpx
    async with httpx.AsyncClient() as client:
        response = await client.get("https://httpbin.org/delay/2", timeout=3)
    print(f"Request {request_id} completed after {response.elapsed.total_seconds():.1f} seconds.")
    return f"Response for {request_id}"

async def main():
    # Create tasks for concurrent execution
    tasks = [handle_request_async(i) for i in range(1, 4)]
    # Run tasks concurrently
    results = await asyncio.gather(*tasks)
    print(results)

# To run this, we need an event loop
# asyncio.run(main())

If you run the asyncio.run(main()) part, you’ll see all three "Handling request…" messages appear almost simultaneously. Then, after about 2 seconds, all three "Request X completed…" messages will appear. The total time is still around 2 seconds, not 6. This is because while one handle_request_async was awaiting the httpx.get call, the event loop switched to another task. No threads were blocked.

The problem this solves is I/O-bound latency. When your program spends most of its time waiting for external resources (network, disk, databases), synchronous code wastes CPU cycles. Asynchronous programming allows your program to do useful work on other tasks during these waiting periods, drastically improving throughput and responsiveness.

Internally, the core of asyncio is the event loop. Think of it as a scheduler. It keeps track of all your running coroutines (tasks). When a task awaits something, it tells the event loop, "I’m waiting for this to finish, wake me up when it’s done." The event loop then looks for another task that’s ready to run and switches to it. This context switching between coroutines is incredibly lightweight compared to thread context switching.

The exact levers you control are:

  • async def: Defines a coroutine function.
  • await: Pauses the current coroutine, yielding control to the event loop. This is where the magic happens – the event loop can run other tasks.
  • asyncio.gather(): A way to run multiple awaitables (like coroutines) concurrently and wait for all of them to complete.
  • asyncio.create_task(): Schedules a coroutine to run "in the background" as a Task.

A common pattern is using an asynchronous web framework (like FastAPI or Starlette) which is built on top of asyncio. When a request comes in, the framework creates a task for the request handler. If the handler awaits a database query using an async driver, the framework doesn’t block a worker thread; it yields control back to the event loop, which can then pick up another incoming request or a background task.

The counter-intuitive aspect for many is that await doesn’t necessarily mean parallelism in the CPU-bound sense (where multiple cores work on different pieces of data simultaneously). Instead, it’s about concurrency – managing multiple tasks that make progress independently, especially when they involve waiting. You can have thousands of awaiting tasks running on a single thread because they only consume CPU when they’re actively computing, not when they’re blocked on I/O.

When you start mixing synchronous blocking code into an asyncio application (e.g., using a standard requests.get inside an async def function without running it in a separate thread pool), you effectively poison the event loop. That single blocking call will freeze the entire event loop for its duration, preventing any other asyncio tasks from making progress.

The next step is understanding how to manage shared state and concurrency primitives within the asyncio ecosystem, like asynchronous locks and queues.

Want structured learning?

Take the full Performance course →