Week 14: Async Python & Concurrency

Every FastAPI route you've written since Week 2 has been an async def function, but so far that's mostly been a formality — your handlers awaited one thing and returned. This week opens up what async/await actually do: the single-threaded event loop that schedules your coroutines, why asyncio.gather makes independent I/O calls dramatically faster while doing nothing for CPU-heavy code, and where to offload blocking work — a thread pool, a process pool, or a Celery worker from Week 10 — so one slow request never stalls every other client your service is talking to.

Module 11 of 22 Week 14 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain what the event loop actually schedules when you await a coroutine, and use asyncio.create_task() to run several at once
  • Recognize whether a slow operation is I/O-bound or CPU-bound, and predict whether asyncio.gather will actually help it
  • Move blocking or CPU-heavy work off the event loop using a thread pool, a process pool, or a Celery task queue

1. The Event Loop, Coroutines & Tasks

Writing async def in front of a function doesn't make it run in parallel, and it doesn't make it run on another thread. It turns the function into a coroutine function: calling it doesn't execute the body at all — it returns a coroutine object, a paused computation waiting to be driven forward. Nothing happens until that coroutine is awaited or handed to the event loop directly.

The event loop is a single-threaded scheduler that keeps a list of in-flight coroutines and switches between them at await points. This is cooperative multitasking: a coroutine only gives up control when it hits an await on something that isn't ready yet (a socket read, a database query, asyncio.sleep). Everything between two await points runs to completion without interruption — there's no OS-level preemption like you'd get with threads, which is exactly why plain Python data structures don't need locks to stay consistent across coroutines the way they would across threads.

event_loop_demo.py
import asyncio

async def fetch_user(user_id: int) -> dict:
    print(f"fetch_user({user_id}) starting")
    await asyncio.sleep(0.2)  # stand-in for a network round trip
    print(f"fetch_user({user_id}) done")
    return {"id": user_id, "name": "Ada Lovelace"}

async def main() -> None:
    coro = fetch_user(1)          # just builds a coroutine object -- nothing runs yet
    task = asyncio.create_task(fetch_user(2))  # scheduled on the loop immediately

    print("both created; loop is free to interleave them")
    result_1 = await coro          # now we drive coro forward and wait for it
    result_2 = await task          # task has likely already made progress by now
    print(result_1, result_2)

asyncio.run(main())

asyncio.create_task() is the key move for concurrency: it hands a coroutine to the event loop's scheduler right now, without pausing the current coroutine to wait for it. The returned Task object keeps running concurrently with whatever you do next, and you can await it later to collect its result. Contrast that with await fetch_user(2) directly — that would pause main() until fetch_user(2) finished before moving on to anything else, which is exactly the sequential behavior you want to avoid for independent operations.

Why this matters for FastAPI

Every async def route handler you write runs on the same single event loop as every other in-flight request in the process. That's a feature when your handlers spend most of their time waiting on I/O — the loop just serves other requests during the wait. It becomes a liability the moment a handler blocks that thread outright, which is exactly what Topics 2 and 3 unpack.

2. I/O-Bound vs CPU-Bound Work

asyncio.gather() runs multiple coroutines concurrently and waits for all of them to finish, returning their results in the same order they were passed in. It's the standard way to fan out several independent calls from one handler:

dashboard.py
import asyncio

async def dashboard(user_id: int) -> dict:
    profile, tasks = await asyncio.gather(
        users.get(user_id),
        task_repo.list_for_user(user_id),
    )
    return {"profile": profile, "tasks": tasks}

Compare that to the sequential version: profile = await users.get(user_id) followed by tasks = await task_repo.list_for_user(user_id). If each call takes roughly 80ms of real wall-clock time — a network round trip to a database or another service — the sequential version takes roughly 160ms, because the second call can't even start until the first one's response has fully arrived. The gather version takes roughly 80ms, because both calls are in flight on the network at the same time; the event loop starts the second one the instant the first hits its own await and yields control, rather than waiting for it to finish first. This only works because both calls are I/O-bound: the CPU is idle almost the entire time, just waiting for bytes to come back over a socket, and the event loop puts that idle time to use running something else.

