KAHIBARO
Discord Login Register

5.15. Async Programming

Why Async Programming Matters for Backend Development

When you build backends, most of the time the bottleneck is not CPU, but waiting for external systems, for example:

During this waiting time, your program can either:

Asynchronous programming is about writing code that can pause while it waits for I/O, and resume later, so the same process can handle many things at once.

In Python backends, this is especially important when you build servers with frameworks like FastAPI or when you call many external APIs.

Async programming lets one process handle many I/O bound tasks by not blocking while it waits. It does not magically make CPU bound code faster.

Synchronous vs Asynchronous: The Core Idea

Synchronous (Blocking) Code

In classic Python code, each line waits for the previous one to finish:

python
import time
def fetch_user():
    print("Fetching user...")
    time.sleep(2)  # simulating slow I/O
    print("User fetched")
    return {"id": 1}
def fetch_orders():
    print("Fetching orders...")
    time.sleep(3)  # simulating slow I/O
    print("Orders fetched")
    return [{"id": 101}, {"id": 102}]
def main():
    user = fetch_user()
    orders = fetch_orders()
    print("Done")
main()

Timeline:

During each sleep, the program is doing nothing, just blocking.

Asynchronous (Non‑Blocking) Code

With async, you can start both operations, then await them:

python
import asyncio
async def fetch_user():
    print("Fetching user...")
    await asyncio.sleep(2)
    print("User fetched")
    return {"id": 1}
async def fetch_orders():
    print("Fetching orders...")
    await asyncio.sleep(3)
    print("Orders fetched")
    return [{"id": 101}, {"id": 102}]
async def main():
    user_task = asyncio.create_task(fetch_user())
    orders_task = asyncio.create_task(fetch_orders())
    user = await user_task
    orders = await orders_task
    print("Done")
asyncio.run(main())

Timeline:

Because while fetch_user is waiting, fetch_orders can use the same event loop.

The Event Loop and `async` / `await`

What Is the Event Loop?

The event loop is the core piece that:

You usually do not manage the loop directly. Frameworks like FastAPI or libraries like asyncio.run handle it.

In a backend server, the event loop continuously:

  1. Accepts new incoming connections.
  2. Passes requests to your async endpoint functions.
  3. Pauses and resumes them as they await I/O.

Declaring Async Functions

An async function is defined with async def:

python
async def say_hello():
    return "Hello"

You cannot call it like a normal function if you want its result. This is coroutine, not a plain value.

python
result = say_hello()
print(result)  # <coroutine object say_hello at 0x...>

To actually run it, you must await it from inside another async function, or use asyncio.run at the top level.

python
async def main():
    result = await say_hello()
    print(result)  # "Hello"
import asyncio
asyncio.run(main())

Rule:

  • Use async def to define asynchronous functions.
  • Use await to pause and wait for an async operation inside an async function.
  • You cannot use await at the top level of a regular script.

`await` Makes the Function Pause, not Block the Thread

When you write:

python
await asyncio.sleep(1)

You are saying:

I am waiting for 1 second, but during this time, the event loop is free to run other tasks.

If you call the blocking time.sleep(1) inside async code, you freeze the whole loop for 1 second and block other requests. That is a common mistake.

Basic `asyncio` Building Blocks

asyncio is the standard library module for asynchronous programming in Python.

Running an Async Program

This pattern is common in simple scripts:

python
import asyncio
async def main():
    print("Before")
    await asyncio.sleep(1)
    print("After")
if __name__ == "__main__":
    asyncio.run(main())

In a web backend framework, you usually do not call asyncio.run yourself. The framework does something similar internally.

Sequential vs Concurrent `await`

Two awaits in a row are still sequential:

python
async def main():
    await asyncio.sleep(2)  # first wait 2s
    await asyncio.sleep(3)  # then wait 3s
    # total ~5s

To run them concurrently, you can use asyncio.gather:

python
async def main():
    await asyncio.gather(
        asyncio.sleep(2),
        asyncio.sleep(3),
    )  # total ~3s

Or create tasks:

python
async def main():
    task1 = asyncio.create_task(asyncio.sleep(2))
    task2 = asyncio.create_task(asyncio.sleep(3))
    await task1
    await task2

Both ways let the event loop juggle multiple operations at once.

Example: Calling Multiple APIs in Parallel

Imagine a backend that needs:

With asynchronous HTTP client httpx:

python
import asyncio
import httpx
async def fetch_user(client):
    resp = await client.get("https://api.example.com/user/1")
    return resp.json()
async def fetch_orders(client):
    resp = await client.get("https://api.example.com/user/1/orders")
    return resp.json()
async def main():
    async with httpx.AsyncClient() as client:
        user_task = asyncio.create_task(fetch_user(client))
        orders_task = asyncio.create_task(fetch_orders(client))
        user, orders = await asyncio.gather(user_task, orders_task)
    print(user)
    print(orders)
asyncio.run(main())

A web API endpoint can use the same pattern to respond faster.

Async I/O vs CPU Bound Work

Async Shines for I/O Bound Work

I/O bound means your program spends most of its time waiting for:

