AI applications spend a lot of time waiting.
They wait for a model, a database, a file, or another service to reply. Async Python lets the program use that waiting time to make progress on other work.
It does not make the model faster. It stops one wait from unnecessarily blocking every other independent wait.
Lesson details
- Module
- AI engineering foundations
- Lesson
- 2 · Async Python
- Difficulty
- Beginner
- Learning time
- 38 minutes
Before you start
- Python variables, lists, loops, and functions
- Python foundations for AI applications
Skills you will gain
- Explain when async Python is useful
- Write and await a small coroutine
- Run independent waits concurrently
- Limit concurrent work with a semaphore
- Add a timeout and clean up cancelled work
Why this matters
Imagine a support assistant that needs a customer’s plan, recent tickets, and a classification of their new message. Those three requests are independent. Waiting for each one before starting the next creates a slower answer.
Async code can start all three waits together while still limiting traffic and handling slow services safely.
The intuition
A coroutineCoroutineA function defined with `async def`. Calling it does not run the body — it returns a coroutine object, which only starts executing once you `await` it or schedule it as a task. A coroutine can pause at an `await` and hand control back to the event loop, which is what lets many coroutines make progress on one thread without any of them blocking the others. is a Python function that can pause and resume. It is written with async def.
Concurrency means several tasks make progress during the same period. It does not mean they execute Python instructions at exactly the same instant.
How it works
An async def function is a coroutineCoroutineA function defined with `async def`. Calling it does not run the body — it returns a coroutine object, which only starts executing once you `await` it or schedule it as a task. A coroutine can pause at an `await` and hand control back to the event loop, which is what lets many coroutines make progress on one thread without any of them blocking the others. function. Calling it does not run its body:
async def call_api(name, seconds):
await asyncio.sleep(seconds)
return f"{name}: done"
call_api("plan", 1) # this alone does nothing — it just builds a coroutine object
You get back a coroutine object — a paused, not-yet-started computation — and nothing has happened yet. To actually run it, you await it, or hand it to the scheduler as a task. await is the key word in this whole chapter: it means “pause me here until this is ready, and let something else run in the meantime.” That “something else” is scheduled by the event loopEvent loopThe single-threaded scheduler that runs your coroutines. It keeps a queue of tasks that are ready to make progress; it runs one until it hits an `await` on something not yet finished, then parks it and runs the next ready task. Nothing here runs in parallel — the loop only ever gives the CPU to one task at a time — but no task sits idle waiting for I/O while another task could be making progress. — the single-threaded loop that keeps a list of coroutines that are ready to make progress, runs one until it hits an await on something not yet finished, parks it, and moves on to the next ready one.
In a normal Python script, you start that loop yourself:
import asyncio
async def main():
result = await call_api("plan", 1)
print(result)
asyncio.run(main()) # creates the loop, runs main() until it finishes, closes the loop
To run several coroutines concurrently instead of one after another, hand them to asyncio.gather:
results = await asyncio.gather(
call_api("plan", 1),
call_api("history", 1),
call_api("classify", 1),
)
gather schedules all three as tasks right away, then waits for all of them to finish. Because each one spends its second waiting, not computing, the loop interleaves them — all three waits overlap — so the wall-clock total is close to the slowest one (about 1s), not the sum of all three (3s). Results come back in the order you passed the coroutines in, regardless of which one actually finished first.
A small complete example
import asyncio
async def fetch(name, seconds):
await asyncio.sleep(seconds)
return f"{name}: ready"
async def main():
results = await asyncio.gather(
fetch("plan", 1),
fetch("history", 1),
fetch("classification", 1),
)
print(results)
asyncio.run(main())Important lines
async def fetch(name, seconds):- Defines a coroutine that is allowed to pause.
await asyncio.sleep(seconds)- Represents an external wait and gives control back to the event loop.
await asyncio.gather(...)- Starts the three independent coroutines and waits for every result.
asyncio.run(main())- Creates an event loop for a normal Python script and runs main to completion.
Common modifications include adding another fetch(...) call, giving calls different delays, or wrapping each call in a timeout.
Real APIs come with limits, so unlimited concurrency is not always safe. A semaphoreSemaphoreA counter, shared across coroutines, that limits how many of them may be inside a section of code at once. `asyncio.Semaphore(n)` lets up to `n` coroutines past `acquire()` at the same time; the rest wait until one of the `n` finishes and releases it. Used to cap concurrent calls to a rate-limited API without giving up concurrency altogether. caps how many coroutines may be past a certain point at once:
sem = asyncio.Semaphore(3) # at most 3 "inside" at a time
async def call_api(name, seconds):
async with sem: # waits here if 3 are already inside
await asyncio.sleep(seconds)
return f"{name}: done"
Ten calls can still all be scheduled together with gather; the semaphore just makes the 4th, 5th, … wait their turn until one of the first 3 finishes and frees a slot. You get bounded, not unlimited, concurrency.
Waiting also needs an upper bound in time, not just in count. asyncio.wait_for(coro, timeout=...) raises asyncio.TimeoutError if coro has not finished in time — and it also cancels coro for you:
try:
result = await asyncio.wait_for(slow_call(), timeout=2)
except asyncio.TimeoutError:
result = None
CancellationCancellationAsking a running asyncio task to stop via `task.cancel()`. Cancellation is cooperative, not immediate — it raises `asyncio.CancelledError` inside the task at its next `await` point, so the task keeps running until it reaches one. Code that needs to release a resource on cancellation must do it in a `finally` block, since the exception path still runs it. is cooperative, not immediate: calling .cancel() on a task (or timing it out) raises asyncio.CancelledError inside it at its next await point — the task does not stop mid-instruction, it stops the next time it would have paused anyway. Anything that must run no matter what — closing a connection, releasing a lock — belongs in a finally block, because finally still runs as that CancelledError propagates out.
A worked example
Three calls need durations of 1s, 1s, and 2s. You bound concurrency to 2 with a semaphore, and give each call a 1.5s timeout. Trace it by hand:
| Task | Needs | Acquires semaphore at | Its timeout deadline | What happens |
|---|---|---|---|---|
| A | 1.0s | t = 0.0 (slot free) | 1.5 | finishes at t = 1.0 ✓ |
| B | 1.0s | t = 0.0 (slot free) | 1.5 | finishes at t = 1.0 ✓ |
| C | 2.0s | t = 1.0 (waits for A or B to free a slot) | 1.0 + 1.5 = 2.5 | needs until t = 3.0, but its own timeout fires at t = 2.5 — cancelled ✗ |
Two things happen at once and it is worth separating them. First, C cannot even start its network call until t = 1.0, because both semaphore slots are held by A and B until then — that is the semaphore doing its job, not a bug. Second, once C does start, its own 2.0s of work is longer than its 1.5s timeout budget, so it is cancelled at t = 2.5 regardless of the semaphore. Total wall-clock time for the whole batch: 2.5s — worse than the 2.0s you’d get with no semaphore at all, but with the guarantee that never more than 2 calls are in flight, which is often the actual constraint a real API forces on you.
Try it
Three live cells. The first two use asyncio.sleep to stand in for real network waits — long enough to see the effect, short enough not to be annoying.
First, the hook’s sequential loop next to the concurrent version, so you can see both totals from the same run:
Next, a semaphore capping concurrency at 2, with start/finish timestamps so you can see task C wait its turn:
Finally, a timeout that cancels a slow call — watch the finally block still run:
Production reality
Nearly every LLM client and web framework you will use is built on this. FastAPI request handlers, AsyncOpenAI-style SDK clients, and agent frameworks are all async underneath, for the same reason: a server handling many users at once needs exactly this pattern — lots of I/O-bound waiting, happening concurrently, without spinning up a thread per request.
A few things this chapter’s toy examples hide, which matter the moment you ship:
- Unbounded concurrency gets you rate-limited.
asyncio.gatheron ten thousand coroutines will fire ten thousand requests at once. Real providers respond with429 Too Many Requestslong before that, and you have gained nothing over a much smaller, well-chosen concurrency limit. A semaphore (or a small pool of workers pulling from a queue) is not an optimisation here — it is what keeps you under the provider’s actual limits. - Timeouts are not optional. A dependency that hangs without a timeout can hold a connection open indefinitely. Enough hung requests exhaust your server’s connection pool, and one slow dependency takes the whole service down with it — not just the request that triggered it.
- A silently-failing background task is a real failure mode. If you
asyncio.create_task(...)something and never await or check on it, an exception inside it can vanish with only a"Task exception was never retrieved"warning in the logs — easy to miss until the behaviour it was supposed to produce just never happens. If you want several calls to run concurrently but let some fail without cancelling the rest, useasyncio.gather(..., return_exceptions=True)and check each result explicitly, rather than firing tasks you never look at again. - Async does not help CPU-bound work. Tokenising a huge corpus or running a local model’s forward pass does not get faster inside an
async def— there is still only one thread, and no amount of waiting-reorganisation helps code that is actually computing the whole time. That needs multiprocessing, or moving the work to a service designed for it (Layer 7 covers serving-level concurrency properly).
That last point has a sharper, more dangerous version: mixing a genuinely blocking call into async code.
Common mistakes
Calling a coroutine function without awaiting it — `call_api("x")` on its own.
Fix — This only builds a coroutine object; nothing runs, and Python prints a "coroutine was never awaited" warning. Await it directly, or wrap it with asyncio.create_task(...) if you want it to start running without blocking here.
Putting a real blocking call — time.sleep, a synchronous requests.get, heavy CPU work — inside an async function.
Fix — There is still only one thread. A genuine blocking call occupies that one thread completely, freezing every other concurrent coroutine, not just this one — unlike asyncio.sleep, which yields control back. Use the async version of the client (an async HTTP library, or the SDK's Async* client), or push blocking work off-thread with await asyncio.to_thread(fn, ...).
Catching asyncio.CancelledError with a broad `except Exception` (or a bare `except:`) and swallowing it.
Fix — This eats the cancellation signal, so the task can keep running when the rest of the program has already moved on and given up on it — and it breaks the caller's ability to actually stop it. Only use CancelledError for cleanup (a finally block, or a narrow except that re-raises); never catch it to silently continue.
Firing unbounded concurrency at a real API: asyncio.gather(*(call_api(u) for u in ten_thousand_urls)).
Fix — This is what actually happens with no cap in place, and it trips rate limits or exhausts local sockets instead of finishing faster. Bound it with a semaphore, or a small fixed-size pool of workers pulling from a queue.
Exercises
You have three async functions, `fetch_a()`, `fetch_b()`, `fetch_c()`, each of which awaits `asyncio.sleep(1)` and returns a string. Write the code to run all three concurrently and print the total time taken.
Reveal solution
import asyncio, time
async def fetch_a():
await asyncio.sleep(1)
return 'a'
async def fetch_b():
await asyncio.sleep(1)
return 'b'
async def fetch_c():
await asyncio.sleep(1)
return 'c'
start = time.perf_counter()
results = await asyncio.gather(fetch_a(), fetch_b(), fetch_c())
print(results) # ['a', 'b', 'c']
print(f'{time.perf_counter() - start:.1f}s') # ~1.0s, not 3.0s
All three sleep(1) calls happen during the same one second, because each one yields control back to the event loop instead of blocking it.
You must call `await call_api(url)` for 10 URLs, but the provider allows at most 3 requests in flight at once. Rewrite the loop so at most 3 calls ever run concurrently, using a semaphore.
Reveal solution
import asyncio
sem = asyncio.Semaphore(3)
async def call_api(url):
async with sem: # blocks here if 3 are already inside
return await do_request(url)
async def main(urls):
return await asyncio.gather(*(call_api(u) for u in urls))
results = await main(urls)
asyncio.gather schedules all 10 coroutines at once, but each one blocks on async with sem: until fewer than 3 are currently inside — so exactly 3 are ever running the request body at a time, and the other 7 wait their turn without you writing any manual queueing.
Write `fetch_with_timeout(coro, seconds)` that awaits `coro`, but returns `None` instead of raising if it has not finished within `seconds`. Explain what happens to `coro` itself when the timeout fires.
Reveal solution
import asyncio
async def fetch_with_timeout(coro, seconds):
try:
return await asyncio.wait_for(coro, timeout=seconds)
except asyncio.TimeoutError:
return None
When seconds elapses, asyncio.wait_for cancels the task wrapping coro — this raises asyncio.CancelledError inside coro at its next await point, unwinding it (running any finally blocks on the way), and then wait_for itself raises TimeoutError to the caller, which this function catches and turns into None. The coroutine does not keep running in the background after the timeout; it is actually stopped.
Knowledge check
Knowledge check
Practice activity
Practice · 10–20 minutes
Fetch three mock AI resources concurrently
Goal: Write a small async program that overlaps three independent waits.
- Import asyncio.
- Complete the fetch function so it waits without blocking.
- Use asyncio.gather to fetch profile, history, and policy.
- Print the returned list.
- Run the program and confirm the result order.
Expected result: ['profile: ready', 'history: ready', 'policy: ready']
Optional challenge: Give policy a two-second delay, then add a timeout that returns a fallback value.
Reveal solution
Starter code:
import asyncio
async def fetch(name, delay):
# Pause without blocking, then return "{name}: ready"
pass
async def main():
# Run the three fetch calls concurrently.
pass
asyncio.run(main())import asyncio
async def fetch(name, delay):
await asyncio.sleep(delay)
return f"{name}: ready"
async def main():
results = await asyncio.gather(
fetch("profile", 1),
fetch("history", 1),
fetch("policy", 1),
)
print(results)
asyncio.run(main())Key takeaways
- Most AI code is I/O-bound — waiting on models, databases, and tools — so what async saves is idle waiting, not CPU time.
- async def and await give you coroutines that pause at await points and hand control back to a single-threaded event loop; asyncio.gather runs several concurrently, so wall-clock time approaches the slowest one, not the sum.
- A semaphore bounds how many calls are in flight at once — essential the moment you call a real, rate-limited API instead of a toy example.
- Timeouts (asyncio.wait_for) and cancellation (cleaned up in finally) stop one hung call from hanging your whole program — never ship an unbounded-wait network call.
- A single blocking call inside an async function freezes every other concurrent coroutine, because there is still only one thread — this is the most common way async code breaks in production.
Flashcards
Flashcards
1 of 6Revision and interview questions
- Explain sequential waiting and concurrent waiting using the support-assistant example.
- Why does async help network calls but not heavy CPU calculation?
- How do gather and a semaphore work together?
- What happens during cancellation, and why does finally matter?
One-page sketchnote summary
Print me · one-page revision sheet
Async Python for AI applications
Main idea
Async Python lets one program make progress on other work while a model, database, file, or tool is taking time to reply.
Core terms
- Coroutine — a function that can pause and resume
- Await — pause here and let another task run
- Event loop — schedules ready coroutines
- Semaphore — limits concurrent access
- Timeout — an upper limit on waiting
One picture
Code pattern
results = await asyncio.gather(
fetch("profile"),
fetch("history"),
)Watch out
- Do not forget to await a coroutine
- Do not put blocking sleep inside async code
- Do not send unbounded requests
- Do not swallow cancellation
Remember
- Overlap independent waits
- Async reorganises waiting, not calculation
- Bound concurrency
- Set timeouts
- Clean up in finally
Three quick questions
- When is async useful?
- What does gather return?
- Why limit concurrency?
Lesson checklist
0 of 7 completeResources
- docsPython docs — asyncioThe top-level index for the whole asyncio library — useful once you know roughly what you are looking for.
- docsPython docs — Coroutines and TasksThe precise, authoritative reference for async/await, gather, tasks, timeouts, and cancellation used in this chapter.
- blogReal Python — Async IO in Python: A Complete WalkthroughA slower, example-heavy walkthrough if this chapter's pace feels too fast, including the history of why Python's async model looks the way it does.