Now imagine users.get and task_repo.list_for_user were replaced by two calls to a pure computation — say, hashing a large payload or running a statistical aggregation with no network or disk access at all. Wrapping those in asyncio.gather would buy you nothing. A CPU-bound coroutine has no natural await point to yield at — it's busy the whole time, and Python's Global Interpreter Lock (GIL) only allows one thread to execute Python bytecode at a time regardless of whether that code is written with async/await or not. asyncio gives you concurrency — many things in flight, overlapping their idle time — not parallelism on extra CPU cores. If a coroutine doesn't spend most of its time waiting on something external, async isn't doing anything useful for it, and may even add scheduling overhead for nothing.

A quick self-check that generalizes past this example: if you could run the same call from your terminal with curl and watch it sit there for tens or hundreds of milliseconds before responding, it's very likely I/O-bound and a great candidate for gather. If it would peg a CPU core to 100% for that whole time with no network activity, it's CPU-bound, and you need Topic 3's tools instead.

gather and failures

By default, if any awaited call inside asyncio.gather() raises, gather re-raises that exception once it propagates — but the other coroutines keep running in the background rather than being cancelled automatically. Pass return_exceptions=True if you'd rather get a list back with exceptions mixed in alongside successful results, so one failing call doesn't take down a response you could still partially serve.

3. Threads, Processes & Task Queues

Sometimes you're stuck with genuinely blocking code — a legacy database driver with no async variant, a synchronous SDK for a third-party API, or a file-processing library that has no concept of await. Calling it directly inside an async def route blocks the entire event loop for its duration, which means every other concurrent request in that process — not just this one — stalls. loop.run_in_executor() fixes that by handing the blocking call to a worker thread from a ThreadPoolExecutor, freeing the event loop to keep serving other coroutines while that thread waits:

blocking_bridge.py
import asyncio

def legacy_blocking_lookup(customer_id: int) -> dict:
    # A synchronous SDK call with no async equivalent -- this really blocks.
    return legacy_sdk.get_customer(customer_id)

async def get_customer(customer_id: int) -> dict:
    loop = asyncio.get_running_loop()
    # None -> use the default ThreadPoolExecutor
    return await loop.run_in_executor(None, legacy_blocking_lookup, customer_id)

Threads share the same GIL, so this doesn't make legacy_blocking_lookup itself run any faster — it still takes just as long. What it buys you is that the event loop's thread is no longer the one sitting idle waiting for it, so other requests keep making progress. For genuinely CPU-bound work, though, a thread pool doesn't help at all, because the GIL still serializes Python bytecode execution across threads. That's what a ProcessPoolExecutor is for: each worker is a separate OS process with its own interpreter and its own GIL, so CPU-heavy functions genuinely run in parallel across cores — at the cost of pickling the arguments and return value across the process boundary, which rules out unpicklable objects like open database connections.

report_offload.py
import asyncio
from concurrent.futures import ProcessPoolExecutor

def render_report(rows: list[dict]) -> bytes:
    # CPU-heavy: aggregation + PDF rendering, no network or disk waiting involved
    aggregated = summarize(rows)
    return render_pdf(aggregated)

_report_pool = ProcessPoolExecutor(max_workers=2)

async def generate_report(rows: list[dict]) -> bytes:
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(_report_pool, render_report, rows)

A process pool inside the API keeps the work tied to that API process's lifetime — if the process restarts or redeploys mid-render, the work is gone. Reach for Celery (Week 10) instead once the work is long enough to reasonably outlive a single request-response cycle, needs to survive a deploy, needs retries or scheduling, or should run on separate infrastructure entirely so a spike in report generation can't compete with your API for CPU. The dividing line in practice: a process pool is for CPU-heavy work you're happy to lose and redo if the API restarts; a task queue is for work durable enough that losing it would actually matter.

