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.
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.
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:
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.
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:
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.
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.
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
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:
- Pick two independent I/O-bound calls from your Week 9 microservice project (two
httpxcalls to different services, or one HTTP call plus one database query). - Write a version that awaits them sequentially, and time it with
time.perf_counter()around the calls. - Write a second version using
asyncio.gather()to run them concurrently, timed the same way. - 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.
- Take (or write) a CPU-heavy report-generation function, and move it off the event loop using either
run_in_executorwith aThreadPoolExecutoror aProcessPoolExecutor. - 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.
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?
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?
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?
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?
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.