Async code can handle many such operations at once in a single process.

Example: A FastAPI server that:

The same process can serve many concurrent requests.

Async Does Not Speed Up CPU Heavy Code

CPU bound work consumes CPU directly, for example:

Async does not give your CPU more power. This function:

python
def slow_cpu_work():
    total = 0
    for i in range(10_000_000):
        total += i
    return total

If you simply put async in front:

python
async def slow_cpu_work():
    total = 0
    for i in range(10_000_000):
        total += i
    return total

It is still CPU bound and will block the event loop until it finishes, because there is no await inside.

To handle CPU heavy work in a backend, you usually:

Async programming improves concurrency for I/O bound tasks, not raw CPU speed. Do not expect async to speed up heavy calculations.

Mixing Async and Sync Code Safely

In real projects you often need to use:

Calling Synchronous Code from Async

If you call a blocking function directly from async code, it will freeze the event loop:

python
import time
async def handler():
    time.sleep(2)  # BAD inside async code
    return "Done"

Use asyncio.to_thread to run blocking code in a thread pool:

python
import asyncio
import time
def blocking_task():
    time.sleep(2)
    return "Done"
async def handler():
    result = await asyncio.to_thread(blocking_task)
    return result

This is very useful when you integrate a library that has no async version.

Example: Synchronous File Read in Async Code

Synchronous version:

python
def read_file(path: str) -> str:
    with open(path, "r", encoding="utf-8") as f:
        return f.read()

Async safe wrapper:

python
import asyncio
async def read_file_async(path: str) -> str:
    return await asyncio.to_thread(read_file, path)

You can then use read_file_async in async endpoints.

Async in Web Backends: Conceptual View

You will see async again in the chapters about FastAPI and asynchronous endpoints. Here is how it fits conceptually.

Async Endpoints

In an async capable framework, an endpoint can look like this:

python
# conceptual example, not tied to a specific framework
async def get_user_handler(request):
    user_id = request.path_params["user_id"]
    user = await fetch_user_from_db(user_id)      # async DB call
    orders = await fetch_orders_for_user(user_id) # async DB call
    return {
        "user": user,
        "orders": orders,
    }

While fetch_user_from_db and fetch_orders_for_user wait for the database, the event loop can process other incoming requests.

If you have 100 concurrent requests, the server can interleave their work instead of blocking on each one.

When to Use Async in Backends

Some simple backends work fine with synchronous code. Async is especially helpful when:

If your backend is small and simple, synchronous code may be easier to reason about. Use async when you start to hit scalability or latency issues, or when you use frameworks that are designed for async (like FastAPI).

Common Pitfalls and Best Practices

Pitfall 1: Using Blocking Libraries in Async Code

Example of a blocking HTTP client in async code:

python
import requests  # blocking
import asyncio
async def fetch_data():
    response = requests.get("https://example.com")  # blocks the loop
    return response.text

Better: use an async client:

python
import httpx
import asyncio
async def fetch_data():
    async with httpx.AsyncClient() as client:
        response = await client.get("https://example.com")
        return response.text

If you must use a blocking library, wrap it in asyncio.to_thread.

Pitfall 2: Forgetting to `await`

If you forget await, the coroutine is created but not executed:

python
async def say_hi():
    print("Hi")
async def main():
    say_hi()  # missing await, nothing runs
import asyncio
asyncio.run(main())

Correct:

python
async def main():
    await say_hi()

Pitfall 3: Overusing Concurrency

Creating too many tasks can overload external systems.

Example of risky code:

python
async def fetch_all(urls):
    tasks = [
        asyncio.create_task(fetch_single(url))
        for url in urls
    ]
    return await asyncio.gather(*tasks)

If urls has thousands of entries, you might:

Better: limit concurrency with a semaphore:

python
import asyncio
semaphore = asyncio.Semaphore(10)  # allow 10 at a time
async def fetch_single_limited(url):
    async with semaphore:
        return await fetch_single(url)

Pitfall 4: Mixing Async and Sync Frameworks Incorrectly

Trying to call async code from a synchronous web framework (like a classic WSGI framework) or the other way around can be tricky and inefficient. In practice, choose:

Practical Mini Exercises (Mentally or in a REPL)

Try these small tasks to become familiar with async patterns.

Exercise 1: Convert Sync to Async

  1. Write a synchronous function that uses time.sleep(1) three times in a loop and prints a counter.
  2. Convert it to async using asyncio.sleep(1).
  3. Run it with asyncio.run.

Exercise 2: Parallel Sleep

Write an async function:

python
async def wait_and_print(seconds, name):
    await asyncio.sleep(seconds)
    print(f"{name} done after {seconds} seconds")

Then run three of them concurrently with asyncio.gather and observe that the total time is close to the maximum of the seconds, not their sum.

Exercise 3: Wrap Blocking Code

Create a blocking function that reads a large file with open and read. Then:

Summary

In later chapters, when you work with FastAPI and background tasks, you will apply these async concepts directly to real backend applications.

Views: 6

Comments

Please login to add a comment.

Don't have an account? Register now!