A decision, not a default

Don't reach for a thread pool, a process pool, or Celery just because something feels slow. First ask whether it's I/O-bound (try gather, or a native async driver) or CPU-bound (try a process pool for short work, Celery for anything longer or business-critical). Reaching for the wrong tool either wastes effort or genuinely doesn't fix the stall you're chasing.

4. Hands-on Exercise

Hands-on

Measure concurrency, then take CPU work off the event loop

Prove to yourself, with real timing numbers, when asyncio.gather helps and when it doesn't — then fix an endpoint that's blocking every other request in your service.

Requirements:

  1. Pick two independent I/O-bound calls from your Week 9 microservice project (two httpx calls to different services, or one HTTP call plus one database query).
  2. Write a version that awaits them sequentially, and time it with time.perf_counter() around the calls.
  3. Write a second version using asyncio.gather() to run them concurrently, timed the same way.
  4. Run each version several times and record the real wall-clock difference in a short note — it should track roughly with "slowest call" for the concurrent version versus "sum of both calls" for the sequential one.
  5. Take (or write) a CPU-heavy report-generation function, and move it off the event loop using either run_in_executor with a ThreadPoolExecutor or a ProcessPoolExecutor.
  6. While that report is generating, fire a request at a trivial, unrelated endpoint on the same running service and confirm it responds immediately — then repeat the test with the report function called directly, unoffloaded, and confirm the trivial endpoint now stalls until the report finishes.
Hint

To make the "stalled" case obvious, use a report function that's actually CPU-heavy — a tight pure-Python loop doing arithmetic for a few hundred milliseconds works well as a stand-in. A function that just calls time.sleep() is I/O-shaped, not CPU-shaped, and will mislead your comparison because it doesn't hold the GIL the way real computation does.

5. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What happens when blocking (synchronous) I/O runs directly inside an async def function?

It blocks the single event-loop thread for as long as that call takes, preventing every other in-flight coroutine — including unrelated requests from other clients being served by the same process — from making any progress until it returns. async def only gives you concurrency at explicit await points; a synchronous call with no await in front of it never yields control back to the loop, so it behaves exactly as if it were called from ordinary, single-threaded synchronous code.

Q2

Why does asyncio.gather() meaningfully speed up two independent database calls, but do nothing for two calls to a pure CPU-bound function?

Database calls spend almost all their time waiting on the network, not using the CPU; while one is waiting, the event loop can start the other, so their wait times overlap instead of stacking up. A CPU-bound function has no idle waiting to overlap — it's actively using the CPU the whole time — and Python's GIL only lets one thread execute bytecode at once regardless of async/await, so running two of them "concurrently" under gather still executes them one after another with no speedup.

Q3

What's the practical difference between await fetch_user(2) and asyncio.create_task(fetch_user(2)) followed later by await on the resulting task?

Directly awaiting fetch_user(2) pauses the current coroutine right there until it finishes, before anything else in that function runs. asyncio.create_task() hands the coroutine to the event loop's scheduler immediately, so it starts running concurrently with whatever code comes next — you get its result only when you later await the task object it returns. This is the mechanism that makes it possible to start several independent operations before waiting on any of them, which is what asyncio.gather() does internally.

Q4

When should CPU-heavy work be handed to a Celery task queue instead of just running it in a ProcessPoolExecutor inside the API process?

Once the work is long enough to reasonably outlive a single request-response cycle, needs to survive an API restart or deploy without being lost, needs retries or scheduling, or should run on separate infrastructure so a burst of heavy jobs can't starve the API of CPU. A ProcessPoolExecutor inside the API is fine for short CPU-bound bursts you're comfortable losing and redoing if the process restarts; Celery decouples the work entirely behind a durable queue and independent worker processes, which is the right shape once losing that work would actually matter.