KAHIBARO
Discord Login Register

26.3 Asynchronous Programming

Why Asynchronous Programming Matters for Backend Performance

When your backend is slow, it is often because it is waiting on something: a database, an external API, disk I/O, or network. During this wait time, the CPU is mostly idle. Asynchronous programming is a way to use that idle time to handle other work without adding more machines.

Asynchronous techniques are especially useful for:

You already learned general ideas about backend performance and CPU-bound vs I/O-bound work in the surrounding chapters, so this chapter focuses on what is specific to async programming and how it helps with performance and scalability.

Key idea: Asynchronous programming is mainly a tool to improve concurrency for I/O-bound workloads, not to make pure CPU calculations faster.

Concurrency vs Parallelism

Before we talk about async code, we must distinguish two related concepts.

ConceptSimple meaningTypical use
ConcurrencyDoing many things in progress at the same timeHandling many I/O-bound requests
ParallelismDoing computations literally at the same timeHeavy CPU tasks, numeric computation

In a single-threaded asynchronous server:

So async code gives you high concurrency with a single thread. Parallelism, for heavy CPU-bound work, usually requires multiple processes or threads, which is covered in other chapters.

Synchronous vs Asynchronous I/O

Imagine a handler that calls another HTTP service:

Synchronous style

python
def get_user_profile(user_id: int):
    response = requests.get(f"https://api.example.com/users/{user_id}")
    data = response.json()
    return data

In a typical synchronous web server:

  1. A thread handles the request.
  2. It calls requests.get(...).
  3. The thread is blocked waiting for the remote server.
  4. While it waits, that thread cannot do anything else.

If network latency is 300 ms and you have 50 concurrent requests, you may need 50 threads steadily waiting on remote services.

Asynchronous style

With async I/O:

python
import httpx
import asyncio
async def get_user_profile(user_id: int):
    async with httpx.AsyncClient() as client:
        response = await client.get(f"https://api.example.com/users/{user_id}")
        data = response.json()
        return data

What changes:

The CPU is free to work on other incoming requests instead of sitting idle.

Rule: In async code, any operation that can block for a noticeable time must be awaited using an async-compatible library, otherwise you lose the benefits of async.

Event Loop and Tasks, Conceptually

Most modern async systems use an event loop.

You can think of the event loop as:

  1. A scheduler that has a queue of ready tasks.
  2. It picks a task and runs it until the task awaits something.
  3. When a task awaits an I/O operation, it is paused.
  4. When the I/O is ready, the task is put back on the ready queue.

Some terminology:

TermMeaning
Event loopCore engine that runs async tasks and dispatches I/O events
CoroutineAn async function that can be paused by await and later resumed
TaskA scheduled coroutine managed by the event loop
AwaitableSomething you can await on, usually returned by async functions

You do not need to build your own event loop. Frameworks like FastAPI and async libraries do it for you. But you must write code that is cooperative:

I/O-Bound Work and Async Benefits

In this course you already distinguish CPU-bound and I/O-bound work. Here is how async fits into that.

When async works very well

Async is great when you have a lot of waiting involved:

Example of an I/O-bound async endpoint:

python
import asyncio
import httpx
async def fetch_two_apis():
    async with httpx.AsyncClient() as client:
        # Start both requests concurrently
        task1 = asyncio.create_task(client.get("https://api.service1.com/data"))
        task2 = asyncio.create_task(client.get("https://api.service2.com/data"))
        # Wait for both responses
        resp1, resp2 = await asyncio.gather(task1, task2)
        return resp1.json(), resp2.json()

Here, while we wait for service1, the event loop can be waiting for service2 at the same time. The total time is roughly:

$$
T_{\text{total}} \approx \max(T_1, T_2)
$$

Instead of:

$$
T_{\text{total}} \approx T_1 + T_2
$$

Performance insight: For multiple independent I/O operations, asynchronous concurrency can reduce total time from the sum of latencies to roughly the maximum latency.

When async does *not* help much

For CPU-bound tasks, async alone is not enough. For example:

python
async def slow_fibonacci(n: int) -> int:
    if n <= 1:
        return n
    return await slow_fibonacci(n - 1) + await slow_fibonacci(n - 2)

This is an artificial recursive example. Even though the function is async, the CPU must still do the same amount of work. The event loop cannot speed this up, because you are not waiting on I/O, just burning CPU.

For heavy CPU-bound work, you typically:

Async-Aware Libraries vs Blocking Libraries

Async programming is effective only if your whole stack is async-aware.

LayerSynchronous exampleAsynchronous example
HTTP clientrequestshttpx.AsyncClient, aiohttp
DB clientpsycopg2asyncpg, databases, SQLAlchemy async
Redis clientredis-py (sync)aioredis, redis.asyncio
Web frameworkFlask (sync)FastAPI, Starlette (async-first)

If you mix blocking libraries in async endpoints, the event loop will be stuck while your code waits, and concurrency will drop.

Example of a bad async endpoint that uses a blocking HTTP client:

python
import requests
async def bad_handler():
    # This blocks, even in async code
    response = requests.get("https://api.example.com/data")
    return response.json()

Better version that uses an async client:

python
import httpx
async def good_handler():
    async with httpx.AsyncClient() as client:
        response = await client.get("https://api.example.com/data")
        return response.json()

Rule: In async endpoints, always use async versions of I/O libraries. A single blocking call can block the entire event loop.

Combining Async with Thread Pools and Process Pools

Even if your main application is async, you sometimes must call blocking code.

Common cases:

In Python, one typical pattern is to offload work to a thread or process pool.

Example, running blocking code in a thread so it does not block the event loop:

python
import asyncio
import time
def blocking_sleep(seconds: int):
    time.sleep(seconds)
    return f"Slept for {seconds} seconds"
async def handler():
    loop = asyncio.get_running_loop()
    result = await loop.run_in_executor(None, blocking_sleep, 3)
    return result

What happens here:

You do not need to manage threads directly. For most backend applications, using an async-first stack avoids this complexity, but it is useful to know this option exists when integrating with blocking code.

Measuring the Impact of Async

You can think of async programming as a way to multiply the useful work your server does for I/O-bound tasks, without increasing CPU speed.

Very simplified, if:

Then:

You can see benefits in:

However, async does not magically remove bottlenecks like:

Those need separate optimizations, which are covered in the database and performance chapters.

Practical Guidelines for Backend Developers

To use async effectively in backend systems:

1. Use async where concurrency matters

Typical places:

2. Keep heavy CPU work outside async hot paths

For example:

Async is about handling many requests efficiently, not about making each request's CPU work faster.

3. Choose your stack with async in mind

If you plan to use FastAPI or other async frameworks:

In this course, when you later build complete FastAPI applications, you will see how async endpoints, async DB clients, and async worker patterns combine to give very high concurrency.

4. Avoid unnecessary context switches

While await is good for I/O, it still has a small overhead. Do not split tiny CPU operations into many async calls.

For example, this is unnecessary:

python
async def add(a, b):
    return a + b
async def handler():
    # This await is pointless
    result = await add(1, 2)
    return result

Use async only when you actually perform I/O or need concurrency, such as network calls, file operations, or waiting for timers.

Example: Parallel Calls to Multiple Services

Imagine an endpoint that must call three external services and combine their data.

Synchronous version

python
import requests
def aggregate_data():
    data1 = requests.get("https://service1/api/data").json()
    data2 = requests.get("https://service2/api/data").json()
    data3 = requests.get("https://service3/api/data").json()
    return {"s1": data1, "s2": data2, "s3": data3}

If each call takes about 200 ms, total time is around:

$$
T_{\text{sync}} \approx 200 + 200 + 200 = 600 \text{ ms}
$$

Async version

python
import asyncio
import httpx
async def aggregate_data_async():
    async with httpx.AsyncClient() as client:
        t1 = asyncio.create_task(client.get("https://service1/api/data"))
        t2 = asyncio.create_task(client.get("https://service2/api/data"))
        t3 = asyncio.create_task(client.get("https://service3/api/data"))
        r1, r2, r3 = await asyncio.gather(t1, t2, t3)
        return {
            "s1": r1.json(),
            "s2": r2.json(),
            "s3": r3.json(),
        }

Now the calls overlap. Total time is roughly:

$$
T_{\text{async}} \approx \max(200, 200, 200) \approx 200 \text{ ms}
$$

On a busy server, the difference is even larger, because async lets many such aggregations run concurrently with one event loop.

Pattern: For independent external calls, start them together and await them with asyncio.gather to overlap their waiting time and reduce total latency.

Summary

In this chapter you learned how asynchronous programming fits into backend performance:

Later, when you build FastAPI applications and integrate databases, Redis, and background workers, these async concepts will become very concrete and practical.

Views: 8

Comments

Please login to add a comment.

Don't have an account? Register now!